diff --git a/README.md b/README.md index 58d7e7a..826ad9a 100644 --- a/README.md +++ b/README.md @@ -465,6 +465,21 @@ Dort lässt sich - der Code **löschen** (mit Bestätigungsdialog), - das Bild erneut als PNG oder SVG **herunterladen**. +**Liste oder Karten.** Voreingestellt ist die **Liste** — eine Zeile je Code, +gedacht für viele Projekte nebeneinander. Über die beiden Schalter rechts neben +der Sortierung wechselst du zur **Kartenansicht** mit großer Vorschau. Die Wahl +merkt sich der Browser. + +Dazu kommen + +- ein **Suchfeld** über Name, Ziel, Kurz-URL und Typ, +- eine **Sortierung** nach Datum, Name oder Scan-Anzahl, +- ein **Gruppenfilter**, sobald Codes in mehr als einer Gruppe liegen. + +Auf schmalen Bildschirmen blendet die Liste die hinteren Spalten aus; die +Kurz-URL rückt dann unter den Namen. Zum Herunterladen dient dort die +Kartenansicht. + Statische Codes erscheinen hier nicht, weil sie nicht gespeichert werden. > Version 1 ist für den Einzelplatz-/LAN-Betrieb gedacht und hat **keine diff --git a/src/app/meine-codes/page.tsx b/src/app/meine-codes/page.tsx index 5156811..9880e3c 100644 --- a/src/app/meine-codes/page.tsx +++ b/src/app/meine-codes/page.tsx @@ -10,8 +10,10 @@ export const dynamic = "force-dynamic"; export default async function MyCodesPage() { await requirePageActor(); + // Breiter als die übrigen Seiten: Die Liste soll viele Projekte + // nebeneinander zeigen können. return ( -
+

Meine Codes diff --git a/src/components/codes-list.tsx b/src/components/codes-list.tsx index f0a77d4..6f558e9 100644 --- a/src/components/codes-list.tsx +++ b/src/components/codes-list.tsx @@ -6,7 +6,16 @@ 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, TextInput } from "@/components/ui"; +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"; @@ -17,11 +26,45 @@ const dateFormat = new Intl.DateTimeFormat("de-DE", { 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); @@ -38,6 +81,23 @@ export function CodesList() { 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"); @@ -128,12 +188,154 @@ export function CodesList() { } }; + /* -------------------------------------------------- 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} @@ -159,11 +361,222 @@ export function CodesList() { ) : null} - {codes.map((code) => ( + {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)} + +
+ + {/* 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) => (
@@ -171,6 +584,7 @@ export function CodesList() { {`QR-Code diff --git a/src/components/icons.tsx b/src/components/icons.tsx index 4c0b33d..04f5c4f 100644 --- a/src/components/icons.tsx +++ b/src/components/icons.tsx @@ -115,6 +115,27 @@ const PATHS = { ), check: , + search: ( + <> + + + + ), + viewList: ( + <> + + + + + ), + viewGrid: ( + <> + + + + + + ), eye: ( <>