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:
Florian Wilde
2026-08-13 15:54:47 +02:00
commit ad4cd92f8e
52 changed files with 12348 additions and 0 deletions
+396
View File
@@ -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>
);
}