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
@@ -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>
);
}