Files
qrcode-generator/src/components/codes-list.tsx
T
Florian Wilde e296b1e6c2 Phase C: die letzten sechs Typen — PDF, Bild, Feedback und Datei-Upload
Damit sind alle 15 QR-Typen gebaut.

PDF und Bild brauchen eine hochgeladene Datei. Die liegt in der Datenbank,
nicht auf der Platte: damit bleibt es bei einem Volumen, das gesichert werden
muss — ein zweites wird beim Sichern zu leicht vergessen. Geprueft wird der
Dateiinhalt, nicht die Endung; eine Textdatei namens harmlos.pdf kommt nicht
durch. SVG ist bewusst nicht erlaubt, weil es Skripte enthalten darf und von
derselben Adresse wie die Anwendung ausgeliefert wuerde. Ausgeliefert wird mit
nosniff und einer CSP-Sandbox, damit auch ein PDF mit Skripten nichts anrichtet.

Dateien haengen am Code: wird er geloescht, geht sie mit. Eine ausgetauschte
oder nie gespeicherte Datei wird nach 24 Stunden weggeraeumt. Eine fremde
Datei laesst sich nicht an den eigenen Code haengen — sonst koennte man sie
einem anderen Code entziehen.

Feedback sammelt Note und Kommentar, ohne Name, IP oder Geraetekennung. Die
Auswertung mit Durchschnitt, Verteilung und Kommentaren ist aus der Codeliste
erreichbar — ein Feedback-Code ohne Auswertung waere nur die halbe Sache.
2026-08-14 23:34:28 +02:00

835 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Icon } from "@/components/icons";
import { Modal } from "@/components/modal";
import { qrForms } from "@/components/qr-forms";
import {
Badge,
Button,
Card,
Field,
Notice,
Select,
TextInput,
cx,
} from "@/components/ui";
import { ApiError, apiJson } from "@/lib/api-client";
import type { QrCodeDto } from "@/lib/dto";
import { qrTypes } from "@/lib/qr/registry";
import type { QrFormData } from "@/lib/qr/type-module";
const dateFormat = new Intl.DateTimeFormat("de-DE", {
dateStyle: "medium",
timeStyle: "short",
});
const shortDateFormat = new Intl.DateTimeFormat("de-DE", {
dateStyle: "short",
});
function formatDate(value: string | null): string {
if (!value) return "—";
return dateFormat.format(new Date(value));
}
function formatShortDate(value: string | null): string {
if (!value) return "—";
return shortDateFormat.format(new Date(value));
}
type View = "list" | "cards";
type SortKey = "neueste" | "aelteste" | "name" | "scans";
const SORT_LABELS: Record<SortKey, string> = {
neueste: "Neueste zuerst",
aelteste: "Älteste zuerst",
name: "Name (A–Z)",
scans: "Meiste Scans",
};
/** Merkt sich die gewählte Ansicht über Seitenwechsel hinweg. */
const VIEW_STORAGE_KEY = "qrgen.codes.view";
function readStoredView(): View {
if (typeof window === "undefined") return "list";
return window.localStorage.getItem(VIEW_STORAGE_KEY) === "cards"
? "cards"
: "list";
}
/** Name, unter dem ein Code in der Liste geführt wird. */
function displayName(code: QrCodeDto): string {
return code.label?.trim() || code.summary;
}
export function CodesList() {
const [codes, setCodes] = useState<QrCodeDto[] | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [editing, setEditing] = useState<QrCodeDto | null>(null);
const [editData, setEditData] = useState<QrFormData>({});
const [editLabel, setEditLabel] = useState("");
const [editError, setEditError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState<QrCodeDto | null>(null);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [removing, setRemoving] = useState(false);
const [copiedId, setCopiedId] = useState<string | null>(null);
const [view, setView] = useState<View>("list");
const [query, setQuery] = useState("");
const [sort, setSort] = useState<SortKey>("neueste");
const [groupFilter, setGroupFilter] = useState("alle");
// Erst nach dem Einhängen lesen — auf dem Server gibt es keinen
// localStorage, und ein abweichender Startwert führte zu Hydrationsfehlern.
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setView(readStoredView());
}, []);
const changeView = (next: View) => {
setView(next);
window.localStorage.setItem(VIEW_STORAGE_KEY, next);
};
const load = useCallback(async () => {
try {
const result = await apiJson<{ codes: QrCodeDto[] }>("/api/codes");
setCodes(result.codes);
setLoadError(null);
} catch (error) {
setCodes([]);
setLoadError(
error instanceof ApiError
? error.message
: "Die Liste konnte nicht geladen werden.",
);
}
}, []);
useEffect(() => {
// `load` setzt den Zustand erst nach dem await der Netzwerkantwort, also
// nie synchron während des Effekts — die Regel kann das nicht erkennen.
// eslint-disable-next-line react-hooks/set-state-in-effect
void load();
}, [load]);
const openEdit = (code: QrCodeDto) => {
setEditing(code);
setEditData(code.data);
setEditLabel(code.label ?? "");
setEditError(null);
};
const EditForm = editing ? qrForms[editing.type] : undefined;
const editValidation = useMemo(
() => (editing ? qrTypes[editing.type].validate(editData) : null),
[editing, editData],
);
const handleSave = async () => {
if (!editing || !editValidation) return;
if (!editValidation.ok) {
setEditError(editValidation.message);
return;
}
setSaving(true);
setEditError(null);
try {
await apiJson<{ code: QrCodeDto }>(`/api/codes/${editing.id}`, {
method: "PATCH",
json: { data: editValidation.data, label: editLabel.trim() || null },
});
setEditing(null);
await load();
} catch (error) {
setEditError(
error instanceof ApiError
? error.message
: "Die Änderung konnte nicht gespeichert werden.",
);
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
if (!deleting) return;
setRemoving(true);
setDeleteError(null);
try {
await apiJson(`/api/codes/${deleting.id}`, { method: "DELETE" });
setDeleting(null);
await load();
} catch (error) {
setDeleteError(
error instanceof ApiError
? error.message
: "Der Code konnte nicht gelöscht werden.",
);
} finally {
setRemoving(false);
}
};
const copy = async (code: QrCodeDto) => {
try {
await navigator.clipboard.writeText(code.shortUrl);
setCopiedId(code.id);
setTimeout(() => setCopiedId(null), 2000);
} catch {
setCopiedId(null);
}
};
/* -------------------------------------------------- Filtern und Sortieren */
// Gruppen kommen aus den geladenen Codes — ein eigener Abruf wäre dafür
// unnötig, und Gruppen ohne Codes gehören hier ohnehin nicht in die Auswahl.
const groups = useMemo(() => {
const seen = new Map<string, string>();
for (const code of codes ?? []) {
if (code.groupName) seen.set(code.groupId, code.groupName);
}
return [...seen].map(([id, name]) => ({ id, name })).sort((a, b) =>
a.name.localeCompare(b.name, "de"),
);
}, [codes]);
const visible = useMemo(() => {
const needle = query.trim().toLowerCase();
const filtered = (codes ?? []).filter((code) => {
if (groupFilter !== "alle" && code.groupId !== groupFilter) return false;
if (!needle) return true;
return [
displayName(code),
code.target,
code.shortUrl,
code.slug,
qrTypes[code.type]?.title ?? code.type,
]
.join(" ")
.toLowerCase()
.includes(needle);
});
const sorted = [...filtered];
sorted.sort((a, b) => {
switch (sort) {
case "aelteste":
return a.createdAt.localeCompare(b.createdAt);
case "name":
return displayName(a).localeCompare(displayName(b), "de");
case "scans":
return b.scans - a.scans;
default:
return b.createdAt.localeCompare(a.createdAt);
}
});
return sorted;
}, [codes, query, sort, groupFilter]);
/* ---------------------------------------------------------------- View */
if (codes === null) {
return <p className="text-sm text-slate-500">Codes werden geladen …</p>;
}
/** Herunterladen und Löschen — in beiden Ansichten dieselben Ziele. */
const pngHref = (code: QrCodeDto) =>
`/api/codes/${code.id}/qr?format=png&download=1`;
const svgHref = (code: QrCodeDto) =>
`/api/codes/${code.id}/qr?format=svg&download=1`;
const toolbar = codes.length > 0 && (
<div className="flex flex-wrap items-center gap-2">
<div className="relative min-w-0 flex-1 basis-56">
<Icon
name="search"
className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-slate-400"
/>
<TextInput
type="search"
aria-label="Codes durchsuchen"
placeholder="Suchen nach Name, Ziel oder Kurz-URL …"
className="pl-9"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
</div>
{groups.length > 1 ? (
<Select
aria-label="Nach Gruppe filtern"
className="!w-auto"
value={groupFilter}
onChange={(event) => setGroupFilter(event.target.value)}
>
<option value="alle">Alle Gruppen</option>
{groups.map((group) => (
<option key={group.id} value={group.id}>
{group.name}
</option>
))}
</Select>
) : null}
<Select
aria-label="Sortierung"
className="!w-auto"
value={sort}
onChange={(event) => setSort(event.target.value as SortKey)}
>
{(Object.keys(SORT_LABELS) as SortKey[]).map((key) => (
<option key={key} value={key}>
{SORT_LABELS[key]}
</option>
))}
</Select>
<div
role="group"
aria-label="Ansicht"
className="flex shrink-0 rounded-lg border border-slate-300 bg-white p-0.5"
>
{(
[
["list", "viewList", "Als Liste anzeigen"],
["cards", "viewGrid", "Als Karten anzeigen"],
] as const
).map(([value, icon, label]) => (
<button
key={value}
type="button"
aria-pressed={view === value}
aria-label={label}
title={label}
data-view={value}
onClick={() => changeView(value)}
className={cx(
"rounded-md p-2 transition-colors",
view === value
? "bg-indigo-50 text-indigo-700"
: "text-slate-500 hover:bg-slate-100 hover:text-slate-700",
)}
>
<Icon name={icon} className="size-4" />
</button>
))}
</div>
</div>
);
const resultCount =
codes.length > 0 ? (
<p className="text-sm text-slate-500 tabular-nums">
{visible.length === codes.length
? `${codes.length} ${codes.length === 1 ? "Code" : "Codes"}`
: `${visible.length} von ${codes.length} Codes`}
</p>
) : null;
return (
<div className="space-y-4">
{loadError ? <Notice tone="error">{loadError}</Notice> : null}
{codes.length === 0 && !loadError ? (
<Card className="text-center">
<span className="mx-auto flex size-14 items-center justify-center rounded-2xl bg-slate-100 text-slate-400">
<Icon name="qr" className="size-7" />
</span>
<h2 className="mt-4 text-lg font-semibold text-slate-900">
Noch keine dynamischen Codes
</h2>
<p className="mx-auto mt-2 max-w-md text-sm text-slate-600">
Erstelle einen QR-Code und wähle dabei „Dynamisch“ — dann erscheint
er hier und du kannst sein Ziel jederzeit ändern.
</p>
<Link
href="/erstellen/website"
className="mt-5 inline-flex items-center gap-2 rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-indigo-700"
>
QR-Code erstellen
</Link>
</Card>
) : null}
{toolbar}
{resultCount}
{codes.length > 0 && visible.length === 0 ? (
<Card className="text-center">
<p className="text-sm text-slate-600">
Kein Code passt zur Suche.{" "}
<button
type="button"
onClick={() => {
setQuery("");
setGroupFilter("alle");
}}
className="font-medium text-indigo-600 underline"
>
Filter zurücksetzen
</button>
</p>
</Card>
) : null}
{/* ------------------------------------------------------ Listenansicht */}
{view === "list" && visible.length > 0 ? (
<div className="rounded-2xl border border-slate-200 bg-white shadow-sm">
{/* Auf schmalen Schirmen blenden wir Spalten von rechts nach links
aus, statt die Tabelle seitlich scrollen zu lassen. Was wegfällt,
steht beim Namen (Kurz-URL) oder in der Kartenansicht. */}
{/* Feste Spaltenbreiten: so bekommt der Name den ganzen Rest und
kürzt selbst, statt die Tabelle breiter zu machen als die Seite. */}
<table className="w-full table-fixed text-sm">
<thead>
<tr className="border-b border-slate-200 bg-slate-50 text-left text-xs font-semibold tracking-wide text-slate-500 uppercase">
<th scope="col" className="py-2.5 pr-2 pl-3 sm:pr-3 sm:pl-4">
Name
</th>
<th scope="col" className="hidden w-44 px-3 py-2.5 md:table-cell lg:w-56">
Kurz-URL
</th>
<th scope="col" className="hidden w-40 px-3 py-2.5 sm:table-cell lg:w-56">
Ziel
</th>
{groups.length > 1 ? (
<th scope="col" className="hidden w-32 px-3 py-2.5 lg:table-cell">
Gruppe
</th>
) : null}
<th scope="col" className="w-16 px-3 py-2.5 text-right sm:w-20">
Scans
</th>
<th
scope="col"
className="hidden w-24 px-3 py-2.5 whitespace-nowrap sm:table-cell"
>
Erstellt
</th>
<th scope="col" className="w-20 py-2.5 pr-4 pl-3 text-right sm:w-36">
<span className="sr-only">Aktionen</span>
</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{visible.map((code) => (
<tr key={code.id} className="hover:bg-slate-50/70">
<td className="py-2.5 pr-2 pl-3 sm:pr-3 sm:pl-4">
<div className="flex items-center gap-2 sm:gap-3">
{/* Reines Bild zum Wiedererkennen — zum Herunterladen
stehen PNG und SVG am Zeilenende. */}
<span className="qr-canvas size-8 shrink-0 rounded-md border border-slate-200 p-0.5 sm:size-10">
{/* eslint-disable-next-line @next/next/no-img-element -- vom Server gerendertes SVG */}
<img
src={`/api/codes/${code.id}/qr`}
alt=""
loading="lazy"
className="h-full w-full object-contain"
/>
</span>
<div className="min-w-0 flex-1">
<div
data-code-name
className="truncate font-medium text-slate-900"
>
{displayName(code)}
</div>
{/* Solange die Kurz-URL keine eigene Spalte hat, steht
sie hier — sie ist wichtiger als der Typ. */}
<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>
<span className="hidden md:inline">
{qrTypes[code.type]?.title ?? code.type}
</span>
</div>
</div>
</div>
</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-indigo-600 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>
</td>
<td className="hidden px-3 py-2.5 sm:table-cell">
{code.target ? (
<a
href={code.target}
target="_blank"
rel="noreferrer"
title={code.target}
className="block truncate text-slate-700 hover:underline"
>
{code.target.replace(/^https?:\/\//, "")}
</a>
) : (
<span className="block truncate text-red-600" title={code.summary}>
{code.summary}
</span>
)}
</td>
{groups.length > 1 ? (
<td className="hidden px-3 py-2.5 text-slate-600 lg:table-cell">
<span className="block truncate" title={code.groupName ?? undefined}>
{code.groupName ?? "—"}
</span>
</td>
) : null}
<td className="px-3 py-2.5 text-right font-semibold text-slate-900 tabular-nums">
{code.scans}
</td>
<td
className="hidden px-3 py-2.5 whitespace-nowrap text-slate-500 tabular-nums sm:table-cell"
title={`Erstellt: ${formatDate(code.createdAt)}\nLetzter Scan: ${formatDate(code.lastScanAt)}`}
>
{formatShortDate(code.createdAt)}
</td>
<td className="py-2.5 pr-4 pl-3">
<div className="flex items-center justify-end gap-0.5">
{/* Ein Feedback-Code ohne Weg zu den Antworten waere
nur die halbe Sache. */}
{code.type === "feedback" ? (
<Link
href={`/meine-codes/${code.id}/feedback`}
title="Rückmeldungen ansehen"
aria-label={`Rückmeldungen zu ${displayName(code)} ansehen`}
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-900"
>
<Icon name="star" className="size-4" />
</Link>
) : null}
<button
type="button"
onClick={() => openEdit(code)}
title="Ziel bearbeiten"
aria-label={`Ziel von ${displayName(code)} bearbeiten`}
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-900"
>
<Icon name="pencil" className="size-4" />
</button>
{/* Auf dem Handy fehlt der Platz; dort führt der Weg zum
Download über die Kartenansicht. */}
<a
href={pngHref(code)}
title="Als PNG herunterladen"
className="hidden rounded-md px-1.5 py-1 text-xs font-medium text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-900 sm:inline-block"
>
PNG
</a>
<a
href={svgHref(code)}
title="Als SVG herunterladen"
className="hidden rounded-md px-1.5 py-1 text-xs font-medium text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-900 sm:inline-block"
>
SVG
</a>
<button
type="button"
onClick={() => {
setDeleting(code);
setDeleteError(null);
}}
title="Löschen"
aria-label={`${displayName(code)} löschen`}
className="rounded-md p-1.5 text-slate-400 transition-colors hover:bg-red-50 hover:text-red-600"
>
<Icon name="trash" className="size-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
{/* ------------------------------------------------------ Kartenansicht */}
{view === "cards" &&
visible.map((code) => (
<Card key={code.id}>
<div className="flex flex-col gap-5 sm:flex-row">
<a
href={pngHref(code)}
title="Als PNG herunterladen"
className="qr-canvas size-28 shrink-0 self-start rounded-xl border border-slate-200 p-1.5"
>
{/* eslint-disable-next-line @next/next/no-img-element -- vom Server gerendertes SVG */}
<img
src={`/api/codes/${code.id}/qr`}
alt={`QR-Code für ${code.summary}`}
loading="lazy"
className="h-full w-full object-contain"
/>
</a>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h2 className="font-semibold text-slate-900">
{code.label ?? code.summary}
</h2>
<Badge tone="neutral">{qrTypes[code.type]?.title ?? code.type}</Badge>
</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-indigo-600 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>
<div className="flex flex-wrap items-baseline gap-x-2">
<dt className="text-slate-500">Ziel:</dt>
<dd className="min-w-0 break-all text-slate-800">
{code.target ? (
<a
href={code.target}
target="_blank"
rel="noreferrer"
className="hover:underline"
>
{code.target}
</a>
) : (
<span className="text-red-600">{code.summary}</span>
)}
</dd>
</div>
</dl>
<div className="mt-3 flex flex-wrap items-center gap-x-5 gap-y-1 text-sm text-slate-500">
<span className="flex items-center gap-1.5">
<Icon name="scan" className="size-4" />
<strong className="font-semibold text-slate-900 tabular-nums">
{code.scans}
</strong>
{code.scans === 1 ? "Scan" : "Scans"}
</span>
<span>Erstellt: {formatDate(code.createdAt)}</span>
<span>Letzter Scan: {formatDate(code.lastScanAt)}</span>
</div>
<div className="mt-4 flex flex-wrap gap-2">
{code.type === "feedback" ? (
<Link
href={`/meine-codes/${code.id}/feedback`}
className="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50"
>
<Icon name="star" className="size-3.5" />
Rückmeldungen
</Link>
) : null}
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => openEdit(code)}
>
<Icon name="pencil" className="size-3.5" />
Ziel bearbeiten
</Button>
<a
href={`/api/codes/${code.id}/qr?format=png&download=1`}
className="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50"
>
<Icon name="download" className="size-3.5" />
PNG
</a>
<a
href={`/api/codes/${code.id}/qr?format=svg&download=1`}
className="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50"
>
<Icon name="download" className="size-3.5" />
SVG
</a>
<Button
type="button"
variant="ghost"
size="sm"
className="text-red-600 hover:bg-red-50"
onClick={() => {
setDeleting(code);
setDeleteError(null);
}}
>
<Icon name="trash" className="size-3.5" />
Löschen
</Button>
</div>
</div>
</div>
</Card>
))}
{/* ------------------------------------------------------ Bearbeiten */}
<Modal
open={editing !== null}
title="Ziel bearbeiten"
onClose={() => setEditing(null)}
footer={
<>
<Button
type="button"
variant="secondary"
onClick={() => setEditing(null)}
disabled={saving}
>
Abbrechen
</Button>
<Button type="button" onClick={() => void handleSave()} disabled={saving}>
{saving ? "Speichert …" : "Speichern"}
</Button>
</>
}
>
{editing ? (
<div className="space-y-4">
<p className="text-sm text-slate-600">
Der gedruckte QR-Code bleibt unverändert gültig — er zeigt weiterhin
auf{" "}
<code className="font-mono text-xs break-all">
{editing.shortUrl}
</code>
.
</p>
{EditForm ? (
<EditForm
value={editData}
onChange={(value) => {
setEditData(value);
setEditError(null);
}}
error={editError}
disabled={saving}
/>
) : (
<Notice tone="error">
Für diesen Typ ist kein Formular verfügbar.
</Notice>
)}
<Field label="Name (optional)" htmlFor="edit-label">
<TextInput
id="edit-label"
value={editLabel}
maxLength={120}
placeholder="z. B. Plakat Eingang"
disabled={saving}
onChange={(event) => setEditLabel(event.target.value)}
/>
</Field>
</div>
) : null}
</Modal>
{/* --------------------------------------------------------- Löschen */}
<Modal
open={deleting !== null}
title="QR-Code löschen?"
onClose={() => setDeleting(null)}
footer={
<>
<Button
type="button"
variant="secondary"
onClick={() => setDeleting(null)}
disabled={removing}
>
Abbrechen
</Button>
<Button
type="button"
variant="danger"
onClick={() => void handleDelete()}
disabled={removing}
>
{removing ? "Löscht …" : "Endgültig löschen"}
</Button>
</>
}
>
{deleting ? (
<div className="space-y-3">
<p className="text-sm text-slate-600">
Der Code{" "}
<strong className="text-slate-900">
{deleting.label ?? deleting.summary}
</strong>{" "}
wird gelöscht. Bereits gedruckte oder verteilte Codes mit der
Kurz-URL{" "}
<code className="font-mono text-xs break-all">
{deleting.shortUrl}
</code>{" "}
führen danach ins Leere. Das lässt sich nicht rückgängig machen.
</p>
{deleting.scans > 0 ? (
<Notice tone="warning">
Dieser Code wurde bereits {deleting.scans}-mal gescannt.
</Notice>
) : null}
{deleteError ? <Notice tone="error">{deleteError}</Notice> : null}
</div>
) : null}
</Modal>
</div>
);
}