Sprechende Kurz-Links: selbst vergeben, aendern, frueher gueltig halten

Schliesst Issue #1. Der Wunsch betraf den Kurz-Link, nicht das Ziel: Er soll
sich von Anfang an bestimmen und spaeter aendern lassen.

Beim Anlegen gibt es dafuer ein Feld, im Bearbeiten-Dialog laesst er sich
aendern. Erlaubt sind Kleinbuchstaben, Ziffern, Bindestriche und „/" als
Trenner — damit sind Adressen wie kunde-a/sommer moeglich. Umlaute bleiben
draussen, weil sie in Adressen unleserlich kodiert werden; Gross- und
Kleinschreibung ist beim Aufrufen egal.

Beim Aendern bleibt die vorherige Adresse gueltig. Das ist der Kern: Auf
gedruckten Codes steht die alte, und genau deren Bestand ist der Sinn eines
dynamischen Codes. Frueher vergebene Adressen stehen im Dialog und lassen
sich einzeln freigeben — fuer den Tippfehler, mit dem noch nichts gedruckt
ist. Solange eine Adresse gehalten wird, kann sie kein anderer Code
uebernehmen; sonst liessen sich fremde gedruckte Codes umlenken.

Vergeben und Aendern ist standardmaessig Administratoren vorbehalten. Ein
Kurz-Link ist eine oeffentliche Adresse, und wer ihn aendert, kann Aushaenge
entwerten. Unter Verwaltung -> Einstellungen laesst sich das fuer Bearbeiter
freigeben; ohne das Recht erscheint das Feld gar nicht, Codes anlegen und
deren Ziel aendern geht weiterhin. Eigene Aktion in der Policy statt Teil von
codes:update, weil sie mehr wiegt.

Technisch: /r und /s sind Sammelrouten, weil ein Kurz-Link „/" enthalten
darf. Die Eindeutigkeit gilt zusaetzlich auf der kleingeschriebenen Form; die
Migration bricht ab, falls der Bestand das nicht zulaesst, statt still Daten
zu verlieren. Der bestehende exakte Index bleibt — er traegt den haeufigen
Fall, den gescannten Code.
This commit is contained in:
Florian Wilde
2026-08-17 10:41:57 +02:00
parent c38b27ca85
commit 46e7eb0b59
24 changed files with 917 additions and 49 deletions
+41 -4
View File
@@ -22,6 +22,7 @@ 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 };
@@ -42,7 +43,14 @@ function sameData(a: unknown, b: unknown): boolean {
return stableStringify(a) === stableStringify(b);
}
export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
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];
@@ -51,6 +59,8 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
// 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);
@@ -164,6 +174,11 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
/* ----------------------------------------------- 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) ||
@@ -171,7 +186,7 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
(saved.label ?? "") !== label.trim());
const handleSave = useCallback(async () => {
if (!validation.ok || !designValid) return;
if (!validation.ok || !designValid || slugProblem) return;
setSaving(true);
setSaveError(null);
try {
@@ -180,6 +195,7 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
data: validation.data,
design,
label: label.trim() || undefined,
slug: wunschSlug.trim() || undefined,
};
const result = saved
? await apiJson<{ code: QrCodeDto }>(`/api/codes/${saved.id}`, {
@@ -200,7 +216,7 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
} finally {
setSaving(false);
}
}, [validation, designValid, typeId, design, label, saved]);
}, [validation, designValid, slugProblem, typeId, design, label, wunschSlug, saved]);
const handleCopy = useCallback(async () => {
if (!saved) return;
@@ -347,6 +363,27 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
/>
</Field>
{/* Nur beim Anlegen: Danach steht der Kurz-Link im
Bearbeiten-Dialog, wo auch die früheren Adressen liegen. */}
{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>
)}
{saved ? (
<div className="space-y-3">
<Notice tone="success">
@@ -408,7 +445,7 @@ export function QrGenerator({ typeId }: { typeId: QrTypeId }) {
<Button
type="button"
onClick={handleSave}
disabled={saving || !ready}
disabled={saving || !ready || Boolean(slugProblem)}
>
{saving ? "Speichert …" : "Dynamischen Code speichern"}
</Button>