V1: Next.js-App mit QR-Typ-Registry, Website-Generator und dynamischen Codes
- Erweiterbare Registry aller 15 QR-Typen; nur "Website" ist aktiv, die
uebrigen erscheinen als Platzhalter mit Badge "Bald verfuegbar".
- defineQrType() kapselt Zod-Schema, Kodierung und Redirect-Ziel je Typ,
sodass ein V2-Typ nur aus einem Registry-Eintrag plus Formular besteht.
- Serverseitige QR-Erzeugung (PNG/SVG) mit Farben, Groesse, Fehlerkorrektur
und Rand; Live-Vorschau ueber /api/qr/preview.
- Dynamische Codes: Kurz-URL {BASE_URL}/r/{slug}, 302-Redirect mit
Scan-Zaehler, CRUD unter /api/codes.
- Prisma 7 mit pg-Driver-Adapter und Initial-Migration fuer PostgreSQL.
- Deutsche Oberflaeche mit Tailwind CSS, helles Theme, responsiv.
This commit is contained in:
@@ -0,0 +1,396 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { Icon } from "@/components/icons";
|
||||
import { Modal } from "@/components/modal";
|
||||
import { qrForms } from "@/components/qr-forms";
|
||||
import { Badge, Button, Card, Field, Notice, TextInput } from "@/components/ui";
|
||||
import { ApiError, apiJson } from "@/lib/api-client";
|
||||
import type { QrCodeDto } from "@/lib/dto";
|
||||
import { qrTypes } from "@/lib/qr/registry";
|
||||
import type { QrFormData } from "@/lib/qr/type-module";
|
||||
|
||||
const dateFormat = new Intl.DateTimeFormat("de-DE", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
});
|
||||
|
||||
function formatDate(value: string | null): string {
|
||||
if (!value) return "—";
|
||||
return dateFormat.format(new Date(value));
|
||||
}
|
||||
|
||||
export function CodesList() {
|
||||
const [codes, setCodes] = useState<QrCodeDto[] | null>(null);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
|
||||
const [editing, setEditing] = useState<QrCodeDto | null>(null);
|
||||
const [editData, setEditData] = useState<QrFormData>({});
|
||||
const [editLabel, setEditLabel] = useState("");
|
||||
const [editError, setEditError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const [deleting, setDeleting] = useState<QrCodeDto | null>(null);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
const [removing, setRemoving] = useState(false);
|
||||
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const result = await apiJson<{ codes: QrCodeDto[] }>("/api/codes");
|
||||
setCodes(result.codes);
|
||||
setLoadError(null);
|
||||
} catch (error) {
|
||||
setCodes([]);
|
||||
setLoadError(
|
||||
error instanceof ApiError
|
||||
? error.message
|
||||
: "Die Liste konnte nicht geladen werden.",
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const openEdit = (code: QrCodeDto) => {
|
||||
setEditing(code);
|
||||
setEditData(code.data);
|
||||
setEditLabel(code.label ?? "");
|
||||
setEditError(null);
|
||||
};
|
||||
|
||||
const EditForm = editing ? qrForms[editing.type] : undefined;
|
||||
const editValidation = useMemo(
|
||||
() => (editing ? qrTypes[editing.type].validate(editData) : null),
|
||||
[editing, editData],
|
||||
);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!editing || !editValidation) return;
|
||||
if (!editValidation.ok) {
|
||||
setEditError(editValidation.message);
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setEditError(null);
|
||||
try {
|
||||
await apiJson<{ code: QrCodeDto }>(`/api/codes/${editing.id}`, {
|
||||
method: "PATCH",
|
||||
json: { data: editValidation.data, label: editLabel.trim() || null },
|
||||
});
|
||||
setEditing(null);
|
||||
await load();
|
||||
} catch (error) {
|
||||
setEditError(
|
||||
error instanceof ApiError
|
||||
? error.message
|
||||
: "Die Änderung konnte nicht gespeichert werden.",
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleting) return;
|
||||
setRemoving(true);
|
||||
setDeleteError(null);
|
||||
try {
|
||||
await apiJson(`/api/codes/${deleting.id}`, { method: "DELETE" });
|
||||
setDeleting(null);
|
||||
await load();
|
||||
} catch (error) {
|
||||
setDeleteError(
|
||||
error instanceof ApiError
|
||||
? error.message
|
||||
: "Der Code konnte nicht gelöscht werden.",
|
||||
);
|
||||
} finally {
|
||||
setRemoving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copy = async (code: QrCodeDto) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(code.shortUrl);
|
||||
setCopiedId(code.id);
|
||||
setTimeout(() => setCopiedId(null), 2000);
|
||||
} catch {
|
||||
setCopiedId(null);
|
||||
}
|
||||
};
|
||||
|
||||
/* ---------------------------------------------------------------- View */
|
||||
|
||||
if (codes === null) {
|
||||
return <p className="text-sm text-slate-500">Codes werden geladen …</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{loadError ? <Notice tone="error">{loadError}</Notice> : null}
|
||||
|
||||
{codes.length === 0 && !loadError ? (
|
||||
<Card className="text-center">
|
||||
<span className="mx-auto flex size-14 items-center justify-center rounded-2xl bg-slate-100 text-slate-400">
|
||||
<Icon name="qr" className="size-7" />
|
||||
</span>
|
||||
<h2 className="mt-4 text-lg font-semibold text-slate-900">
|
||||
Noch keine dynamischen Codes
|
||||
</h2>
|
||||
<p className="mx-auto mt-2 max-w-md text-sm text-slate-600">
|
||||
Erstelle einen QR-Code und wähle dabei „Dynamisch“ — dann erscheint
|
||||
er hier und du kannst sein Ziel jederzeit ändern.
|
||||
</p>
|
||||
<Link
|
||||
href="/erstellen/website"
|
||||
className="mt-5 inline-flex items-center gap-2 rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-indigo-700"
|
||||
>
|
||||
QR-Code erstellen
|
||||
</Link>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{codes.map((code) => (
|
||||
<Card key={code.id}>
|
||||
<div className="flex flex-col gap-5 sm:flex-row">
|
||||
<a
|
||||
href={`/api/codes/${code.id}/qr?format=png&download=1`}
|
||||
title="Als PNG herunterladen"
|
||||
className="qr-canvas size-28 shrink-0 self-start rounded-xl border border-slate-200 p-1.5"
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- vom Server gerendertes SVG */}
|
||||
<img
|
||||
src={`/api/codes/${code.id}/qr`}
|
||||
alt={`QR-Code für ${code.summary}`}
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
</a>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="font-semibold text-slate-900">
|
||||
{code.label ?? code.summary}
|
||||
</h2>
|
||||
<Badge tone="neutral">{qrTypes[code.type]?.title ?? code.type}</Badge>
|
||||
</div>
|
||||
|
||||
<dl className="mt-3 space-y-2 text-sm">
|
||||
<div className="flex flex-wrap items-baseline gap-x-2">
|
||||
<dt className="text-slate-500">Kurz-URL:</dt>
|
||||
<dd className="flex items-center gap-2">
|
||||
<a
|
||||
href={code.shortUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-mono text-xs break-all text-indigo-600 hover:underline"
|
||||
>
|
||||
{code.shortUrl}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copy(code)}
|
||||
title="Kurz-URL kopieren"
|
||||
className="rounded p-1 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700"
|
||||
>
|
||||
<Icon
|
||||
name={copiedId === code.id ? "check" : "copy"}
|
||||
className="size-4"
|
||||
/>
|
||||
</button>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-baseline gap-x-2">
|
||||
<dt className="text-slate-500">Ziel:</dt>
|
||||
<dd className="min-w-0 break-all text-slate-800">
|
||||
{code.target ? (
|
||||
<a
|
||||
href={code.target}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="hover:underline"
|
||||
>
|
||||
{code.target}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-red-600">{code.summary}</span>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-x-5 gap-y-1 text-sm text-slate-500">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Icon name="scan" className="size-4" />
|
||||
<strong className="font-semibold text-slate-900 tabular-nums">
|
||||
{code.scans}
|
||||
</strong>
|
||||
{code.scans === 1 ? "Scan" : "Scans"}
|
||||
</span>
|
||||
<span>Erstellt: {formatDate(code.createdAt)}</span>
|
||||
<span>Letzter Scan: {formatDate(code.lastScanAt)}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => openEdit(code)}
|
||||
>
|
||||
<Icon name="pencil" className="size-3.5" />
|
||||
Ziel bearbeiten
|
||||
</Button>
|
||||
<a
|
||||
href={`/api/codes/${code.id}/qr?format=png&download=1`}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50"
|
||||
>
|
||||
<Icon name="download" className="size-3.5" />
|
||||
PNG
|
||||
</a>
|
||||
<a
|
||||
href={`/api/codes/${code.id}/qr?format=svg&download=1`}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50"
|
||||
>
|
||||
<Icon name="download" className="size-3.5" />
|
||||
SVG
|
||||
</a>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-red-600 hover:bg-red-50"
|
||||
onClick={() => {
|
||||
setDeleting(code);
|
||||
setDeleteError(null);
|
||||
}}
|
||||
>
|
||||
<Icon name="trash" className="size-3.5" />
|
||||
Löschen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{/* ------------------------------------------------------ Bearbeiten */}
|
||||
<Modal
|
||||
open={editing !== null}
|
||||
title="Ziel bearbeiten"
|
||||
onClose={() => setEditing(null)}
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => setEditing(null)}
|
||||
disabled={saving}
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="button" onClick={() => void handleSave()} disabled={saving}>
|
||||
{saving ? "Speichert …" : "Speichern"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{editing ? (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-slate-600">
|
||||
Der gedruckte QR-Code bleibt unverändert gültig — er zeigt weiterhin
|
||||
auf{" "}
|
||||
<code className="font-mono text-xs break-all">
|
||||
{editing.shortUrl}
|
||||
</code>
|
||||
.
|
||||
</p>
|
||||
|
||||
{EditForm ? (
|
||||
<EditForm
|
||||
value={editData}
|
||||
onChange={(value) => {
|
||||
setEditData(value);
|
||||
setEditError(null);
|
||||
}}
|
||||
error={editError}
|
||||
disabled={saving}
|
||||
/>
|
||||
) : (
|
||||
<Notice tone="error">
|
||||
Für diesen Typ ist kein Formular verfügbar.
|
||||
</Notice>
|
||||
)}
|
||||
|
||||
<Field label="Name (optional)" htmlFor="edit-label">
|
||||
<TextInput
|
||||
id="edit-label"
|
||||
value={editLabel}
|
||||
maxLength={120}
|
||||
placeholder="z. B. Plakat Eingang"
|
||||
disabled={saving}
|
||||
onChange={(event) => setEditLabel(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
|
||||
{/* --------------------------------------------------------- Löschen */}
|
||||
<Modal
|
||||
open={deleting !== null}
|
||||
title="QR-Code löschen?"
|
||||
onClose={() => setDeleting(null)}
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => setDeleting(null)}
|
||||
disabled={removing}
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="danger"
|
||||
onClick={() => void handleDelete()}
|
||||
disabled={removing}
|
||||
>
|
||||
{removing ? "Löscht …" : "Endgültig löschen"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{deleting ? (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-slate-600">
|
||||
Der Code{" "}
|
||||
<strong className="text-slate-900">
|
||||
{deleting.label ?? deleting.summary}
|
||||
</strong>{" "}
|
||||
wird gelöscht. Bereits gedruckte oder verteilte Codes mit der
|
||||
Kurz-URL{" "}
|
||||
<code className="font-mono text-xs break-all">
|
||||
{deleting.shortUrl}
|
||||
</code>{" "}
|
||||
führen danach ins Leere. Das lässt sich nicht rückgängig machen.
|
||||
</p>
|
||||
{deleting.scans > 0 ? (
|
||||
<Notice tone="warning">
|
||||
Dieser Code wurde bereits {deleting.scans}-mal gescannt.
|
||||
</Notice>
|
||||
) : null}
|
||||
{deleteError ? <Notice tone="error">{deleteError}</Notice> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user