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,418 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { Icon } from "@/components/icons";
|
||||
import { DesignPanel } from "@/components/design-panel";
|
||||
import { QrPreview } from "@/components/qr-preview";
|
||||
import { qrForms } from "@/components/qr-forms";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardTitle,
|
||||
Field,
|
||||
Notice,
|
||||
TextInput,
|
||||
cx,
|
||||
} from "@/components/ui";
|
||||
import { ApiError, apiJson, downloadFile } from "@/lib/api-client";
|
||||
import type { QrCodeDto } from "@/lib/dto";
|
||||
import { defaultDesign, designSchema, type QrDesign } from "@/lib/qr/design";
|
||||
import { qrTypes } from "@/lib/qr/registry";
|
||||
import type { QrFormData, QrTypeId } from "@/lib/qr/type-module";
|
||||
|
||||
type PreviewState = { dataUrl: string; content: string };
|
||||
|
||||
/** Vergleicht zwei Datenobjekte unabhängig von der Schlüsselreihenfolge. */
|
||||
function stableStringify(value: unknown): string {
|
||||
return JSON.stringify(value, (_key, inner: unknown) =>
|
||||
inner !== null && typeof inner === "object" && !Array.isArray(inner)
|
||||
? Object.fromEntries(
|
||||
Object.entries(inner as Record<string, unknown>).sort(([a], [b]) =>
|
||||
a.localeCompare(b),
|
||||
),
|
||||
)
|
||||
: inner,
|
||||
);
|
||||
}
|
||||
|
||||
function sameData(a: unknown, b: unknown): boolean {
|
||||
return stableStringify(a) === stableStringify(b);
|
||||
}
|
||||
|
||||
export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
|
||||
const module = qrTypes[typeId];
|
||||
const Form = qrForms[typeId];
|
||||
|
||||
const [data, setData] = useState<QrFormData>(module.defaultData);
|
||||
const [design, setDesign] = useState<QrDesign>(defaultDesign);
|
||||
const [isDynamic, setIsDynamic] = useState(false);
|
||||
const [label, setLabel] = useState("");
|
||||
|
||||
const [saved, setSaved] = useState<QrCodeDto | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const [preview, setPreview] = useState<PreviewState | null>(null);
|
||||
const [previewError, setPreviewError] = useState<string | null>(null);
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
const [downloading, setDownloading] = useState<"png" | "svg" | null>(null);
|
||||
const [downloadError, setDownloadError] = useState<string | null>(null);
|
||||
|
||||
const validation = useMemo(() => module.validate(data), [module, data]);
|
||||
const designValid = useMemo(
|
||||
() => designSchema.safeParse(design).success,
|
||||
[design],
|
||||
);
|
||||
|
||||
// Fehler erst zeigen, wenn tatsächlich etwas eingegeben wurde.
|
||||
const touched = !sameData(data, module.defaultData);
|
||||
const dataError = !validation.ok && touched ? validation.message : null;
|
||||
|
||||
const mode = isDynamic ? "dynamic" : "static";
|
||||
const slug = isDynamic ? saved?.slug : undefined;
|
||||
|
||||
const requestBody = useMemo(
|
||||
() => ({ type: typeId, data, design, mode, slug }),
|
||||
[typeId, data, design, mode, slug],
|
||||
);
|
||||
|
||||
const ready = validation.ok && designValid;
|
||||
|
||||
/* ------------------------------------------------------------ Vorschau */
|
||||
|
||||
const requestId = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready) {
|
||||
setPreview(null);
|
||||
setPreviewError(null);
|
||||
setPreviewLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const id = (requestId.current += 1);
|
||||
const controller = new AbortController();
|
||||
setPreviewLoading(true);
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
apiJson<PreviewState>("/api/qr/preview", {
|
||||
method: "POST",
|
||||
json: requestBody,
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then((result) => {
|
||||
if (id !== requestId.current) return;
|
||||
setPreview(result);
|
||||
setPreviewError(null);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (controller.signal.aborted || id !== requestId.current) return;
|
||||
setPreview(null);
|
||||
setPreviewError(
|
||||
error instanceof ApiError
|
||||
? error.message
|
||||
: "Die Vorschau konnte nicht geladen werden.",
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
if (id === requestId.current) setPreviewLoading(false);
|
||||
});
|
||||
}, 250);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
controller.abort();
|
||||
};
|
||||
}, [ready, requestBody]);
|
||||
|
||||
/* ------------------------------------------------------------ Download */
|
||||
|
||||
const downloadDisabledReason =
|
||||
isDynamic && !saved
|
||||
? "Speichere den dynamischen Code, damit die Kurz-URL entsteht — danach ist der Download möglich."
|
||||
: null;
|
||||
|
||||
const handleDownload = useCallback(
|
||||
async (format: "png" | "svg") => {
|
||||
setDownloadError(null);
|
||||
setDownloading(format);
|
||||
try {
|
||||
await downloadFile(
|
||||
"/api/qr/download",
|
||||
{ ...requestBody, format },
|
||||
`qr-code.${format}`,
|
||||
);
|
||||
} catch (error) {
|
||||
setDownloadError(
|
||||
error instanceof ApiError
|
||||
? error.message
|
||||
: "Der Download ist fehlgeschlagen.",
|
||||
);
|
||||
} finally {
|
||||
setDownloading(null);
|
||||
}
|
||||
},
|
||||
[requestBody],
|
||||
);
|
||||
|
||||
/* ----------------------------------------------- Dynamischen Code speichern */
|
||||
|
||||
const hasUnsavedChanges =
|
||||
saved !== null &&
|
||||
(!sameData(saved.data, validation.ok ? validation.data : data) ||
|
||||
!sameData(saved.design, design) ||
|
||||
(saved.label ?? "") !== label.trim());
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!validation.ok || !designValid) return;
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
try {
|
||||
const body = {
|
||||
type: typeId,
|
||||
data: validation.data,
|
||||
design,
|
||||
label: label.trim() || undefined,
|
||||
};
|
||||
const result = saved
|
||||
? await apiJson<{ code: QrCodeDto }>(`/api/codes/${saved.id}`, {
|
||||
method: "PATCH",
|
||||
json: { data: validation.data, design, label: label.trim() || null },
|
||||
})
|
||||
: await apiJson<{ code: QrCodeDto }>("/api/codes", {
|
||||
method: "POST",
|
||||
json: body,
|
||||
});
|
||||
setSaved(result.code);
|
||||
} catch (error) {
|
||||
setSaveError(
|
||||
error instanceof ApiError
|
||||
? error.message
|
||||
: "Der Code konnte nicht gespeichert werden.",
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [validation, designValid, typeId, design, label, saved]);
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
if (!saved) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(saved.shortUrl);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
setCopied(false);
|
||||
}
|
||||
}, [saved]);
|
||||
|
||||
/* ---------------------------------------------------------------- View */
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-4 py-8 sm:px-6 sm:py-10">
|
||||
<Link
|
||||
href="/"
|
||||
className="mb-5 inline-flex items-center gap-1.5 rounded-lg text-sm font-medium text-slate-600 transition-colors hover:text-slate-900"
|
||||
>
|
||||
<Icon name="arrowLeft" className="size-4" />
|
||||
Alle Typen
|
||||
</Link>
|
||||
|
||||
<div className="mb-7 flex flex-wrap items-center gap-3">
|
||||
<span className="flex size-11 items-center justify-center rounded-xl bg-indigo-50 text-indigo-600">
|
||||
<Icon name={module.icon} className="size-6" />
|
||||
</span>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-slate-900">
|
||||
{module.title}-QR-Code erstellen
|
||||
</h1>
|
||||
<p className="text-sm text-slate-600">{module.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 items-start gap-6 lg:grid-cols-[minmax(0,1fr)_380px]">
|
||||
{/* -------------------------------------------------- linke Spalte */}
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardTitle>Inhalt</CardTitle>
|
||||
{Form ? (
|
||||
<Form value={data} onChange={setData} error={dataError} />
|
||||
) : (
|
||||
<Notice tone="error">
|
||||
Für diesen Typ ist noch kein Formular hinterlegt.
|
||||
</Notice>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardTitle
|
||||
hint="Statisch: die Adresse steckt direkt im Code und lässt sich später nicht mehr ändern. Dynamisch: der Code zeigt auf eine Kurz-URL, deren Ziel du jederzeit anpassen kannst."
|
||||
>
|
||||
Art des Codes
|
||||
</CardTitle>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={!isDynamic}
|
||||
onClick={() => setIsDynamic(false)}
|
||||
className={cx(
|
||||
"rounded-xl border p-4 text-left transition-colors",
|
||||
!isDynamic
|
||||
? "border-indigo-600 bg-indigo-50/60"
|
||||
: "border-slate-300 bg-white hover:bg-slate-50",
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2 font-medium text-slate-900">
|
||||
<Icon name="lock" className="size-4" />
|
||||
Statisch
|
||||
</span>
|
||||
<span className="mt-1 block text-sm text-slate-600">
|
||||
Funktioniert ohne diesen Server, dafür unveränderlich.
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={isDynamic}
|
||||
disabled={!module.supportsDynamic}
|
||||
onClick={() => setIsDynamic(true)}
|
||||
className={cx(
|
||||
"rounded-xl border p-4 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-50",
|
||||
isDynamic
|
||||
? "border-indigo-600 bg-indigo-50/60"
|
||||
: "border-slate-300 bg-white hover:bg-slate-50",
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2 font-medium text-slate-900">
|
||||
<Icon name="scan" className="size-4" />
|
||||
Dynamisch
|
||||
<Badge tone="neutral">mit Statistik</Badge>
|
||||
</span>
|
||||
<span className="mt-1 block text-sm text-slate-600">
|
||||
Ziel jederzeit änderbar, Scans werden gezählt.
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isDynamic ? (
|
||||
<div className="mt-5 space-y-4 border-t border-slate-200 pt-5">
|
||||
<Field
|
||||
label="Name (optional)"
|
||||
htmlFor="code-label"
|
||||
hint="Hilft dir, den Code später in „Meine Codes“ wiederzufinden."
|
||||
>
|
||||
<TextInput
|
||||
id="code-label"
|
||||
value={label}
|
||||
maxLength={120}
|
||||
placeholder="z. B. Plakat Eingang"
|
||||
onChange={(event) => setLabel(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{saved ? (
|
||||
<div className="space-y-3">
|
||||
<Notice tone="success">
|
||||
<span className="font-medium">Code gespeichert.</span> Der
|
||||
QR-Code zeigt dauerhaft auf diese Kurz-URL:
|
||||
<span className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<code className="rounded bg-white/70 px-2 py-1 font-mono text-xs break-all">
|
||||
{saved.shortUrl}
|
||||
</code>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
<Icon
|
||||
name={copied ? "check" : "copy"}
|
||||
className="size-3.5"
|
||||
/>
|
||||
{copied ? "Kopiert" : "Kopieren"}
|
||||
</Button>
|
||||
</span>
|
||||
</Notice>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{hasUnsavedChanges ? (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saving || !ready}
|
||||
>
|
||||
{saving ? "Speichert …" : "Änderungen speichern"}
|
||||
</Button>
|
||||
) : null}
|
||||
<Link
|
||||
href="/meine-codes"
|
||||
className="inline-flex items-center gap-1.5 rounded-lg text-sm font-medium text-indigo-600 hover:text-indigo-700"
|
||||
>
|
||||
Zu „Meine Codes"
|
||||
<Icon name="external" className="size-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{hasUnsavedChanges ? (
|
||||
<p className="text-sm text-amber-700">
|
||||
Du hast Änderungen vorgenommen, die noch nicht gespeichert
|
||||
sind. Der gedruckte Code bleibt gültig — nur das Ziel
|
||||
ändert sich.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<Notice tone="info">
|
||||
Die Vorschau zeigt einen Platzhalter. Die endgültige
|
||||
Kurz-URL — und damit der fertige QR-Code — entsteht beim
|
||||
Speichern.
|
||||
</Notice>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saving || !ready}
|
||||
>
|
||||
{saving ? "Speichert …" : "Dynamischen Code speichern"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{saveError ? <Notice tone="error">{saveError}</Notice> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardTitle hint="Farben, Größe und Fehlerkorrektur gelten für Vorschau und Download.">
|
||||
Gestaltung
|
||||
</CardTitle>
|
||||
<DesignPanel design={design} onChange={setDesign} />
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* ------------------------------------------------- rechte Spalte */}
|
||||
<div className="lg:sticky lg:top-6">
|
||||
<Card>
|
||||
<CardTitle>Vorschau</CardTitle>
|
||||
<QrPreview
|
||||
dataUrl={preview?.dataUrl ?? null}
|
||||
content={preview?.content ?? null}
|
||||
size={design.size}
|
||||
error={previewError ?? downloadError}
|
||||
loading={previewLoading}
|
||||
downloadDisabledReason={downloadDisabledReason}
|
||||
onDownload={handleDownload}
|
||||
downloading={downloading}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user