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
This commit is contained in:
Florian Wilde
2026-08-21 02:53:23 +02:00
parent b96e9ddbe5
commit 60f46bcdb5
5 changed files with 218 additions and 30 deletions
+88 -16
View File
@@ -43,6 +43,21 @@ 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. */
@@ -65,6 +80,14 @@ export function QrGenerator({
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);
@@ -187,10 +210,18 @@ export function QrGenerator({
!sameData(saved.design, design) ||
(saved.label ?? "") !== label.trim());
const handleSave = useCallback(async () => {
// 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,
@@ -200,6 +231,8 @@ export function QrGenerator({
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}`, {
@@ -212,11 +245,20 @@ export function QrGenerator({
});
setSaved(result.code);
} catch (error) {
setSaveError(
error instanceof ApiError
? error.message
: "Der Code konnte nicht gespeichert werden.",
);
// 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);
}
@@ -232,6 +274,7 @@ export function QrGenerator({
setIsDynamic(dynamisch);
setSaved(null);
setSaveError(null);
setDuplicate(null);
}, []);
const handleCopy = useCallback(async () => {
@@ -423,14 +466,43 @@ export function QrGenerator({
</Link>
</div>
) : (
<Button
type="button"
variant="secondary"
onClick={handleSave}
disabled={saving || !ready}
>
{saving ? "Legt ab …" : "In „Meine Codes“ ablegen"}
</Button>
<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">
@@ -460,7 +532,7 @@ export function QrGenerator({
{hasUnsavedChanges ? (
<Button
type="button"
onClick={handleSave}
onClick={() => void handleSave()}
disabled={saving || !ready}
>
{saving ? "Speichert …" : "Änderungen speichern"}
@@ -492,7 +564,7 @@ export function QrGenerator({
</Notice>
<Button
type="button"
onClick={handleSave}
onClick={() => void handleSave()}
disabled={saving || !ready || Boolean(slugProblem)}
>
{saving ? "Speichert …" : "Dynamischen Code speichern"}