Merge #2: Statische Codes benennen und auf Wunsch ablegen
This commit is contained in:
@@ -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
|
||||
|
||||
+19
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
+12
-3
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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<Response> {
|
||||
return jsonError(validated.message);
|
||||
}
|
||||
|
||||
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<Response> {
|
||||
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 {
|
||||
|
||||
@@ -46,7 +46,7 @@ export async function POST(request: NextRequest): Promise<Response> {
|
||||
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",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
<LandingNotice title="Diese Seite gibt es nicht">
|
||||
Dieser Code führt nicht auf eine Seite. Bitte wende dich an denjenigen,
|
||||
der ihn erstellt hat.
|
||||
</LandingNotice>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Landing
|
||||
data={code.data}
|
||||
|
||||
@@ -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.
|
||||
// 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 ? (
|
||||
<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,6 +509,7 @@ export function CodesList({
|
||||
</td>
|
||||
|
||||
<td className="hidden px-3 py-2.5 md:table-cell">
|
||||
{code.shortUrl ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<a
|
||||
href={code.shortUrl}
|
||||
@@ -513,6 +532,11 @@ export function CodesList({
|
||||
/>
|
||||
</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,6 +676,7 @@ export function CodesList({
|
||||
</div>
|
||||
|
||||
<dl className="mt-3 space-y-2 text-sm">
|
||||
{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">
|
||||
@@ -664,6 +701,16 @@ export function CodesList({
|
||||
</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">
|
||||
<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
|
||||
@@ -454,7 +502,6 @@ export function QrGenerator({
|
||||
|
||||
{saveError ? <Notice tone="error">{saveError}</Notice> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
|
||||
+11
-2
@@ -38,14 +38,22 @@ 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
|
||||
target =
|
||||
istStatisch || !code.slug
|
||||
? ""
|
||||
: qrType.hasLandingPage
|
||||
? landingUrl(code.slug, code.domain?.hostname)
|
||||
: qrType.redirectTarget(data);
|
||||
summary = qrType.summary(data);
|
||||
@@ -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: {
|
||||
|
||||
+15
-5
@@ -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<string, unknown>;
|
||||
/** 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;
|
||||
|
||||
+29
-11
@@ -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<typeof qrRequestSchema>;
|
||||
@@ -137,16 +139,9 @@ export async function resolveQrRequest(
|
||||
};
|
||||
}
|
||||
|
||||
/** Dateiname für den Download, abgeleitet aus dem Inhalt des Codes. */
|
||||
export function downloadFilename(
|
||||
qrType: QrTypeModule,
|
||||
data: unknown,
|
||||
extension: string,
|
||||
): string {
|
||||
let base = "qr-code";
|
||||
try {
|
||||
const summary = qrType
|
||||
.summary(data)
|
||||
/** Macht aus beliebigem Text einen brauchbaren Dateinamen-Bestandteil. */
|
||||
function alsDateiname(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replaceAll("ä", "ae")
|
||||
.replaceAll("ö", "oe")
|
||||
@@ -155,7 +150,30 @@ export function downloadFilename(
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 60);
|
||||
if (summary) base = `qr-${summary}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
const gesaeubert = alsDateiname(summary);
|
||||
if (gesaeubert) base = `qr-${gesaeubert}`;
|
||||
} catch {
|
||||
// Zusammenfassung ist optional — im Zweifel bleibt der Standardname.
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user