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.
428 lines
15 KiB
TypeScript
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>
|
|
);
|
|
}
|