feat(katalog): set-katalog mit filtern, detailseite und uploads

Kachelliste mit Suche ueber Setnummer, Name und EAN sowie Filtern nach
Hersteller, Thema, Jahr und Besitzstatus. Besitz kommt aus genau einer
Stelle: src/lib/ownership.ts definiert "hat er nicht" als Katalogeintrag
ohne collection_item mit Status ungleich sold, als korrelierte
Subquery, damit mehrere Exemplare die Liste nicht verdoppeln.

Detailseite mit Metadaten, Anleitungen (PDF-Upload plus pdf.js-Viewer
ohne CDN, oder externer Link), Fotos, Sammlungseintraegen und den
Aktionen "Zur Sammlung" und "Auf Wunschliste".

Upload-Pipeline prueft Magic Bytes statt des gemeldeten MIME-Typs,
erzeugt Dateinamen serverseitig, wandelt Bilder in WebP inklusive
Thumbnail und liefert Dateien ueber /api/files aus - Set-Bilder
oeffentlich, Fotos und PDFs nur mit Session.

Dazu das Seed-Skript mit 3 Herstellern, 6 Themen, 10 Sets und
4 Sammlungseintraegen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
unknown
2026-09-06 22:59:26 +02:00
co-authored by Claude Opus 5
parent af0ae039cb
commit 856800b4e4
29 changed files with 4273 additions and 0 deletions
+148
View File
@@ -0,0 +1,148 @@
'use client';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { useEffect, useState, useTransition } from 'react';
import { Loader2, Search, X } from 'lucide-react';
import { OWNERSHIP_FILTERS, OWNERSHIP_FILTER_LABELS } from '@/lib/ownership';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { NativeSelect } from '@/components/ui/native-select';
export type FilterOptions = {
manufacturers: Array<{ id: string; name: string }>;
themes: Array<{ id: string; name: string; manufacturerId: string }>;
years: number[];
};
/**
* Filters live in the URL, so every state of the catalog is a shareable link
* and the back button behaves the way people expect.
*/
export function CatalogFilters({ options }: { options: FilterOptions }) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [pending, startTransition] = useTransition();
const [query, setQuery] = useState(searchParams.get('q') ?? '');
const manufacturerId = searchParams.get('hersteller') ?? '';
const themeId = searchParams.get('thema') ?? '';
const year = searchParams.get('jahr') ?? '';
const ownership = searchParams.get('besitz') ?? 'alle';
function apply(changes: Record<string, string | null>) {
const params = new URLSearchParams(searchParams.toString());
for (const [key, value] of Object.entries(changes)) {
if (value === null || value === '') params.delete(key);
else params.set(key, value);
}
// Any filter change starts over at page one.
params.delete('seite');
startTransition(() => router.push(`${pathname}?${params.toString()}`));
}
// Debounced search so typing on a phone does not fire a request per keystroke.
useEffect(() => {
const current = searchParams.get('q') ?? '';
if (query === current) return;
const timer = setTimeout(() => apply({ q: query || null }), 350);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query]);
const visibleThemes = manufacturerId
? options.themes.filter((theme) => theme.manufacturerId === manufacturerId)
: options.themes;
const hasFilters = Boolean(manufacturerId || themeId || year || query) || ownership !== 'alle';
return (
<div className="space-y-3">
<div className="relative">
<Search
className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground"
aria-hidden
/>
<Input
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Setnummer, Name oder EAN"
aria-label="Sets durchsuchen"
className="pl-9"
/>
{pending ? (
<Loader2 className="absolute top-1/2 right-3 size-4 -translate-y-1/2 animate-spin text-muted-foreground" />
) : null}
</div>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
<NativeSelect
aria-label="Besitzstatus"
value={ownership}
onChange={(event) => apply({ besitz: event.target.value })}
>
{OWNERSHIP_FILTERS.map((value) => (
<option key={value} value={value}>
{OWNERSHIP_FILTER_LABELS[value]}
</option>
))}
</NativeSelect>
<NativeSelect
aria-label="Hersteller"
value={manufacturerId}
onChange={(event) => apply({ hersteller: event.target.value || null, thema: null })}
>
<option value="">Alle Hersteller</option>
{options.manufacturers.map((manufacturer) => (
<option key={manufacturer.id} value={manufacturer.id}>
{manufacturer.name}
</option>
))}
</NativeSelect>
<NativeSelect
aria-label="Thema"
value={themeId}
onChange={(event) => apply({ thema: event.target.value || null })}
>
<option value="">Alle Themen</option>
{visibleThemes.map((theme) => (
<option key={theme.id} value={theme.id}>
{theme.name}
</option>
))}
</NativeSelect>
<NativeSelect
aria-label="Jahr"
value={year}
onChange={(event) => apply({ jahr: event.target.value || null })}
>
<option value="">Alle Jahre</option>
{options.years.map((value) => (
<option key={value} value={String(value)}>
{value}
</option>
))}
</NativeSelect>
</div>
{hasFilters ? (
<Button
variant="ghost"
size="sm"
onClick={() => {
setQuery('');
startTransition(() => router.push(pathname));
}}
>
<X />
Filter zurücksetzen
</Button>
) : null}
</div>
);
}
+306
View File
@@ -0,0 +1,306 @@
'use client';
import { useRouter } from 'next/navigation';
import { useRef, useState, useTransition } from 'react';
import { CheckCheck, Gift, Loader2, X } from 'lucide-react';
import { toast } from 'sonner';
import { cn } from 'cn';
import { addSetsToWishlist, createWishlist } from '@/app/(app)/wunschlisten/actions';
import type { CatalogRow } from '@/lib/queries/catalog';
import { PRIORITY_LABELS } from '@/lib/wishlist-logic';
import { SetCard, SetCardContent } from './set-card';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Field } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { NativeSelect } from '@/components/ui/native-select';
const LONG_PRESS_MS = 450;
const NEW_LIST = '__neu__';
export type WishlistTarget = { id: string; title: string };
type Props = {
sets: CatalogRow[];
wishlists: WishlistTarget[];
/** Preselected target, used by the embedded view on a wishlist page. */
defaultWishlistId?: string;
emptyState?: React.ReactNode;
};
/**
* The catalog grid with its selection mode.
*
* On a phone a long press starts the selection, afterwards a tap toggles.
* On a desktop the "Auswählen" button does the same job. Sets that are already
* in the collection cannot be selected - they do not belong on a wishlist.
*/
export function CatalogGrid({ sets, wishlists, defaultWishlistId, emptyState }: Props) {
const router = useRouter();
const [selecting, setSelecting] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [dialogOpen, setDialogOpen] = useState(false);
const [targetId, setTargetId] = useState(defaultWishlistId ?? wishlists[0]?.id ?? NEW_LIST);
const [newTitle, setNewTitle] = useState('');
const [priority, setPriority] = useState('2');
const [pending, startTransition] = useTransition();
const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const longPressFired = useRef(false);
const selectable = sets.filter((item) => item.ownedCopies === 0);
function toggle(setId: string) {
setSelected((current) => {
const next = new Set(current);
if (next.has(setId)) next.delete(setId);
else next.add(setId);
return next;
});
}
function startLongPress(item: CatalogRow) {
if (item.ownedCopies > 0) return;
longPressFired.current = false;
longPressTimer.current = setTimeout(() => {
longPressFired.current = true;
setSelecting(true);
toggle(item.id);
// A short buzz confirms the mode switch where the device supports it.
navigator.vibrate?.(15);
}, LONG_PRESS_MS);
}
function cancelLongPress() {
if (longPressTimer.current) clearTimeout(longPressTimer.current);
longPressTimer.current = null;
}
function endSelection() {
setSelecting(false);
setSelected(new Set());
}
function submit() {
const setIds = [...selected];
if (setIds.length === 0) return;
startTransition(async () => {
let wishlistId = targetId;
if (wishlistId === NEW_LIST) {
const created = await createWishlist({ title: newTitle, occasion: 'other' });
if (!created.ok) {
toast.error(created.error);
return;
}
wishlistId = created.data.id;
}
const result = await addSetsToWishlist({ wishlistId, setIds, priority });
if (!result.ok) {
toast.error(result.error);
return;
}
if (result.data.added === 0) toast.warning(result.data.message);
else toast.success(result.data.message);
setDialogOpen(false);
endSelection();
router.refresh();
});
}
if (sets.length === 0) {
return (
<div className="rounded-lg border border-dashed border-border p-8 text-center text-sm text-muted-foreground">
{emptyState ?? 'Keine Sets gefunden. Passe die Filter an oder leg ein neues Set an.'}
</div>
);
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between gap-2">
<p className="text-xs text-muted-foreground">
{selecting
? 'Tippe die Sets an, die auf die Wunschliste sollen.'
: 'Tipp: Kachel lange drücken, um mehrere Sets auszuwählen.'}
</p>
{selecting ? (
<Button variant="ghost" size="sm" onClick={endSelection}>
<X />
Abbrechen
</Button>
) : (
<Button
variant="outline"
size="sm"
onClick={() => setSelecting(true)}
disabled={selectable.length === 0}
>
<CheckCheck />
Auswählen
</Button>
)}
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
{sets.map((item) => {
const owned = item.ownedCopies > 0;
const isSelected = selected.has(item.id);
if (!selecting) {
return (
<div
key={item.id}
onPointerDown={() => startLongPress(item)}
onPointerUp={cancelLongPress}
onPointerLeave={cancelLongPress}
onPointerCancel={cancelLongPress}
onContextMenu={(event) => {
// The long press must not open the browser context menu.
if (longPressFired.current) event.preventDefault();
}}
onClickCapture={(event) => {
if (longPressFired.current) {
event.preventDefault();
event.stopPropagation();
longPressFired.current = false;
}
}}
>
<SetCard set={item} className="block h-full" />
</div>
);
}
return (
<button
key={item.id}
type="button"
disabled={owned}
aria-pressed={isSelected}
onClick={() => toggle(item.id)}
title={owned ? 'Schon in der Sammlung' : undefined}
className={cn(
'relative rounded-xl text-left outline-none transition-opacity',
'focus-visible:ring-3 focus-visible:ring-ring/50',
owned && 'cursor-not-allowed opacity-50',
isSelected && '[&>div]:border-primary [&>div]:ring-2 [&>div]:ring-primary/40',
)}
>
<SetCardContent set={item} />
{!owned ? (
<span className="absolute top-2 right-2 rounded-md bg-background/90 p-1 shadow-sm">
<Checkbox checked={isSelected} tabIndex={-1} aria-hidden />
</span>
) : null}
</button>
);
})}
</div>
{selecting ? (
<div className="fixed inset-x-0 bottom-0 z-50 border-t border-border bg-background/95 p-3 pb-[calc(env(safe-area-inset-bottom)+0.75rem)] backdrop-blur">
<div className="mx-auto flex max-w-5xl items-center gap-3">
<span className="text-sm font-medium">
{selected.size} ausgewählt
</span>
<div className="flex-1" />
<Button
size="lg"
disabled={selected.size === 0}
onClick={() => setDialogOpen(true)}
>
<Gift />
Zur Wunschliste hinzufügen
</Button>
</div>
</div>
) : null}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{selected.size === 1
? '1 Set auf die Wunschliste'
: `${selected.size} Sets auf die Wunschliste`}
</DialogTitle>
<DialogDescription>
Sets, die schon auf der Liste oder bereits in der Sammlung sind, werden
übersprungen.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<Field label="Wunschliste" htmlFor="target-wishlist" required>
<NativeSelect
id="target-wishlist"
value={targetId}
onChange={(event) => setTargetId(event.target.value)}
>
{wishlists.map((list) => (
<option key={list.id} value={list.id}>
{list.title}
</option>
))}
<option value={NEW_LIST}>+ Neue Wunschliste anlegen</option>
</NativeSelect>
</Field>
{targetId === NEW_LIST ? (
<Field label="Titel der neuen Liste" htmlFor="new-title" required>
<Input
id="new-title"
value={newTitle}
onChange={(event) => setNewTitle(event.target.value)}
placeholder="Weihnachten 2026"
maxLength={80}
/>
</Field>
) : null}
<Field
label="Priorität"
htmlFor="priority"
hint="Gilt für alle ausgewählten Sets und lässt sich später je Eintrag ändern."
>
<NativeSelect
id="priority"
value={priority}
onChange={(event) => setPriority(event.target.value)}
>
{Object.entries(PRIORITY_LABELS).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</NativeSelect>
</Field>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)} disabled={pending}>
Abbrechen
</Button>
<Button onClick={submit} disabled={pending}>
{pending ? <Loader2 className="animate-spin" /> : <Gift />}
Hinzufügen
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -0,0 +1,215 @@
'use client';
import { useRouter } from 'next/navigation';
import { useState, useTransition } from 'react';
import { Download, ExternalLink, FileText, Loader2, Plus, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import {
addInstructionPdf,
addInstructionUrl,
deleteInstruction,
} from '@/app/(app)/katalog/actions';
import { fileUrl } from '@/lib/images';
import { PdfViewer } from './pdf-viewer';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Field } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { NativeSelect } from '@/components/ui/native-select';
export type InstructionRow = {
id: string;
kind: 'pdf_upload' | 'external_url';
filePath: string | null;
url: string | null;
label: string | null;
fileSizeBytes: number | null;
};
function formatSize(bytes: number | null): string {
if (!bytes) return '';
const mb = bytes / (1024 * 1024);
return mb >= 1
? `${mb.toLocaleString('de-DE', { maximumFractionDigits: 1 })} MB`
: `${Math.round(bytes / 1024)} KB`;
}
export function InstructionsSection({
setId,
instructions,
}: {
setId: string;
instructions: InstructionRow[];
}) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [addOpen, setAddOpen] = useState(false);
const [kind, setKind] = useState<'pdf_upload' | 'external_url'>('pdf_upload');
const [viewing, setViewing] = useState<InstructionRow | null>(null);
function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const formData = new FormData(event.currentTarget);
startTransition(async () => {
const result =
kind === 'pdf_upload'
? await addInstructionPdf(setId, formData)
: await addInstructionUrl(setId, Object.fromEntries(formData.entries()));
if (!result.ok) {
toast.error(result.error);
return;
}
toast.success('Anleitung hinzugefügt.');
setAddOpen(false);
router.refresh();
});
}
function remove(id: string) {
startTransition(async () => {
const result = await deleteInstruction(id);
if (!result.ok) {
toast.error(result.error);
return;
}
toast.success('Anleitung entfernt.');
router.refresh();
});
}
return (
<div className="space-y-3">
{instructions.length === 0 ? (
<p className="text-sm text-muted-foreground">
Noch keine Anleitung hinterlegt. Lade das PDF hoch oder verlinke es beim Hersteller.
</p>
) : (
<ul className="divide-y divide-border rounded-lg border border-border">
{instructions.map((item) => (
<li key={item.id} className="flex items-center gap-3 p-3">
<FileText className="size-5 shrink-0 text-muted-foreground" aria-hidden />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">
{item.label ?? (item.kind === 'pdf_upload' ? 'Anleitung' : 'Externe Anleitung')}
</p>
<p className="text-xs text-muted-foreground">
{item.kind === 'pdf_upload' ? `PDF ${formatSize(item.fileSizeBytes)}` : 'Link'}
</p>
</div>
{item.kind === 'pdf_upload' && item.filePath ? (
<>
<Button variant="outline" size="sm" onClick={() => setViewing(item)}>
Ansehen
</Button>
<Button asChild variant="ghost" size="icon-sm" aria-label="Herunterladen">
<a href={fileUrl(item.filePath)} download>
<Download />
</a>
</Button>
</>
) : item.url ? (
<Button asChild variant="outline" size="sm">
<a href={item.url} target="_blank" rel="noreferrer noopener">
Öffnen
<ExternalLink />
</a>
</Button>
) : null}
<Button
variant="ghost"
size="icon-sm"
aria-label="Anleitung entfernen"
onClick={() => remove(item.id)}
disabled={pending}
>
<Trash2 className="text-destructive" />
</Button>
</li>
))}
</ul>
)}
<Button variant="outline" onClick={() => setAddOpen(true)}>
<Plus />
Anleitung hinzufügen
</Button>
<Dialog open={addOpen} onOpenChange={setAddOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Anleitung hinzufügen</DialogTitle>
<DialogDescription>
Ein Set kann mehrere Anleitungen haben – zum Beispiel „Buch 1 von 3“.
</DialogDescription>
</DialogHeader>
<form id="instruction-form" onSubmit={submit} className="space-y-4">
<Field label="Art" htmlFor="instruction-kind">
<NativeSelect
id="instruction-kind"
value={kind}
onChange={(event) => setKind(event.target.value as typeof kind)}
>
<option value="pdf_upload">PDF hochladen</option>
<option value="external_url">Link zur Anleitung</option>
</NativeSelect>
</Field>
<Field label="Bezeichnung" htmlFor="label" hint="Optional, z. B. „Buch 1 von 3“.">
<Input id="label" name="label" maxLength={80} />
</Field>
{kind === 'pdf_upload' ? (
<Field label="PDF-Datei" htmlFor="file" required>
<Input id="file" name="file" type="file" accept="application/pdf" required />
</Field>
) : (
<Field label="Adresse" htmlFor="url" required>
<Input
id="url"
name="url"
type="url"
inputMode="url"
required
placeholder="https://www.lego.com/…"
/>
</Field>
)}
</form>
<DialogFooter>
<Button variant="outline" onClick={() => setAddOpen(false)} disabled={pending}>
Abbrechen
</Button>
<Button type="submit" form="instruction-form" disabled={pending}>
{pending ? <Loader2 className="animate-spin" /> : null}
Hinzufügen
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={viewing !== null} onOpenChange={(open) => !open && setViewing(null)}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>{viewing?.label ?? 'Anleitung'}</DialogTitle>
</DialogHeader>
{viewing?.filePath ? (
<PdfViewer url={fileUrl(viewing.filePath)} label={viewing.label ?? 'Anleitung'} />
) : null}
</DialogContent>
</Dialog>
</div>
);
}
+54
View File
@@ -0,0 +1,54 @@
'use client';
import { usePathname, useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { Button } from '@/components/ui/button';
export function Pagination({
page,
total,
pageSize,
}: {
page: number;
total: number;
pageSize: number;
}) {
const pathname = usePathname();
const searchParams = useSearchParams();
const lastPage = Math.max(1, Math.ceil(total / pageSize));
if (lastPage <= 1) return null;
function hrefFor(target: number) {
const params = new URLSearchParams(searchParams.toString());
if (target <= 1) params.delete('seite');
else params.set('seite', String(target));
const query = params.toString();
return query ? `${pathname}?${query}` : pathname;
}
return (
<nav className="flex items-center justify-between gap-3" aria-label="Seiten">
<Button asChild variant="outline" disabled={page <= 1}>
<Link href={hrefFor(page - 1)} aria-disabled={page <= 1} className={page <= 1 ? 'pointer-events-none opacity-50' : ''}>
<ChevronLeft />
Zurück
</Link>
</Button>
<span className="text-sm text-muted-foreground">
Seite {page} von {lastPage}
</span>
<Button asChild variant="outline" disabled={page >= lastPage}>
<Link
href={hrefFor(page + 1)}
aria-disabled={page >= lastPage}
className={page >= lastPage ? 'pointer-events-none opacity-50' : ''}
>
Weiter
<ChevronRight />
</Link>
</Button>
</nav>
);
}
+155
View File
@@ -0,0 +1,155 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import { ChevronLeft, ChevronRight, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
/**
* Minimal pdf.js viewer for building instructions.
*
* The library and its worker are bundled with the app - no CDN, no external
* request. One page at a time, because that is how you actually build a set,
* and because rendering 200 pages at once would stall a phone.
*/
export function PdfViewer({ url, label }: { url: string; label: string }) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const documentRef = useRef<any>(null);
const renderTaskRef = useRef<{ cancel: () => void } | null>(null);
const loadingTaskRef = useRef<{ destroy: () => Promise<void> } | null>(null);
const [page, setPage] = useState(1);
const [pageCount, setPageCount] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
async function load() {
try {
const pdfjs = await import('pdfjs-dist');
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url,
).toString();
const loadingTask = pdfjs.getDocument({ url });
const document = await loadingTask.promise;
if (cancelled) {
void loadingTask.destroy();
return;
}
loadingTaskRef.current = loadingTask;
documentRef.current = document;
setPageCount(document.numPages);
setPage(1);
} catch (cause) {
console.error('[pdf] konnte nicht geladen werden', cause);
if (!cancelled) setError('Die Anleitung konnte nicht geladen werden.');
}
}
void load();
return () => {
cancelled = true;
renderTaskRef.current?.cancel();
void loadingTaskRef.current?.destroy();
loadingTaskRef.current = null;
documentRef.current = null;
};
}, [url]);
const renderPage = useCallback(async (pageNumber: number) => {
const document = documentRef.current;
const canvas = canvasRef.current;
const container = containerRef.current;
if (!document || !canvas || !container) return;
setLoading(true);
renderTaskRef.current?.cancel();
try {
const pdfPage = await document.getPage(pageNumber);
const unscaled = pdfPage.getViewport({ scale: 1 });
// Fit the width of the container, but stay sharp on high-DPI screens.
const scale = (container.clientWidth || 600) / unscaled.width;
const ratio = Math.min(window.devicePixelRatio || 1, 2);
const viewport = pdfPage.getViewport({ scale: scale * ratio });
canvas.width = viewport.width;
canvas.height = viewport.height;
canvas.style.width = '100%';
canvas.style.height = 'auto';
const context = canvas.getContext('2d');
if (!context) return;
const task = pdfPage.render({ canvas, canvasContext: context, viewport });
renderTaskRef.current = task;
await task.promise;
} catch (cause) {
if ((cause as { name?: string })?.name !== 'RenderingCancelledException') {
console.error('[pdf] Seite konnte nicht gezeichnet werden', cause);
}
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (pageCount > 0) void renderPage(page);
}, [page, pageCount, renderPage]);
if (error) {
return (
<div className="space-y-3 rounded-lg border border-border p-4 text-sm">
<p className="text-destructive">{error}</p>
<Button asChild variant="outline" size="sm">
<a href={url} target="_blank" rel="noreferrer">
PDF direkt öffnen
</a>
</Button>
</div>
);
}
return (
<div className="space-y-3">
<div ref={containerRef} className="relative rounded-lg border border-border bg-muted/30 p-2">
<canvas ref={canvasRef} aria-label={`${label}, Seite ${page}`} className="mx-auto block" />
{loading ? (
<div className="absolute inset-0 flex items-center justify-center bg-background/60">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
) : null}
</div>
<div className="flex items-center justify-between gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPage((current) => Math.max(1, current - 1))}
disabled={page <= 1 || loading}
>
<ChevronLeft />
Zurück
</Button>
<span className="text-sm text-muted-foreground">
Seite {page} von {pageCount || '…'}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setPage((current) => Math.min(pageCount, current + 1))}
disabled={page >= pageCount || loading}
>
Weiter
<ChevronRight />
</Button>
</div>
</div>
);
}
+138
View File
@@ -0,0 +1,138 @@
'use client';
import { useRouter } from 'next/navigation';
import { useState, useTransition } from 'react';
import { Camera, Loader2, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { addSetPhoto, deletePhoto } from '@/app/(app)/katalog/actions';
import { fileUrl } from '@/lib/images';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Field } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
export type PhotoRow = {
id: string;
filePath: string;
thumbnailPath: string | null;
caption: string | null;
};
export function PhotosSection({ setId, photos }: { setId: string; photos: PhotoRow[] }) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [addOpen, setAddOpen] = useState(false);
function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const formData = new FormData(event.currentTarget);
startTransition(async () => {
const result = await addSetPhoto(setId, formData);
if (!result.ok) {
toast.error(result.error);
return;
}
toast.success('Foto gespeichert.');
setAddOpen(false);
router.refresh();
});
}
function remove(id: string) {
startTransition(async () => {
const result = await deletePhoto(id);
if (!result.ok) {
toast.error(result.error);
return;
}
toast.success('Foto gelöscht.');
router.refresh();
});
}
return (
<div className="space-y-3">
{photos.length > 0 ? (
<ul className="grid grid-cols-3 gap-2 sm:grid-cols-4">
{photos.map((item) => (
<li key={item.id} className="group relative">
<a
href={fileUrl(item.filePath)}
target="_blank"
rel="noreferrer"
className="block overflow-hidden rounded-lg border border-border"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={fileUrl(item.thumbnailPath ?? item.filePath)}
alt={item.caption ?? ''}
loading="lazy"
className="aspect-square w-full object-cover"
/>
</a>
<Button
variant="secondary"
size="icon-xs"
aria-label="Foto löschen"
className="absolute top-1 right-1 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
onClick={() => remove(item.id)}
disabled={pending}
>
<Trash2 className="text-destructive" />
</Button>
</li>
))}
</ul>
) : (
<p className="text-sm text-muted-foreground">
Noch keine eigenen Fotos – zum Beispiel vom fertig gebauten Modell.
</p>
)}
<Button variant="outline" onClick={() => setAddOpen(true)}>
<Camera />
Foto hinzufügen
</Button>
<Dialog open={addOpen} onOpenChange={setAddOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Foto hinzufügen</DialogTitle>
</DialogHeader>
<form id="photo-form" onSubmit={submit} className="space-y-4">
<Field label="Bild" htmlFor="photo-file" required>
{/* capture lets the phone open the camera directly. */}
<Input
id="photo-file"
name="file"
type="file"
accept="image/jpeg,image/png,image/webp"
capture="environment"
required
/>
</Field>
<Field label="Bildunterschrift" htmlFor="caption">
<Input id="caption" name="caption" maxLength={160} />
</Field>
</form>
<DialogFooter>
<Button variant="outline" onClick={() => setAddOpen(false)} disabled={pending}>
Abbrechen
</Button>
<Button type="submit" form="photo-form" disabled={pending}>
{pending ? <Loader2 className="animate-spin" /> : null}
Speichern
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+216
View File
@@ -0,0 +1,216 @@
'use client';
import { useRouter } from 'next/navigation';
import { useState, useTransition } from 'react';
import { Gift, Loader2, Plus, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { deleteSet } from '@/app/(app)/katalog/actions';
import { addSetToCollection } from '@/app/(app)/sammlung/actions';
import { addSetsToWishlist, createWishlist } from '@/app/(app)/wunschlisten/actions';
import { PRIORITY_LABELS } from '@/lib/wishlist-logic';
import { Button } from '@/components/ui/button';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Field } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { NativeSelect } from '@/components/ui/native-select';
const NEW_LIST = '__neu__';
export function SetActions({
setId,
setName,
owned,
wishlists,
}: {
setId: string;
setName: string;
owned: boolean;
wishlists: Array<{ id: string; title: string }>;
}) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [wishlistOpen, setWishlistOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [targetId, setTargetId] = useState(wishlists[0]?.id ?? NEW_LIST);
const [newTitle, setNewTitle] = useState('');
const [priority, setPriority] = useState('2');
function addToCollection() {
startTransition(async () => {
const result = await addSetToCollection(setId);
if (!result.ok) {
toast.error(result.error);
return;
}
toast.success('Zur Sammlung hinzugefügt.');
router.refresh();
});
}
function addToWishlist() {
startTransition(async () => {
let wishlistId = targetId;
if (wishlistId === NEW_LIST) {
const created = await createWishlist({ title: newTitle, occasion: 'other' });
if (!created.ok) {
toast.error(created.error);
return;
}
wishlistId = created.data.id;
}
const result = await addSetsToWishlist({ wishlistId, setIds: [setId], priority });
if (!result.ok) {
toast.error(result.error);
return;
}
if (result.data.added === 0) toast.warning(result.data.message);
else toast.success('Auf die Wunschliste gesetzt.');
setWishlistOpen(false);
router.refresh();
});
}
function remove() {
startTransition(async () => {
const result = await deleteSet(setId);
if (!result.ok) {
toast.error(result.error);
return;
}
toast.success('Set gelöscht.');
router.push('/katalog');
router.refresh();
});
}
return (
<div className="flex flex-wrap gap-2">
<Button size="lg" onClick={addToCollection} disabled={pending}>
{pending ? <Loader2 className="animate-spin" /> : <Plus />}
Zur Sammlung hinzufügen
</Button>
<Button
size="lg"
variant="outline"
onClick={() => setWishlistOpen(true)}
disabled={pending || owned}
title={owned ? 'Das Set ist bereits in der Sammlung.' : undefined}
>
<Gift />
Auf Wunschliste
</Button>
<Button size="lg" variant="ghost" onClick={() => setDeleteOpen(true)} disabled={pending}>
<Trash2 className="text-destructive" />
Löschen
</Button>
{owned ? (
<p className="w-full text-xs text-muted-foreground">
Sets aus der Sammlung lassen sich nicht auf eine Wunschliste setzen.
</p>
) : null}
<Dialog open={wishlistOpen} onOpenChange={setWishlistOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Auf Wunschliste setzen</DialogTitle>
<DialogDescription>{setName}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<Field label="Wunschliste" htmlFor="single-target" required>
<NativeSelect
id="single-target"
value={targetId}
onChange={(event) => setTargetId(event.target.value)}
>
{wishlists.map((list) => (
<option key={list.id} value={list.id}>
{list.title}
</option>
))}
<option value={NEW_LIST}>+ Neue Wunschliste anlegen</option>
</NativeSelect>
</Field>
{targetId === NEW_LIST ? (
<Field label="Titel der neuen Liste" htmlFor="single-new-title" required>
<Input
id="single-new-title"
value={newTitle}
onChange={(event) => setNewTitle(event.target.value)}
placeholder="Weihnachten 2026"
maxLength={80}
/>
</Field>
) : null}
<Field label="Priorität" htmlFor="single-priority">
<NativeSelect
id="single-priority"
value={priority}
onChange={(event) => setPriority(event.target.value)}
>
{Object.entries(PRIORITY_LABELS).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</NativeSelect>
</Field>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setWishlistOpen(false)} disabled={pending}>
Abbrechen
</Button>
<Button onClick={addToWishlist} disabled={pending}>
{pending ? <Loader2 className="animate-spin" /> : <Gift />}
Hinzufügen
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Set wirklich löschen?</AlertDialogTitle>
<AlertDialogDescription>
„{setName}“ wird aus dem Katalog entfernt – mit allen Sammlungseinträgen,
Anleitungen, Fotos und Wunschlisten-Einträgen dazu. Das lässt sich nicht rückgängig
machen.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Abbrechen</AlertDialogCancel>
<AlertDialogAction onClick={remove}>Löschen</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
+88
View File
@@ -0,0 +1,88 @@
import Link from 'next/link';
import { Check, Gift, ImageOff } from 'lucide-react';
import { cn } from 'cn';
import type { CatalogRow } from '@/lib/queries/catalog';
import { formatEuro } from '@/lib/money';
import { setImageSrc } from '@/lib/images';
import { Badge } from '@/components/ui/badge';
/**
* One set in the catalog grid.
*
* Rendered as a plain block so it can be used both as a link (browsing) and
* inside a selection wrapper (bulk add to a wishlist).
*/
export function SetCardContent({ set }: { set: CatalogRow }) {
const image = setImageSrc(set);
const owned = set.ownedCopies > 0;
return (
<div className="flex h-full flex-col overflow-hidden rounded-xl border border-border bg-card transition-colors">
<div className="relative aspect-square bg-muted">
{image ? (
// Plain img: images are either local files or a URL the user entered,
// and the optimizer must never fetch arbitrary remote hosts.
// eslint-disable-next-line @next/next/no-img-element
<img
src={image}
alt=""
loading="lazy"
decoding="async"
className="size-full object-contain p-2"
/>
) : (
<div className="flex size-full items-center justify-center text-muted-foreground">
<ImageOff className="size-8" aria-hidden />
</div>
)}
<div className="absolute top-2 left-2 flex flex-col items-start gap-1">
{owned ? (
<Badge className="gap-1 bg-emerald-600 text-white hover:bg-emerald-600">
<Check className="size-3" aria-hidden />
{set.ownedCopies > 1 ? `${set.ownedCopies}× vorhanden` : 'Vorhanden'}
</Badge>
) : null}
{set.wishlistCount > 0 ? (
<Badge variant="secondary" className="gap-1">
<Gift className="size-3" aria-hidden />
Auf Wunschliste
</Badge>
) : null}
</div>
</div>
<div className="flex flex-1 flex-col gap-1 p-3">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Badge variant="outline" className="font-mono">
{set.setNumber}
</Badge>
<span className="truncate">{set.manufacturerName}</span>
</div>
<p className="line-clamp-2 text-sm leading-snug font-medium">{set.name}</p>
<div className="mt-auto flex items-center justify-between gap-2 pt-1 text-xs text-muted-foreground">
<span className="truncate">{[set.themeName, set.year].filter(Boolean).join(' · ')}</span>
{set.retailPriceCents !== null ? (
<span className="shrink-0 font-medium text-foreground">
{formatEuro(set.retailPriceCents)}
</span>
) : null}
</div>
</div>
</div>
);
}
export function SetCard({ set, className }: { set: CatalogRow; className?: string }) {
return (
<Link
href={`/katalog/${set.id}`}
className={cn(
'rounded-xl outline-none focus-visible:ring-3 focus-visible:ring-ring/50 [&>div]:hover:border-primary/40',
className,
)}
>
<SetCardContent set={set} />
</Link>
);
}
+248
View File
@@ -0,0 +1,248 @@
'use client';
import { useRouter } from 'next/navigation';
import { useState, useTransition } from 'react';
import { Loader2, Save } from 'lucide-react';
import { toast } from 'sonner';
import { createSet, updateSet } from '@/app/(app)/katalog/actions';
import { centsToInput } from '@/lib/money';
import { setImageSrc } from '@/lib/images';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Field } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { NativeSelect } from '@/components/ui/native-select';
import { Textarea } from '@/components/ui/textarea';
export type SetFormValues = {
id: string;
manufacturerId: string;
themeId: string | null;
setNumber: string;
name: string;
year: number | null;
pieceCount: number | null;
minifigCount: number | null;
ean: string | null;
ageRange: string | null;
imageUrl: string | null;
imagePath: string | null;
retailPriceCents: number | null;
notes: string | null;
};
type Props = {
manufacturers: Array<{ id: string; name: string }>;
themes: Array<{ id: string; name: string; manufacturerId: string }>;
set?: SetFormValues;
/** Prefilled EAN coming from the barcode scanner (phase 2). */
defaultEan?: string;
};
export function SetForm({ manufacturers, themes, set, defaultEan }: Props) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [manufacturerId, setManufacturerId] = useState(
set?.manufacturerId ?? manufacturers[0]?.id ?? '',
);
const existingImage = set ? setImageSrc(set) : null;
const availableThemes = themes.filter((theme) => theme.manufacturerId === manufacturerId);
function onSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const formData = new FormData(event.currentTarget);
startTransition(async () => {
if (set) {
const result = await updateSet(set.id, formData);
if (!result.ok) {
toast.error(result.error);
return;
}
toast.success('Set gespeichert.');
router.push(`/katalog/${set.id}`);
router.refresh();
return;
}
const result = await createSet(formData);
if (!result.ok) {
toast.error(result.error);
return;
}
toast.success('Set angelegt.');
router.push(`/katalog/${result.data.id}`);
router.refresh();
});
}
return (
<form onSubmit={onSubmit} className="space-y-6">
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Hersteller" htmlFor="manufacturerId" required>
<NativeSelect
id="manufacturerId"
name="manufacturerId"
value={manufacturerId}
onChange={(event) => setManufacturerId(event.target.value)}
required
>
{manufacturers.map((manufacturer) => (
<option key={manufacturer.id} value={manufacturer.id}>
{manufacturer.name}
</option>
))}
</NativeSelect>
</Field>
<Field label="Thema" htmlFor="themeId" hint="Optional.">
<NativeSelect id="themeId" name="themeId" defaultValue={set?.themeId ?? 'none'}>
<option value="none">– kein Thema –</option>
{availableThemes.map((theme) => (
<option key={theme.id} value={theme.id}>
{theme.name}
</option>
))}
</NativeSelect>
</Field>
<Field label="Setnummer" htmlFor="setNumber" required hint="Die Nummer des Herstellers.">
<Input
id="setNumber"
name="setNumber"
defaultValue={set?.setNumber ?? ''}
required
maxLength={40}
placeholder="60398"
className="font-mono"
/>
</Field>
<Field label="Name" htmlFor="name" required className="sm:col-span-1">
<Input
id="name"
name="name"
defaultValue={set?.name ?? ''}
required
maxLength={160}
placeholder="Familienhaus und Elektroauto"
/>
</Field>
<Field label="Jahr" htmlFor="year">
<Input
id="year"
name="year"
type="number"
inputMode="numeric"
defaultValue={set?.year ?? ''}
min={1930}
max={new Date().getFullYear() + 5}
/>
</Field>
<Field label="Teile" htmlFor="pieceCount">
<Input
id="pieceCount"
name="pieceCount"
type="number"
inputMode="numeric"
defaultValue={set?.pieceCount ?? ''}
min={0}
/>
</Field>
<Field label="Minifiguren" htmlFor="minifigCount">
<Input
id="minifigCount"
name="minifigCount"
type="number"
inputMode="numeric"
defaultValue={set?.minifigCount ?? ''}
min={0}
/>
</Field>
<Field label="UVP" htmlFor="retailPriceCents" hint="In Euro, z. B. 89,99.">
<Input
id="retailPriceCents"
name="retailPriceCents"
inputMode="decimal"
defaultValue={centsToInput(set?.retailPriceCents)}
placeholder="89,99"
/>
</Field>
<Field label="EAN" htmlFor="ean" hint="Der Strichcode auf der Verpackung.">
<Input
id="ean"
name="ean"
inputMode="numeric"
defaultValue={set?.ean ?? defaultEan ?? ''}
maxLength={20}
className="font-mono"
/>
</Field>
<Field label="Alter" htmlFor="ageRange" hint="Zum Beispiel 6-12.">
<Input id="ageRange" name="ageRange" defaultValue={set?.ageRange ?? ''} maxLength={20} />
</Field>
</div>
<Field
label="Bild-URL"
htmlFor="imageUrl"
hint="Wird nur verwendet, wenn kein Bild hochgeladen ist."
>
<Input
id="imageUrl"
name="imageUrl"
type="url"
inputMode="url"
defaultValue={set?.imageUrl ?? ''}
placeholder="https://…"
/>
</Field>
<div className="space-y-2">
<Label htmlFor="image">Bild hochladen</Label>
{existingImage ? (
<div className="flex items-center gap-3">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={existingImage}
alt=""
className="size-20 rounded-lg border border-border object-contain"
/>
{set?.imagePath ? (
<label className="flex items-center gap-2 text-sm">
<Checkbox name="removeImage" value="on" />
Hochgeladenes Bild entfernen
</label>
) : null}
</div>
) : null}
<Input id="image" name="image" type="file" accept="image/jpeg,image/png,image/webp" />
<p className="text-xs text-muted-foreground">
JPEG, PNG oder WebP. Das Bild wird beim Speichern verkleinert und lokal abgelegt.
</p>
</div>
<Field label="Notizen" htmlFor="notes">
<Textarea id="notes" name="notes" defaultValue={set?.notes ?? ''} rows={4} />
</Field>
<div className="flex gap-3">
<Button type="submit" size="lg" disabled={pending}>
{pending ? <Loader2 className="animate-spin" /> : <Save />}
{set ? 'Speichern' : 'Set anlegen'}
</Button>
<Button type="button" variant="outline" size="lg" onClick={() => router.back()}>
Abbrechen
</Button>
</div>
</form>
);
}
@@ -0,0 +1,302 @@
'use client';
import { useRouter } from 'next/navigation';
import { useState, useTransition } from 'react';
import { Loader2, Pencil, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { deleteCollectionItem, updateCollectionItem } from '@/app/(app)/sammlung/actions';
import type { CollectionStatus, Completeness, Condition } from '@/db/types';
import { formatDate, toDateInputValue } from '@/lib/format';
import {
COMPLETENESS_LABELS,
CONDITION_LABELS,
STATUS_LABELS,
completenessLabel,
conditionLabel,
statusLabel,
} from '@/lib/labels';
import { centsToInput, formatEuro } from '@/lib/money';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Field } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { NativeSelect } from '@/components/ui/native-select';
import { Textarea } from '@/components/ui/textarea';
export type CollectionItemRow = {
id: string;
setId: string;
status: CollectionStatus;
completeness: Completeness;
condition: Condition | null;
locationId: string | null;
locationName?: string | null;
acquiredAt: Date | null;
acquiredPriceCents: number | null;
acquiredFrom: string | null;
giftedBy: string | null;
lentOutTo: string | null;
notes: string | null;
};
export function CollectionItemsSection({
items,
locations,
}: {
items: CollectionItemRow[];
locations: Array<{ id: string; name: string }>;
}) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [editing, setEditing] = useState<CollectionItemRow | null>(null);
const [deleting, setDeleting] = useState<CollectionItemRow | null>(null);
function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const item = editing;
if (!item) return;
const input = Object.fromEntries(new FormData(event.currentTarget).entries());
startTransition(async () => {
const result = await updateCollectionItem(item.id, { ...input, setId: item.setId });
if (!result.ok) {
toast.error(result.error);
return;
}
toast.success('Sammlungseintrag gespeichert.');
setEditing(null);
router.refresh();
});
}
function remove() {
const item = deleting;
if (!item) return;
startTransition(async () => {
const result = await deleteCollectionItem(item.id);
if (!result.ok) {
toast.error(result.error);
return;
}
toast.success('Sammlungseintrag entfernt.');
setDeleting(null);
router.refresh();
});
}
if (items.length === 0) {
return (
<p className="text-sm text-muted-foreground">
Dieses Set ist noch nicht in der Sammlung.
</p>
);
}
return (
<div className="space-y-3">
<ul className="divide-y divide-border rounded-lg border border-border">
{items.map((item) => (
<li key={item.id} className="flex items-start gap-3 p-3">
<div className="min-w-0 flex-1 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<Badge variant={item.status === 'sold' ? 'outline' : 'default'}>
{statusLabel(item.status)}
</Badge>
<Badge variant="secondary">{completenessLabel(item.completeness)}</Badge>
{item.locationName ? (
<span className="text-xs text-muted-foreground">{item.locationName}</span>
) : null}
</div>
<p className="text-xs text-muted-foreground">
{[
item.acquiredAt ? `Seit ${formatDate(item.acquiredAt)}` : null,
item.acquiredPriceCents !== null
? `Gekauft für ${formatEuro(item.acquiredPriceCents)}`
: null,
item.giftedBy ? `Geschenk von ${item.giftedBy}` : null,
item.lentOutTo ? `Verliehen an ${item.lentOutTo}` : null,
item.condition ? conditionLabel(item.condition) : null,
]
.filter(Boolean)
.join(' · ') || 'Keine weiteren Angaben'}
</p>
{item.notes ? <p className="text-xs">{item.notes}</p> : null}
</div>
<Button
variant="ghost"
size="icon-sm"
aria-label="Bearbeiten"
onClick={() => setEditing(item)}
disabled={pending}
>
<Pencil />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label="Entfernen"
onClick={() => setDeleting(item)}
disabled={pending}
>
<Trash2 className="text-destructive" />
</Button>
</li>
))}
</ul>
<Dialog open={editing !== null} onOpenChange={(open) => !open && setEditing(null)}>
<DialogContent className="max-h-[85dvh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Sammlungseintrag bearbeiten</DialogTitle>
</DialogHeader>
<form id="collection-item-form" onSubmit={submit} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Status" htmlFor="status">
<NativeSelect id="status" name="status" defaultValue={editing?.status ?? 'owned'}>
{Object.entries(STATUS_LABELS).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</NativeSelect>
</Field>
<Field label="Vollständigkeit" htmlFor="completeness">
<NativeSelect
id="completeness"
name="completeness"
defaultValue={editing?.completeness ?? 'unknown'}
>
{Object.entries(COMPLETENESS_LABELS).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</NativeSelect>
</Field>
<Field label="Zustand" htmlFor="condition">
<NativeSelect id="condition" name="condition" defaultValue={editing?.condition ?? 'none'}>
<option value="none">– keine Angabe –</option>
{Object.entries(CONDITION_LABELS).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</NativeSelect>
</Field>
<Field label="Standort" htmlFor="locationId">
<NativeSelect
id="locationId"
name="locationId"
defaultValue={editing?.locationId ?? 'none'}
>
<option value="none">– kein Standort –</option>
{locations.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</NativeSelect>
</Field>
<Field label="Im Besitz seit" htmlFor="acquiredAt">
<Input
id="acquiredAt"
name="acquiredAt"
type="date"
defaultValue={toDateInputValue(editing?.acquiredAt)}
/>
</Field>
<Field label="Kaufpreis" htmlFor="acquiredPriceCents" hint="In Euro.">
<Input
id="acquiredPriceCents"
name="acquiredPriceCents"
inputMode="decimal"
defaultValue={centsToInput(editing?.acquiredPriceCents)}
/>
</Field>
<Field label="Gekauft bei" htmlFor="acquiredFrom">
<Input
id="acquiredFrom"
name="acquiredFrom"
defaultValue={editing?.acquiredFrom ?? ''}
maxLength={120}
/>
</Field>
<Field label="Geschenkt von" htmlFor="giftedBy">
<Input
id="giftedBy"
name="giftedBy"
defaultValue={editing?.giftedBy ?? ''}
maxLength={120}
/>
</Field>
<Field label="Verliehen an" htmlFor="lentOutTo">
<Input
id="lentOutTo"
name="lentOutTo"
defaultValue={editing?.lentOutTo ?? ''}
maxLength={120}
/>
</Field>
</div>
<Field label="Notizen" htmlFor="item-notes">
<Textarea id="item-notes" name="notes" defaultValue={editing?.notes ?? ''} rows={3} />
</Field>
</form>
<DialogFooter>
<Button variant="outline" onClick={() => setEditing(null)} disabled={pending}>
Abbrechen
</Button>
<Button type="submit" form="collection-item-form" disabled={pending}>
{pending ? <Loader2 className="animate-spin" /> : null}
Speichern
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog open={deleting !== null} onOpenChange={(open) => !open && setDeleting(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Eintrag entfernen?</AlertDialogTitle>
<AlertDialogDescription>
Das Set bleibt im Katalog, gilt danach aber wieder als „fehlt noch“.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Abbrechen</AlertDialogCancel>
<AlertDialogAction onClick={remove}>Entfernen</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}