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.
This commit is contained in:
Florian Wilde
2026-08-14 15:35:05 +02:00
parent cdda55f9b0
commit b3d386b0d2
4 changed files with 456 additions and 4 deletions
+15
View File
@@ -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
+3 -1
View File
@@ -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 (
<div className="mx-auto max-w-4xl px-4 py-8 sm:px-6 sm:py-10">
<div className="mx-auto max-w-6xl px-4 py-8 sm:px-6 sm:py-10">
<div className="mb-7">
<h1 className="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">
Meine Codes
+417 -3
View File
@@ -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<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);
@@ -38,6 +81,23 @@ export function CodesList() {
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");
@@ -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<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}
@@ -159,11 +361,222 @@ export function CodesList() {
</Card>
) : null}
{codes.map((code) => (
{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">
<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={`/api/codes/${code.id}/qr?format=png&download=1`}
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"
>
@@ -171,6 +584,7 @@ export function CodesList() {
<img
src={`/api/codes/${code.id}/qr`}
alt={`QR-Code für ${code.summary}`}
loading="lazy"
className="h-full w-full object-contain"
/>
</a>
+21
View File
@@ -115,6 +115,27 @@ const PATHS = {
</>
),
check: <path d="m4.5 12.5 5 5 10-11" />,
search: (
<>
<circle cx="10.8" cy="10.8" r="6.3" />
<path d="m15.5 15.5 5 5" />
</>
),
viewList: (
<>
<rect x="3" y="4.5" width="18" height="4" rx="1.2" />
<rect x="3" y="10" width="18" height="4" rx="1.2" />
<rect x="3" y="15.5" width="18" height="4" rx="1.2" />
</>
),
viewGrid: (
<>
<rect x="3.5" y="3.5" width="7" height="7" rx="1.5" />
<rect x="13.5" y="3.5" width="7" height="7" rx="1.5" />
<rect x="3.5" y="13.5" width="7" height="7" rx="1.5" />
<rect x="13.5" y="13.5" width="7" height="7" rx="1.5" />
</>
),
eye: (
<>
<path d="M2.5 12s3.6-6.5 9.5-6.5S21.5 12 21.5 12s-3.6 6.5-9.5 6.5S2.5 12 2.5 12Z" />