From b3d386b0d2dec3a9c31b7420aa94e4101920e328 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Fri, 14 Aug 2026 15:35:05 +0200 Subject: [PATCH] Meine Codes: Listenansicht mit Suche, Sortierung und Gruppenfilter Bei vielen Projekten ist die Kartenansicht unuebersichtlich. Die Liste ist jetzt die Voreinstellung, die Karten bleiben ueber einen Schalter erreichbar; die Wahl merkt sich der Browser. Dazu Suche ueber Name, Ziel, Kurz-URL und Typ, Sortierung nach Datum, Name oder Scans sowie ein Gruppenfilter, sobald Codes in mehr als einer Gruppe liegen. Die Seite ist breiter als die uebrigen, damit die Namen Platz haben. Die Tabelle hat feste Spaltenbreiten: mit automatischem Layout hat ihre Eigenbreite die ganze Seite seitlich scrollbar gemacht, auch wenn der Container sie eigentlich beschnitten hat. Auf schmalen Schirmen weichen die hinteren Spalten, die Kurz-URL rueckt unter den Namen. --- README.md | 15 ++ src/app/meine-codes/page.tsx | 4 +- src/components/codes-list.tsx | 420 +++++++++++++++++++++++++++++++++- src/components/icons.tsx | 21 ++ 4 files changed, 456 insertions(+), 4 deletions(-) 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: ( <>