Statische Codes benennen und auf Wunsch ablegen
Das Feld „Name (optional)" stand nur im Dynamisch-Block. Bei einem statischen Code gab es damit keine Moeglichkeit, ihm einen Namen zum Wiederfinden zu geben. Jetzt gilt es in beiden Betriebsarten und tut zweierlei: Es bestimmt den Dateinamen beim Herunterladen — aus „Plakat Eingang" wird qr-plakat-eingang.png statt qr-example-com-plakat.png — und es laesst sich damit ein statischer Code in „Meine Codes" ablegen. Ein abgelegter statischer Code ist ein Schnappschuss, kein steuerbares Ziel. Aenderbar ist nur der Name; Inhalt und Gestaltung bleiben, wie sie beim Ablegen waren, denn genau das steckt im gedruckten Bild. Er bekommt keinen Kurz-Link, ist unter /r nicht erreichbar und zaehlt keine Scans — in der Liste steht dort „statisch" und ein Strich. Wer nach dem Ablegen den Inhalt aendert, bekommt das Ablegen erneut angeboten; der vorherige Eintrag bleibt als eigener Schnappschuss. Dafuer traegt QrCode jetzt `mode`, und `slug` darf leer sein. In PostgreSQL schliessen sich mehrere NULL in einem eindeutigen Index nicht aus, die Bedingung „je Domain eindeutig" gilt fuer dynamische Codes also unveraendert. Refs #2
This commit is contained in:
+110
-62
@@ -133,7 +133,7 @@ export function CodesList({
|
||||
setEditing(code);
|
||||
setEditData(code.data);
|
||||
setEditLabel(code.label ?? "");
|
||||
setEditSlug(code.slug);
|
||||
setEditSlug(code.slug ?? "");
|
||||
setEditAliases(code.aliases);
|
||||
setEditError(null);
|
||||
};
|
||||
@@ -141,7 +141,9 @@ export function CodesList({
|
||||
const slugProblem =
|
||||
editSlug.trim().length > 0 ? describeSlugProblem(editSlug) : null;
|
||||
const slugGeaendert =
|
||||
editing !== null && editSlug.trim().toLowerCase() !== editing.slug.toLowerCase();
|
||||
editing !== null &&
|
||||
editing.slug !== null &&
|
||||
editSlug.trim().toLowerCase() !== editing.slug.toLowerCase();
|
||||
const freigegeben = editing
|
||||
? editing.aliases.filter((alias) => !editAliases.includes(alias))
|
||||
: [];
|
||||
@@ -168,12 +170,18 @@ export function CodesList({
|
||||
await apiJson<{ code: QrCodeDto }>(`/api/codes/${editing.id}`, {
|
||||
method: "PATCH",
|
||||
json: {
|
||||
data: editValidation.data,
|
||||
label: editLabel.trim() || null,
|
||||
// Nur mitschicken, wenn es überhaupt bearbeitet werden durfte.
|
||||
...(maySetSlug
|
||||
? { slug: editSlug.trim(), aliases: editAliases }
|
||||
: {}),
|
||||
// Ein statischer Code ist ein Schnappschuss — Inhalt und Kurz-Link
|
||||
// gehören nicht in die Anfrage.
|
||||
...(editing.mode === "static"
|
||||
? {}
|
||||
: {
|
||||
data: editValidation.data,
|
||||
// Nur mitschicken, wenn es bearbeitet werden durfte.
|
||||
...(maySetSlug
|
||||
? { slug: editSlug.trim(), aliases: editAliases }
|
||||
: {}),
|
||||
}),
|
||||
},
|
||||
});
|
||||
setEditing(null);
|
||||
@@ -209,6 +217,7 @@ export function CodesList({
|
||||
};
|
||||
|
||||
const copy = async (code: QrCodeDto) => {
|
||||
if (!code.shortUrl) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(code.shortUrl);
|
||||
setCopiedId(code.id);
|
||||
@@ -479,9 +488,18 @@ export function CodesList({
|
||||
<div className="truncate text-xs text-slate-500">
|
||||
{/* Nur der Pfad: Der Hostname ist auf dem Handy
|
||||
gleich für alle Zeilen, der Slug unterscheidet. */}
|
||||
<span className="font-mono md:hidden" title={code.shortUrl}>
|
||||
{code.shortUrl.replace(/^https?:\/\/[^/]+/, "")}
|
||||
</span>
|
||||
{code.shortUrl ? (
|
||||
<span
|
||||
className="font-mono md:hidden"
|
||||
title={code.shortUrl}
|
||||
>
|
||||
{code.shortUrl.replace(/^https?:\/\/[^/]+/, "")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="md:hidden">
|
||||
{qrTypes[code.type]?.title ?? code.type} · statisch
|
||||
</span>
|
||||
)}
|
||||
<span className="hidden md:inline">
|
||||
{qrTypes[code.type]?.title ?? code.type}
|
||||
</span>
|
||||
@@ -491,28 +509,34 @@ export function CodesList({
|
||||
</td>
|
||||
|
||||
<td className="hidden px-3 py-2.5 md:table-cell">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<a
|
||||
href={code.shortUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="truncate font-mono text-xs text-accent-text hover:underline"
|
||||
title={code.shortUrl}
|
||||
>
|
||||
{code.shortUrl.replace(/^https?:\/\//, "")}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copy(code)}
|
||||
title="Kurz-URL kopieren"
|
||||
className="rounded p-1 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700"
|
||||
>
|
||||
<Icon
|
||||
name={copiedId === code.id ? "check" : "copy"}
|
||||
className="size-3.5"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{code.shortUrl ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<a
|
||||
href={code.shortUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="truncate font-mono text-xs text-accent-text hover:underline"
|
||||
title={code.shortUrl}
|
||||
>
|
||||
{code.shortUrl.replace(/^https?:\/\//, "")}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copy(code)}
|
||||
title="Kurz-URL kopieren"
|
||||
className="rounded p-1 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700"
|
||||
>
|
||||
<Icon
|
||||
name={copiedId === code.id ? "check" : "copy"}
|
||||
className="size-3.5"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
// Ein statischer Code hat keine Kurz-URL — sein Inhalt
|
||||
// steckt in ihm selbst.
|
||||
<Badge tone="neutral">statisch</Badge>
|
||||
)}
|
||||
</td>
|
||||
|
||||
<td className="hidden px-3 py-2.5 sm:table-cell">
|
||||
@@ -527,7 +551,13 @@ export function CodesList({
|
||||
{code.target.replace(/^https?:\/\//, "")}
|
||||
</a>
|
||||
) : (
|
||||
<span className="block truncate text-red-600" title={code.summary}>
|
||||
<span
|
||||
className={cx(
|
||||
"block truncate",
|
||||
code.mode === "static" ? "text-slate-600" : "text-red-600",
|
||||
)}
|
||||
title={code.summary}
|
||||
>
|
||||
{code.summary}
|
||||
</span>
|
||||
)}
|
||||
@@ -542,7 +572,13 @@ export function CodesList({
|
||||
) : null}
|
||||
|
||||
<td className="px-3 py-2.5 text-right font-semibold text-slate-900 tabular-nums">
|
||||
{code.scans}
|
||||
{code.mode === "static" ? (
|
||||
<span className="font-normal text-slate-400" title="Statische Codes werden nicht gezählt">
|
||||
—
|
||||
</span>
|
||||
) : (
|
||||
code.scans
|
||||
)}
|
||||
</td>
|
||||
|
||||
<td
|
||||
@@ -640,30 +676,41 @@ export function CodesList({
|
||||
</div>
|
||||
|
||||
<dl className="mt-3 space-y-2 text-sm">
|
||||
<div className="flex flex-wrap items-baseline gap-x-2">
|
||||
<dt className="text-slate-500">Kurz-URL:</dt>
|
||||
<dd className="flex items-center gap-2">
|
||||
<a
|
||||
href={code.shortUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-mono text-xs break-all text-accent-text hover:underline"
|
||||
>
|
||||
{code.shortUrl}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copy(code)}
|
||||
title="Kurz-URL kopieren"
|
||||
className="rounded p-1 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700"
|
||||
>
|
||||
<Icon
|
||||
name={copiedId === code.id ? "check" : "copy"}
|
||||
className="size-4"
|
||||
/>
|
||||
</button>
|
||||
</dd>
|
||||
</div>
|
||||
{code.shortUrl ? (
|
||||
<div className="flex flex-wrap items-baseline gap-x-2">
|
||||
<dt className="text-slate-500">Kurz-URL:</dt>
|
||||
<dd className="flex items-center gap-2">
|
||||
<a
|
||||
href={code.shortUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-mono text-xs break-all text-accent-text hover:underline"
|
||||
>
|
||||
{code.shortUrl}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copy(code)}
|
||||
title="Kurz-URL kopieren"
|
||||
className="rounded p-1 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700"
|
||||
>
|
||||
<Icon
|
||||
name={copiedId === code.id ? "check" : "copy"}
|
||||
className="size-4"
|
||||
/>
|
||||
</button>
|
||||
</dd>
|
||||
</div>
|
||||
) : (
|
||||
// Statisch: Der Inhalt steckt im Code selbst, es gibt keine
|
||||
// Kurz-URL und nichts, was sich nachträglich umbiegen liesse.
|
||||
<div className="flex flex-wrap items-baseline gap-x-2">
|
||||
<dt className="text-slate-500">Art:</dt>
|
||||
<dd className="text-slate-800">
|
||||
statisch — Inhalt steckt im Code, nicht änderbar
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-baseline gap-x-2">
|
||||
<dt className="text-slate-500">Ziel:</dt>
|
||||
@@ -772,11 +819,12 @@ export function CodesList({
|
||||
{editing ? (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-slate-600">
|
||||
Das Ziel lässt sich jederzeit ändern — der gedruckte QR-Code bleibt
|
||||
gültig, solange der Kurz-Link derselbe ist.
|
||||
{editing.mode === "static"
|
||||
? "Ein statischer Code trägt seinen Inhalt selbst — änderbar ist deshalb nur der Name, unter dem du ihn hier wiederfindest."
|
||||
: "Das Ziel lässt sich jederzeit ändern — der gedruckte QR-Code bleibt gültig, solange der Kurz-Link derselbe ist."}
|
||||
</p>
|
||||
|
||||
{EditForm ? (
|
||||
{editing.mode === "static" ? null : EditForm ? (
|
||||
<EditForm
|
||||
value={editData}
|
||||
onChange={(value) => {
|
||||
@@ -792,7 +840,7 @@ export function CodesList({
|
||||
</Notice>
|
||||
)}
|
||||
|
||||
{maySetSlug ? (
|
||||
{maySetSlug && editing.mode !== "static" ? (
|
||||
<>
|
||||
<Field
|
||||
label="Kurz-Link"
|
||||
|
||||
@@ -84,11 +84,13 @@ export function QrGenerator({
|
||||
const dataError = !validation.ok && touched ? validation.message : null;
|
||||
|
||||
const mode = isDynamic ? "dynamic" : "static";
|
||||
const slug = isDynamic ? saved?.slug : undefined;
|
||||
const slug = (isDynamic ? saved?.slug : undefined) ?? undefined;
|
||||
|
||||
const requestBody = useMemo(
|
||||
() => ({ type: typeId, data, design, mode, slug }),
|
||||
[typeId, data, design, mode, slug],
|
||||
// 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;
|
||||
@@ -194,8 +196,10 @@ export function QrGenerator({
|
||||
type: typeId,
|
||||
data: validation.data,
|
||||
design,
|
||||
mode,
|
||||
label: label.trim() || undefined,
|
||||
slug: wunschSlug.trim() || undefined,
|
||||
// Ein statischer Code hat keinen Kurz-Link.
|
||||
slug: isDynamic ? wunschSlug.trim() || undefined : undefined,
|
||||
};
|
||||
const result = saved
|
||||
? await apiJson<{ code: QrCodeDto }>(`/api/codes/${saved.id}`, {
|
||||
@@ -216,10 +220,22 @@ export function QrGenerator({
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [validation, designValid, slugProblem, typeId, design, label, wunschSlug, saved]);
|
||||
}, [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);
|
||||
}, []);
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
if (!saved) return;
|
||||
if (!saved?.shortUrl) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(saved.shortUrl);
|
||||
setCopied(true);
|
||||
@@ -302,7 +318,7 @@ export function QrGenerator({
|
||||
data-mode="static"
|
||||
aria-pressed={!isDynamic}
|
||||
disabled={!qrType.supportsStatic}
|
||||
onClick={() => setIsDynamic(false)}
|
||||
onClick={() => wechsleModus(false)}
|
||||
className={cx(
|
||||
"rounded-xl border p-4 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-50",
|
||||
!isDynamic
|
||||
@@ -326,7 +342,7 @@ export function QrGenerator({
|
||||
data-mode="dynamic"
|
||||
aria-pressed={isDynamic}
|
||||
disabled={!qrType.supportsDynamic}
|
||||
onClick={() => setIsDynamic(true)}
|
||||
onClick={() => wechsleModus(true)}
|
||||
className={cx(
|
||||
"rounded-xl border p-4 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-50",
|
||||
isDynamic
|
||||
@@ -347,12 +363,15 @@ export function QrGenerator({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isDynamic ? (
|
||||
<div className="mt-5 space-y-4 border-t border-slate-200 pt-5">
|
||||
<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."
|
||||
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"
|
||||
@@ -363,9 +382,7 @@ export function QrGenerator({
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{/* Nur beim Anlegen: Danach steht der Kurz-Link im
|
||||
Bearbeiten-Dialog, wo auch die früheren Adressen liegen. */}
|
||||
{saved || !maySetSlug ? null : (
|
||||
{!isDynamic || saved || !maySetSlug ? null : (
|
||||
<Field
|
||||
label="Kurz-Link (optional)"
|
||||
htmlFor="code-slug"
|
||||
@@ -384,7 +401,38 @@ export function QrGenerator({
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{saved ? (
|
||||
{!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>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={handleSave}
|
||||
disabled={saving || !ready}
|
||||
>
|
||||
{saving ? "Legt ab …" : "In „Meine Codes“ ablegen"}
|
||||
</Button>
|
||||
)
|
||||
) : saved ? (
|
||||
<div className="space-y-3">
|
||||
<Notice tone="success">
|
||||
<span className="font-medium">Code gespeichert.</span> Der
|
||||
@@ -453,8 +501,7 @@ export function QrGenerator({
|
||||
)}
|
||||
|
||||
{saveError ? <Notice tone="error">{saveError}</Notice> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user