Files
bricktrack/src/components/katalog/set-card.tsx
T
unknownandClaude Opus 5 856800b4e4 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>
2026-09-06 22:59:26 +02:00

89 lines
3.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}