"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 = { 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(null); const [loadError, setLoadError] = useState(null); const [editing, setEditing] = useState(null); const [editData, setEditData] = useState({}); const [editLabel, setEditLabel] = useState(""); const [editError, setEditError] = useState(null); const [saving, setSaving] = useState(false); const [deleting, setDeleting] = useState(null); const [deleteError, setDeleteError] = useState(null); const [removing, setRemoving] = useState(false); const [copiedId, setCopiedId] = useState(null); const [view, setView] = useState("list"); const [query, setQuery] = useState(""); const [sort, setSort] = useState("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(); 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

Codes werden geladen …

; } /** 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 && (
setQuery(event.target.value)} />
{groups.length > 1 ? ( ) : null}
{( [ ["list", "viewList", "Als Liste anzeigen"], ["cards", "viewGrid", "Als Karten anzeigen"], ] as const ).map(([value, icon, label]) => ( ))}
); const resultCount = codes.length > 0 ? (

{visible.length === codes.length ? `${codes.length} ${codes.length === 1 ? "Code" : "Codes"}` : `${visible.length} von ${codes.length} Codes`}

) : null; return (
{loadError ? {loadError} : null} {codes.length === 0 && !loadError ? (

Noch keine dynamischen Codes

Erstelle einen QR-Code und wähle dabei „Dynamisch“ — dann erscheint er hier und du kannst sein Ziel jederzeit ändern.

QR-Code erstellen
) : null} {toolbar} {resultCount} {codes.length > 0 && visible.length === 0 ? (

Kein Code passt zur Suche.{" "}

) : null} {/* ------------------------------------------------------ Listenansicht */} {view === "list" && visible.length > 0 ? (
{/* 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. */} {groups.length > 1 ? ( ) : null} {visible.map((code) => ( {groups.length > 1 ? ( ) : null} ))}
Name Kurz-URL Ziel Gruppe Scans Erstellt Aktionen
{/* Reines Bild zum Wiedererkennen — zum Herunterladen stehen PNG und SVG am Zeilenende. */} {/* eslint-disable-next-line @next/next/no-img-element -- vom Server gerendertes SVG */}
{displayName(code)}
{/* Solange die Kurz-URL keine eigene Spalte hat, steht sie hier — sie ist wichtiger als der Typ. */}
{/* Nur der Pfad: Der Hostname ist auf dem Handy gleich für alle Zeilen, der Slug unterscheidet. */} {code.shortUrl.replace(/^https?:\/\/[^/]+/, "")} {qrTypes[code.type]?.title ?? code.type}
{code.shortUrl.replace(/^https?:\/\//, "")}
{code.target ? ( {code.target.replace(/^https?:\/\//, "")} ) : ( {code.summary} )} {code.groupName ?? "—"} {code.scans} {formatShortDate(code.createdAt)}
{/* Ein Feedback-Code ohne Weg zu den Antworten waere nur die halbe Sache. */} {code.type === "feedback" ? ( ) : null} {/* Auf dem Handy fehlt der Platz; dort führt der Weg zum Download über die Kartenansicht. */} PNG SVG
) : null} {/* ------------------------------------------------------ Kartenansicht */} {view === "cards" && visible.map((code) => (
{/* eslint-disable-next-line @next/next/no-img-element -- vom Server gerendertes SVG */} {`QR-Code

{code.label ?? code.summary}

{qrTypes[code.type]?.title ?? code.type}
Kurz-URL:
{code.shortUrl}
Ziel:
{code.target ? ( {code.target} ) : ( {code.summary} )}
{code.scans} {code.scans === 1 ? "Scan" : "Scans"} Erstellt: {formatDate(code.createdAt)} Letzter Scan: {formatDate(code.lastScanAt)}
{code.type === "feedback" ? ( Rückmeldungen ) : null} PNG SVG
))} {/* ------------------------------------------------------ Bearbeiten */} setEditing(null)} footer={ <> } > {editing ? (

Der gedruckte QR-Code bleibt unverändert gültig — er zeigt weiterhin auf{" "} {editing.shortUrl} .

{EditForm ? ( { setEditData(value); setEditError(null); }} error={editError} disabled={saving} /> ) : ( Für diesen Typ ist kein Formular verfügbar. )} setEditLabel(event.target.value)} />
) : null}
{/* --------------------------------------------------------- Löschen */} setDeleting(null)} footer={ <> } > {deleting ? (

Der Code{" "} {deleting.label ?? deleting.summary} {" "} wird gelöscht. Bereits gedruckte oder verteilte Codes mit der Kurz-URL{" "} {deleting.shortUrl} {" "} führen danach ins Leere. Das lässt sich nicht rückgängig machen.

{deleting.scans > 0 ? ( Dieser Code wurde bereits {deleting.scans}-mal gescannt. ) : null} {deleteError ? {deleteError} : null}
) : null}
); }