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.
835 lines
30 KiB
TypeScript
835 lines
30 KiB
TypeScript
"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>
|
||
);
|
||
}
|