Files
qrcode-generator/src/components/qr-generator.tsx
T
Florian Wilde 60f46bcdb5 Warnung vor doppelt abgelegten statischen Codes
Ein statischer Code traegt seinen Inhalt selbst — zweimal derselbe Inhalt
ergibt zweimal exakt dasselbe Bild. Bisher entstand der zweite Eintrag
stillschweigend.

Das Anlegen antwortet nun mit 409 und nennt den vorhandenen Eintrag samt
Namen und Datum; die Oberflaeche zeigt das als Warnung mit dem Knopf
"Trotzdem ablegen". Eine Rueckfrage, keine Sperre — zwei Fassungen
desselben Codes koennen gewollt sein.

Verglichen wird Typ und Inhalt innerhalb der Gruppe, nicht die Gestaltung.
Dynamische Codes bleiben aussen vor: zwei Kurz-Links auf dasselbe Ziel
sind der Normalfall, sobald getrennt gezaehlt werden soll.

ApiError traegt dafuer jetzt Status und Antwortkoerper — eine gemeldete
Dublette ist keine Sackgasse, sondern eine Rueckfrage.

Refs #6
2026-08-21 02:53:23 +02:00

592 lines
23 KiB
TypeScript

"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";
import { describeSlugProblem } from "@/lib/slug";
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);
}
/** Der bereits abgelegte Code, den die Dublettenprüfung gemeldet hat. */
type Dublette = { id: string; label: string | null; createdAt: string };
/** Liest die Meldung aus einer 409-Antwort — oder gibt auf. */
function leseDublette(details: unknown): Dublette | null {
if (details === null || typeof details !== "object") return null;
const feld = (details as { duplicate?: unknown }).duplicate;
if (feld === null || typeof feld !== "object") return null;
const { id, label, createdAt } = feld as Record<string, unknown>;
if (typeof id !== "string" || typeof createdAt !== "string") return null;
return { id, label: typeof label === "string" ? label : null, createdAt };
}
const dublettenDatum = new Intl.DateTimeFormat("de-DE", { dateStyle: "medium" });
export function QrGenerator({
typeId,
/** Darf der Kurz-Link selbst vergeben werden? Sonst erscheint das Feld nicht. */
maySetSlug = true,
}: {
typeId: QrTypeId;
maySetSlug?: boolean;
}) {
const qrType = qrTypes[typeId];
const Form = qrForms[typeId];
const [data, setData] = useState<QrFormData>(qrType.defaultData);
const [design, setDesign] = useState<QrDesign>(defaultDesign);
// Typen ohne statische Variante — etwa der App-Link — starten dynamisch.
const [isDynamic, setIsDynamic] = useState(!qrType.supportsStatic);
const [label, setLabel] = useState("");
/** Selbst gewählter Kurz-Link. Leer bedeutet: einen erzeugen lassen. */
const [wunschSlug, setWunschSlug] = useState("");
const [saved, setSaved] = useState<QrCodeDto | null>(null);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
/**
* Gemeldete Dublette samt dem Inhalt, für den sie gilt. Ändert sich der
* Inhalt, ist die Warnung überholt und verschwindet von selbst — ohne
* einen Effekt, der sie hinterherräumt.
*/
const [duplicate, setDuplicate] = useState<
{ treffer: Dublette; inhalt: 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(() => qrType.validate(data), [qrType, data]);
const designValid = useMemo(
() => designSchema.safeParse(design).success,
[design],
);
// Fehler erst zeigen, wenn tatsächlich etwas eingegeben wurde.
const touched = !sameData(data, qrType.defaultData);
const dataError = !validation.ok && touched ? validation.message : null;
const mode = isDynamic ? "dynamic" : "static";
const slug = (isDynamic ? saved?.slug : undefined) ?? undefined;
const requestBody = useMemo(
// Der Name geht mit: Der Server bildet daraus den Dateinamen beim
// Herunterladen. Auf die Vorschau hat er keinen Einfluss.
() => ({ type: typeId, data, design, mode, slug, label: label.trim() || undefined }),
[typeId, data, design, mode, slug, label],
);
const ready = validation.ok && designValid;
/* ------------------------------------------------------------ Vorschau */
const requestId = useRef(0);
useEffect(() => {
if (!ready) return;
const id = (requestId.current += 1);
const controller = new AbortController();
const timer = setTimeout(() => {
setPreviewLoading(true);
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]);
// Solange die Eingaben unvollständig sind, gibt es nichts anzuzeigen. Das
// wird abgeleitet statt im Effekt zurückgesetzt — so bleibt beim Tippen die
// zuletzt gültige Vorschau stehen, statt bei jedem Zeichen zu flackern.
const shownPreview = ready ? preview : null;
const shownPreviewError = ready ? previewError : null;
const shownPreviewLoading = ready && previewLoading;
/* ------------------------------------------------------------ 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 */
// Sofortige Rückmeldung, damit man nicht erst beim Speichern erfährt, dass
// ein Zeichen nicht erlaubt ist.
const slugProblem =
wunschSlug.trim().length > 0 ? describeSlugProblem(wunschSlug) : null;
const hasUnsavedChanges =
saved !== null &&
(!sameData(saved.data, validation.ok ? validation.data : data) ||
!sameData(saved.design, design) ||
(saved.label ?? "") !== label.trim());
// Nur zeigen, solange der Inhalt derselbe ist, für den die Meldung kam.
const dublette =
duplicate !== null &&
duplicate.inhalt === stableStringify(validation.ok ? validation.data : data)
? duplicate.treffer
: null;
const handleSave = useCallback(async (trotzDublette = false) => {
if (!validation.ok || !designValid || slugProblem) return;
setSaving(true);
setSaveError(null);
setDuplicate(null);
try {
const body = {
type: typeId,
data: validation.data,
design,
mode,
label: label.trim() || undefined,
// Ein statischer Code hat keinen Kurz-Link.
slug: isDynamic ? wunschSlug.trim() || undefined : undefined,
// Nur beim zweiten Anlauf, nachdem die Warnung dastand.
allowDuplicate: trotzDublette,
};
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) {
// Eine gemeldete Dublette ist kein Fehler, sondern eine Rückfrage.
const gemeldet =
error instanceof ApiError && error.status === 409
? leseDublette(error.details)
: null;
if (gemeldet) {
setDuplicate({ treffer: gemeldet, inhalt: stableStringify(validation.data) });
} else {
setSaveError(
error instanceof ApiError
? error.message
: "Der Code konnte nicht gespeichert werden.",
);
}
} finally {
setSaving(false);
}
}, [validation, designValid, slugProblem, typeId, design, mode, isDynamic, label, wunschSlug, saved]);
/**
* Wechselt die Betriebsart und vergisst dabei einen zuvor gespeicherten
* Code. Ein abgelegter statischer Eintrag ist etwas anderes als ein
* gespeicherter dynamischer — beides gleichzeitig festzuhalten führte
* dazu, dass die Oberfläche den falschen Zustand zeigt.
*/
const wechsleModus = useCallback((dynamisch: boolean) => {
setIsDynamic(dynamisch);
setSaved(null);
setSaveError(null);
setDuplicate(null);
}, []);
const handleCopy = useCallback(async () => {
if (!saved?.shortUrl) 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-accent-soft text-accent-text">
<Icon name={qrType.icon} className="size-6" />
</span>
<div>
<h1 className="text-2xl font-bold tracking-tight text-slate-900">
{qrType.title}-QR-Code erstellen
</h1>
<p className="text-sm text-slate-600">{qrType.description}</p>
</div>
</div>
{/*
Die Rasterplätze werden ab „lg“ ausdrücklich vergeben. Dadurch steht
die Vorschau auf dem Handy direkt unter der Eingabe — statt ganz unten
hinter der Gestaltung — und auf dem Desktop trotzdem rechts daneben.
*/}
<div className="grid grid-cols-1 items-start gap-6 lg:grid-cols-[minmax(0,1fr)_380px]">
<div className="lg:col-start-1 lg:row-start-1">
<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>
</div>
<div className="lg:sticky lg:top-6 lg:col-start-2 lg:row-start-1 lg:row-span-3 lg:self-start">
<Card>
<CardTitle>Vorschau</CardTitle>
<QrPreview
dataUrl={shownPreview?.dataUrl ?? null}
content={shownPreview?.content ?? null}
size={design.size}
error={shownPreviewError ?? downloadError}
loading={shownPreviewLoading}
downloadDisabledReason={downloadDisabledReason}
onDownload={handleDownload}
downloading={downloading}
/>
</Card>
</div>
<div className="lg:col-start-1 lg:row-start-2">
<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"
data-mode="static"
aria-pressed={!isDynamic}
disabled={!qrType.supportsStatic}
onClick={() => wechsleModus(false)}
className={cx(
"rounded-xl border p-4 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-50",
!isDynamic
? "border-indigo-600 bg-accent-soft/60"
: "border-slate-300 bg-surface 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">
{qrType.supportsStatic
? "Funktioniert ohne diesen Server, dafür unveränderlich."
: `Für „${qrType.title}“ nicht möglich.`}
</span>
</button>
<button
type="button"
data-mode="dynamic"
aria-pressed={isDynamic}
disabled={!qrType.supportsDynamic}
onClick={() => wechsleModus(true)}
className={cx(
"rounded-xl border p-4 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-50",
isDynamic
? "border-indigo-600 bg-accent-soft/60"
: "border-slate-300 bg-surface 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">
{qrType.supportsDynamic
? "Ziel jederzeit änderbar, Scans werden gezählt."
: `Für „${qrType.title}“ nicht möglich — die Daten stecken im Code selbst.`}
</span>
</button>
</div>
<div className="mt-5 space-y-4 border-t border-slate-200 pt-5">
<Field
label="Name (optional)"
htmlFor="code-label"
hint={
isDynamic
? "Hilft dir, den Code später in „Meine Codes“ wiederzufinden."
: "Bestimmt den Dateinamen beim Herunterladen — und den Eintrag, falls du ihn ablegst."
}
>
<TextInput
id="code-label"
value={label}
maxLength={120}
placeholder="z. B. Plakat Eingang"
onChange={(event) => setLabel(event.target.value)}
/>
</Field>
{!isDynamic || saved || !maySetSlug ? null : (
<Field
label="Kurz-Link (optional)"
htmlFor="code-slug"
error={slugProblem}
hint="Leer lassen, dann wird einer erzeugt. Kleinbuchstaben, Ziffern, Bindestriche und „/“ als Trenner."
>
<TextInput
id="code-slug"
value={wunschSlug}
maxLength={100}
spellCheck={false}
invalid={Boolean(slugProblem)}
placeholder="z. B. sommeraktion oder kunde-a/flyer"
onChange={(event) => setWunschSlug(event.target.value)}
/>
</Field>
)}
{!isDynamic ? (
/* Statisch: Der Code ist ohne Speichern fertig. Ablegen ist
ein Angebot, kein Zwang — es geht nur ums Wiederfinden.
Nach einer Inhaltsänderung ist die Ablage ein Schnappschuss
von vorher; dann wird sie wieder angeboten. */
saved && !hasUnsavedChanges ? (
<div className="space-y-3">
<Notice tone="success">
<span className="font-medium">Abgelegt.</span> Du findest
den Code jetzt unter „Meine Codes“ und kannst ihn dort
erneut herunterladen. Der Code selbst ändert sich dadurch
nicht — sein Inhalt steckt in ihm.
</Notice>
<Link
href="/meine-codes"
className="inline-flex items-center gap-1.5 rounded-lg text-sm font-medium text-accent-text hover:text-accent-soft-fg"
>
Zu „Meine Codes“
<Icon name="external" className="size-3.5" />
</Link>
</div>
) : (
<div className="space-y-3">
{dublette ? (
<Notice tone="warning">
<span className="font-medium">
Diesen Inhalt hast du schon abgelegt
</span>{" "}
— {dublette.label ? `„${dublette.label}“` : "ohne Namen"}{" "}
vom {dublettenDatum.format(new Date(dublette.createdAt))}.
Zweimal derselbe Inhalt ergibt zweimal denselben Code.
<span className="mt-1 block text-xs">
Verglichen wird nur der Inhalt, nicht die Gestaltung —
derselbe Code in anderen Farben gilt hier als Dublette.
</span>
<span className="mt-2 flex flex-wrap items-center gap-3">
<Link
href="/meine-codes"
className="inline-flex items-center gap-1.5 rounded-lg text-sm font-medium underline"
>
Den vorhandenen ansehen
<Icon name="external" className="size-3.5" />
</Link>
</span>
</Notice>
) : null}
<Button
type="button"
variant="secondary"
onClick={() => void handleSave(dublette !== null)}
disabled={saving || !ready}
>
{saving
? "Legt ab …"
: dublette
? "Trotzdem ablegen"
: "In „Meine Codes“ ablegen"}
</Button>
</div>
)
) : 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-surface/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={() => void 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-accent-text hover:text-accent-soft-fg"
>
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={() => void handleSave()}
disabled={saving || !ready || Boolean(slugProblem)}
>
{saving ? "Speichert …" : "Dynamischen Code speichern"}
</Button>
</div>
)}
{saveError ? <Notice tone="error">{saveError}</Notice> : null}
</div>
</Card>
</div>
<div className="lg:col-start-1 lg:row-start-3">
<Card>
<CardTitle hint="Farben, Größe und Fehlerkorrektur gelten für Vorschau und Download.">
Gestaltung
</CardTitle>
<DesignPanel design={design} onChange={setDesign} />
</Card>
</div>
</div>
</div>
);
}