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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user