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
592 lines
23 KiB
TypeScript
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>
|
|
);
|
|
}
|