Files
qrcode-generator/src/components/qr-generator.tsx
T
Florian Wilde 4c8adf3f4a Vorschau-Layout und Hintergrund der Vorschauflaeche verbessert
Auf dem Handy stand die Vorschau ganz unten hinter der Gestaltung — man
musste an allen Reglern vorbeiscrollen, um den Code zu sehen. Die
Rasterplaetze werden jetzt ab "lg" ausdruecklich vergeben: auf dem Handy
folgt die Vorschau direkt auf die Eingabe, auf dem Desktop steht sie
weiterhin rechts daneben.

Das Schachbrettmuster hinter der Vorschau entfaellt. Es sollte
Transparenz sichtbar machen, aber jeder erzeugte Code bringt eine
deckende Hintergrundfarbe mit — es war nur Unruhe.
2026-08-13 16:59:49 +02:00

428 lines
15 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";
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 qrType = qrTypes[typeId];
const Form = qrForms[typeId];
const [data, setData] = useState<QrFormData>(qrType.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(() => 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;
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) 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 */
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={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"
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={!qrType.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>
</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>
);
}