From eeaf3820b7731eafc6e92b3dd46ca28d78d3c6f8 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Thu, 20 Aug 2026 19:33:49 +0200 Subject: [PATCH] Statische Codes benennen und auf Wunsch ablegen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 3 +- docs/funktionen.md | 20 +- docs/roadmap.md | 8 + .../migration.sql | 12 ++ prisma/schema.prisma | 15 +- src/app/api/codes/[id]/qr/route.ts | 8 +- src/app/api/codes/[id]/route.ts | 20 +- src/app/api/codes/route.ts | 51 +++++- src/app/api/qr/download/route.ts | 2 +- src/app/s/[...slug]/page.tsx | 11 ++ src/components/codes-list.tsx | 172 +++++++++++------- src/components/qr-generator.tsx | 81 +++++++-- src/lib/codes.ts | 17 +- src/lib/dto.ts | 20 +- src/lib/qr/request.ts | 38 +++- 15 files changed, 370 insertions(+), 108 deletions(-) create mode 100644 prisma/migrations/20260820170000_statische_codes_ablegen/migration.sql diff --git a/README.md b/README.md index 849b10a..4d1d7d5 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,8 @@ entscheidet die Verwaltung — nicht gebrauchte lassen sich ausblenden. - **Gestaltung:** freie Farben mit Kontrastwarnung, 256–2048 px, Fehlerkorrektur L/M/Q/H, einstellbarer Rand. Download als PNG und SVG. - **Meine Codes:** Liste oder Karten, Suche, Sortierung, Gruppenfilter, - Ziel ändern, löschen. + Ziel ändern, löschen. Statische Codes lassen sich auf Wunsch mit Namen + ablegen, um sie wiederzufinden. - **Feedback auswerten:** Durchschnitt, Verteilung und Kommentare — anonym erhoben, ohne Name und IP-Adresse. - **Anmeldung abschaltbar.** Mit Anmeldung: Benutzer, Gruppen und die Rollen diff --git a/docs/funktionen.md b/docs/funktionen.md index 910e64d..1f7977d 100644 --- a/docs/funktionen.md +++ b/docs/funktionen.md @@ -102,7 +102,25 @@ Browser landet. | Ziel später änderbar | nein | ja, jederzeit | | Scans gezählt | nein | ja, mit Datum des letzten | | Braucht diesen Server | nie | dauerhaft | -| In der Datenbank | nichts | Slug, Typ, Daten, Gestaltung, Zähler | +| In „Meine Codes“ | auf Wunsch, als Schnappschuss | immer | + +### Ein statischer Code mit Namen + +Beide Betriebsarten haben ein Feld **Name (optional)**. Es tut zweierlei: + +- Es bestimmt den **Dateinamen beim Herunterladen** — aus „Plakat Eingang“ + wird `qr-plakat-eingang.png` statt `qr-example-com-plakat.png`. +- Auf Wunsch lässt sich der Code damit **in „Meine Codes“ ablegen**, um ihn + wiederzufinden und erneut herunterzuladen. + +Ein abgelegter statischer Code ist ein **Schnappschuss**, kein steuerbares +Ziel. Änderbar ist nur sein Name; Inhalt und Gestaltung bleiben, wie sie beim +Ablegen waren — denn genau das steckt im gedruckten Bild. Er hat keinen +Kurz-Link, ist unter `/r/…` nicht erreichbar und zählt keine Scans; in der +Liste steht dort „statisch“ und ein Strich. + +Wer nach dem Ablegen den Inhalt ändert, bekommt das Ablegen erneut angeboten — +der vorherige Eintrag bleibt als eigener Schnappschuss bestehen. ### Der Kurz-Link diff --git a/docs/roadmap.md b/docs/roadmap.md index 1bf8113..eacefc1 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -83,6 +83,14 @@ vorbehalten, per Einstellung für Bearbeiter freigebbar. Schließt [Issue #1](https://git.wilde-server.de/florian.wilde/qrcode-generator/issues/1). +### Statische Codes benennen und ablegen (20.08.2026) + +Das Namensfeld gilt in beiden Betriebsarten. Es bestimmt den Dateinamen beim +Herunterladen und erlaubt, einen statischen Code in „Meine Codes" abzulegen — +als Schnappschuss, dessen Inhalt sich nicht mehr ändert. + +Schließt [Issue #2](https://git.wilde-server.de/florian.wilde/qrcode-generator/issues/2). + ### Dokumentation (15.08.2026) Bedienungsanleitung mit Bildern, Authentik-Anleitung, Funktionsübersicht. diff --git a/prisma/migrations/20260820170000_statische_codes_ablegen/migration.sql b/prisma/migrations/20260820170000_statische_codes_ablegen/migration.sql new file mode 100644 index 0000000..901bb5b --- /dev/null +++ b/prisma/migrations/20260820170000_statische_codes_ablegen/migration.sql @@ -0,0 +1,12 @@ +-- Statische Codes lassen sich mit Namen ablegen, damit man sie wiederfindet +-- und erneut herunterladen kann. + +-- Alles, was es bisher gibt, ist dynamisch. +ALTER TABLE "QrCode" ADD COLUMN "mode" TEXT NOT NULL DEFAULT 'dynamic'; +CREATE INDEX "QrCode_mode_idx" ON "QrCode"("mode"); + +-- Ein statischer Code hat keinen Kurz-Link: Er traegt seinen Inhalt selbst und +-- fuehrt auf nichts, was dieser Server ausliefert. In PostgreSQL schliessen +-- sich mehrere NULL in einem eindeutigen Index nicht aus — die Bedingung +-- „je Domain eindeutig" bleibt fuer dynamische Codes also unveraendert. +ALTER TABLE "QrCode" ALTER COLUMN "slug" DROP NOT NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1bda016..898c2c5 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -162,10 +162,18 @@ model QrTypeSetting { /// eigenes Payload-Format in `data` ab, ohne Schemaänderung. model QrCode { id String @id @default(cuid()) - /// Nur je Domain eindeutig — derselbe sprechende Slug darf auf zwei - /// Domains liegen. Weil eine Domain mehreren Gruppen dienen kann, gilt + /// Statisch oder dynamisch. Ein statischer Code traegt seinen Inhalt selbst + /// und wird nur abgelegt, damit man ihn wiederfindet und erneut + /// herunterladen kann — er hat weder Kurz-Link noch Scan-Zaehler. + mode String @default("dynamic") + /// Kurz-Link. Nur je Domain eindeutig — derselbe sprechende Slug darf auf + /// zwei Domains liegen. Weil eine Domain mehreren Gruppen dienen kann, gilt /// diese Eindeutigkeit über Gruppengrenzen hinweg. - slug String + /// + /// Leer bei statischen Codes: Die fuehren auf nichts, was dieser Server + /// ausliefert. In PostgreSQL schliessen sich mehrere NULL in einem + /// eindeutigen Index nicht aus, die Bedingung bleibt also gueltig. + slug String? type String label String? data Json @@ -202,6 +210,7 @@ model QrCode { @@index([createdAt]) @@index([groupId]) @@index([domainId]) + @@index([mode]) } /// Eine hochgeladene Datei — das PDF hinter einem PDF-Code, das Bild hinter diff --git a/src/app/api/codes/[id]/qr/route.ts b/src/app/api/codes/[id]/qr/route.ts index a6984c2..39d9997 100644 --- a/src/app/api/codes/[id]/qr/route.ts +++ b/src/app/api/codes/[id]/qr/route.ts @@ -4,6 +4,7 @@ import { getActor } from "@/lib/auth/actor"; import { requirePermission } from "@/lib/auth/policy"; import { prisma } from "@/lib/db"; import { shortUrl } from "@/lib/env"; +import { requireImplementedQrType } from "@/lib/qr/availability"; import { errorResponse } from "@/lib/http"; import { designSchema } from "@/lib/qr/design"; import { getQrType } from "@/lib/qr/registry"; @@ -49,7 +50,12 @@ export async function GET( margin: code.margin, }); - const content = shortUrl(code.slug, code.domain.hostname); + // Was im Bild steckt, haengt an der Betriebsart: Ein dynamischer Code + // traegt die Kurz-URL, ein statischer seinen Inhalt selbst. + const content = + code.mode === "static" || !code.slug + ? requireImplementedQrType(code.type).encode(code.data) + : shortUrl(code.slug, code.domain.hostname); const body = format === "png" ? new Uint8Array(await renderPng(content, design)) diff --git a/src/app/api/codes/[id]/route.ts b/src/app/api/codes/[id]/route.ts index a8d04a2..9aaafe7 100644 --- a/src/app/api/codes/[id]/route.ts +++ b/src/app/api/codes/[id]/route.ts @@ -96,6 +96,15 @@ export async function PATCH( /** Dateien, die nach der Änderung zu diesem Code gehören. */ let neueDateien: string[] | null = null; + // Der Inhalt eines statischen Codes steckt im gedruckten Bild. Ihn hier zu + // ändern würde die Ablage von dem entfernen, was tatsächlich im Umlauf + // ist — der Eintrag ist ein Schnappschuss, kein steuerbares Ziel. + if (parsed.data.data !== undefined && existing.mode === "static") { + return jsonError( + "Der Inhalt eines statischen Codes lässt sich nicht ändern — er steckt im gedruckten Code selbst. Änderbar ist nur der Name.", + ); + } + if (parsed.data.data !== undefined) { // Bewusst nur „ausprogrammiert“, nicht „freigegeben“: ein in der // Verwaltung abgeschalteter Typ soll bestehende Codes nicht @@ -127,8 +136,16 @@ export async function PATCH( /* ------------------------------------------------------- Kurz-Link */ const neuerSlug = parsed.data.slug?.trim(); + // Ein statischer Code hat keinen Kurz-Link — sein Inhalt steckt in ihm + // selbst. Ein Umbenennen waere gegenstandslos. + if (existing.mode === "static" && neuerSlug) { + return jsonError( + "Ein statischer Code hat keinen Kurz-Link. Sein Inhalt steckt im Code selbst.", + ); + } const umbenannt = neuerSlug !== undefined && + existing.slug !== null && normalizeSlug(neuerSlug) !== normalizeSlug(existing.slug); /** @@ -191,7 +208,8 @@ export async function PATCH( await tx.qrCodeAlias.create({ data: { domainId: existing.domainId, - slug: existing.slug, + // `umbenannt` gilt nur bei vorhandenem Kurz-Link. + slug: existing.slug as string, qrCodeId: existing.id, }, }); diff --git a/src/app/api/codes/route.ts b/src/app/api/codes/route.ts index 8aef2e4..239279e 100644 --- a/src/app/api/codes/route.ts +++ b/src/app/api/codes/route.ts @@ -28,6 +28,12 @@ const createSchema = z.object({ .trim() .max(120, { error: "Der Name darf höchstens 120 Zeichen lang sein." }) .optional(), + /** + * Statisch oder dynamisch. Ein statischer Code wird nur abgelegt, damit man + * ihn wiederfindet — er bekommt keinen Kurz-Link und zählt keine Scans. + */ + mode: z.enum(["static", "dynamic"], { error: "Ungültiger Modus." }) + .default("dynamic"), /** * Selbst gewählter Kurz-Link. Ohne Angabe wird einer erzeugt. * Geprüft wird er in `describeSlugProblem` — hier nur die grobe Länge. @@ -101,8 +107,25 @@ export async function POST(request: NextRequest): Promise { return jsonError(validated.message); } - // Erst hier, nach der Validierung: die Probe braucht die geprüften Daten. - assertDynamicPossible(qrType, validated.data); + const istStatisch = parsed.data.mode === "static"; + + if (istStatisch) { + // Ein statischer Code trägt seinen Inhalt selbst — der Typ muss das + // können, sonst gäbe es nichts zu kodieren. + if (!qrType.supportsStatic) { + return jsonError( + `Der QR-Typ „${qrType.title}“ funktioniert nur als dynamischer Code.`, + ); + } + if (parsed.data.slug) { + return jsonError( + "Ein statischer Code hat keinen Kurz-Link. Sein Inhalt steckt im Code selbst.", + ); + } + } else { + // Erst hier, nach der Validierung: die Probe braucht die geprüften Daten. + assertDynamicPossible(qrType, validated.data); + } // Ein selbst gewählter Kurz-Link wird geprüft, bevor irgendetwas entsteht — // sonst stünde am Ende ein Code mit einer Adresse, die niemand wollte. @@ -117,6 +140,30 @@ export async function POST(request: NextRequest): Promise { await assertSlugAvailable(domainId, gewuenscht); } + if (istStatisch) { + const created = await prisma.qrCode.create({ + data: { + mode: "static", + slug: null, + type: qrType.id, + label: label && label.length > 0 ? label : null, + data: toJsonInput(validated.data), + groupId, + domainId, + createdById: actor.kind === "user" ? actor.user.id : null, + ...design, + }, + include: { + group: { select: { name: true } }, + domain: { select: { hostname: true } }, + createdBy: { select: { name: true, email: true } }, + aliases: { select: { slug: true }, orderBy: { createdAt: "desc" } }, + }, + }); + await attachUploads(created.id, qrType.uploadIds(validated.data)); + return Response.json({ code: toQrCodeDto(created) }, { status: 201 }); + } + // Slug-Kollisionen sind extrem unwahrscheinlich, aber nicht unmöglich. for (let attempt = 0; attempt < 5; attempt += 1) { try { diff --git a/src/app/api/qr/download/route.ts b/src/app/api/qr/download/route.ts index ff2f44a..21f8c49 100644 --- a/src/app/api/qr/download/route.ts +++ b/src/app/api/qr/download/route.ts @@ -46,7 +46,7 @@ export async function POST(request: NextRequest): Promise { return new Response(body, { headers: { "Content-Type": CONTENT_TYPES[format], - "Content-Disposition": `attachment; filename="${downloadFilename(qrType, data, format)}"`, + "Content-Disposition": `attachment; filename="${downloadFilename(qrType, data, format, parsed.data.label)}"`, "Cache-Control": "no-store", }, }); diff --git a/src/app/s/[...slug]/page.tsx b/src/app/s/[...slug]/page.tsx index a6d731b..3c81d1d 100644 --- a/src/app/s/[...slug]/page.tsx +++ b/src/app/s/[...slug]/page.tsx @@ -91,6 +91,17 @@ export default async function LandingPage({ params }: PageProps) { ); } + // Gefunden wird ein Code nur ueber seinen Kurz-Link; statische haben keinen + // und koennen hier deshalb gar nicht ankommen. Der Zweig ist reine Vorsorge. + if (!code.slug) { + return ( + + Dieser Code führt nicht auf eine Seite. Bitte wende dich an denjenigen, + der ihn erstellt hat. + + ); + } + return ( 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({
{/* Nur der Pfad: Der Hostname ist auf dem Handy gleich für alle Zeilen, der Slug unterscheidet. */} - - {code.shortUrl.replace(/^https?:\/\/[^/]+/, "")} - + {code.shortUrl ? ( + + {code.shortUrl.replace(/^https?:\/\/[^/]+/, "")} + + ) : ( + + {qrTypes[code.type]?.title ?? code.type} · statisch + + )} {qrTypes[code.type]?.title ?? code.type} @@ -491,28 +509,34 @@ export function CodesList({ -
- - {code.shortUrl.replace(/^https?:\/\//, "")} - - -
+ {code.shortUrl ? ( +
+ + {code.shortUrl.replace(/^https?:\/\//, "")} + + +
+ ) : ( + // Ein statischer Code hat keine Kurz-URL — sein Inhalt + // steckt in ihm selbst. + statisch + )} @@ -527,7 +551,13 @@ export function CodesList({ {code.target.replace(/^https?:\/\//, "")} ) : ( - + {code.summary} )} @@ -542,7 +572,13 @@ export function CodesList({ ) : null} - {code.scans} + {code.mode === "static" ? ( + + — + + ) : ( + code.scans + )}
-
-
Kurz-URL:
-
- - {code.shortUrl} - - -
-
+ {code.shortUrl ? ( +
+
Kurz-URL:
+
+ + {code.shortUrl} + + +
+
+ ) : ( + // Statisch: Der Inhalt steckt im Code selbst, es gibt keine + // Kurz-URL und nichts, was sich nachträglich umbiegen liesse. +
+
Art:
+
+ statisch — Inhalt steckt im Code, nicht änderbar +
+
+ )}
Ziel:
@@ -772,11 +819,12 @@ export function CodesList({ {editing ? (

- 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."}

- {EditForm ? ( + {editing.mode === "static" ? null : EditForm ? ( { @@ -792,7 +840,7 @@ export function CodesList({ )} - {maySetSlug ? ( + {maySetSlug && editing.mode !== "static" ? ( <> ({ 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({
- {isDynamic ? ( -
+
- {/* 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 : ( )} - {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 ? ( +
+ + Abgelegt. 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. + + + Zu „Meine Codes“ + + +
+ ) : ( + + ) + ) : saved ? (
Code gespeichert. Der @@ -453,8 +501,7 @@ export function QrGenerator({ )} {saveError ? {saveError} : null} -
- ) : null} +
diff --git a/src/lib/codes.ts b/src/lib/codes.ts index 4e00f1b..a11efdf 100644 --- a/src/lib/codes.ts +++ b/src/lib/codes.ts @@ -38,16 +38,24 @@ export function toQrCodeDto(code: QrCodeWithRelations): QrCodeDto { const data = asRecord(code.data); const qrType = getQrType(code.type); + const istStatisch = code.mode === "static"; + let target = ""; let summary = ""; if (qrType) { try { + // Ein statischer Code führt nirgendwohin — sein Inhalt steckt in ihm + // selbst. Als „Ziel“ bleibt deshalb nichts übrig; die Zusammenfassung + // sagt trotzdem, was drinsteht. // Typen mit eigener Landingpage führen auf eine Seite dieser Anwendung. // Die ist als „Ziel“ das Ehrlichste — und obendrein anklickbar, um sie // sich anzusehen. - target = qrType.hasLandingPage - ? landingUrl(code.slug, code.domain?.hostname) - : qrType.redirectTarget(data); + target = + istStatisch || !code.slug + ? "" + : qrType.hasLandingPage + ? landingUrl(code.slug, code.domain?.hostname) + : qrType.redirectTarget(data); summary = qrType.summary(data); } catch { // Datensatz passt nicht mehr zum Typ-Schema (z. B. nach einem @@ -60,12 +68,13 @@ export function toQrCodeDto(code: QrCodeWithRelations): QrCodeDto { return { id: code.id, + mode: istStatisch ? "static" : "dynamic", slug: code.slug, aliases: (code.aliases ?? []).map((alias) => alias.slug), type: code.type as QrTypeId, label: code.label, data, - shortUrl: shortUrl(code.slug, code.domain?.hostname), + shortUrl: code.slug ? shortUrl(code.slug, code.domain?.hostname) : null, target, summary, design: { diff --git a/src/lib/dto.ts b/src/lib/dto.ts index d921b64..c3bf3ed 100644 --- a/src/lib/dto.ts +++ b/src/lib/dto.ts @@ -1,21 +1,31 @@ import type { EcLevel } from "@/lib/qr/design"; import type { QrTypeId } from "@/lib/qr/type-module"; +/** Statisch oder dynamisch — bestimmt, was ein Code überhaupt kann. */ +export type QrCodeMode = "static" | "dynamic"; + /** - * Darstellung eines dynamischen Codes an der API-Grenze. Bewusst frei von + * Darstellung eines gespeicherten Codes an der API-Grenze. Bewusst frei von * Server-Abhängigkeiten, damit Client-Komponenten den Typ verwenden können. */ export type QrCodeDto = { id: string; - slug: string; + /** + * Ein statischer Code trägt seinen Inhalt selbst. Er ist nur abgelegt, damit + * man ihn wiederfindet und erneut herunterladen kann — Kurz-Link, Ziel und + * Scan-Zähler bleiben deshalb leer. + */ + mode: QrCodeMode; + /** Kurz-Link. Leer bei statischen Codes. */ + slug: string | null; /** Frühere Kurz-Links, die weiterhin auf diesen Code führen. */ aliases: string[]; type: QrTypeId; label: string | null; data: Record; - /** Die Kurz-URL, die im QR-Code steckt. */ - shortUrl: string; - /** Aktuelles Weiterleitungsziel. */ + /** Die Kurz-URL, die im QR-Code steckt. Leer bei statischen Codes. */ + shortUrl: string | null; + /** Aktuelles Weiterleitungsziel. Leer bei statischen Codes. */ target: string; /** Lesbare Kurzfassung des Ziels. */ summary: string; diff --git a/src/lib/qr/request.ts b/src/lib/qr/request.ts index b367292..b8469d7 100644 --- a/src/lib/qr/request.ts +++ b/src/lib/qr/request.ts @@ -36,6 +36,8 @@ export const qrRequestSchema = z.object({ slug: z.string().trim().optional(), /** Domain, unter der die Kurz-URL entsteht. */ domainId: z.string().trim().optional(), + /** Name des Codes — bestimmt den Dateinamen beim Herunterladen. */ + label: z.string().trim().max(120).optional(), }); export type QrRequest = z.infer; @@ -137,25 +139,41 @@ export async function resolveQrRequest( }; } -/** Dateiname für den Download, abgeleitet aus dem Inhalt des Codes. */ +/** Macht aus beliebigem Text einen brauchbaren Dateinamen-Bestandteil. */ +function alsDateiname(text: string): string { + return text + .toLowerCase() + .replaceAll("ä", "ae") + .replaceAll("ö", "oe") + .replaceAll("ü", "ue") + .replaceAll("ß", "ss") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 60); +} + +/** + * Dateiname für den Download. + * + * Wenn ein Name vergeben wurde, gewinnt er — genau dafür ist er da: damit man + * die Datei später wiederfindet. Sonst wie bisher aus dem Inhalt abgeleitet. + */ export function downloadFilename( qrType: QrTypeModule, data: unknown, extension: string, + label?: string | null, ): string { + const ausName = label ? alsDateiname(label) : ""; + if (ausName) return `qr-${ausName}.${extension}`; + let base = "qr-code"; try { const summary = qrType .summary(data) - .toLowerCase() - .replaceAll("ä", "ae") - .replaceAll("ö", "oe") - .replaceAll("ü", "ue") - .replaceAll("ß", "ss") - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, "") - .slice(0, 60); - if (summary) base = `qr-${summary}`; + .toLowerCase(); + const gesaeubert = alsDateiname(summary); + if (gesaeubert) base = `qr-${gesaeubert}`; } catch { // Zusammenfassung ist optional — im Zweifel bleibt der Standardname. }