feat(wunschliste): mehrfachauswahl, teilen und oeffentliche ansicht

Der zentrale Weg auf eine Wunschliste ist die Ansicht "Fehlende Sets":
Kachel lange druecken startet den Auswahlmodus, die Aktionsleiste am
unteren Rand uebernimmt die Auswahl in eine bestehende oder neue Liste.
Dubletten und bereits besessene Sets werden uebersprungen und im Toast
benannt; der Unique-Index auf (wishlist_id, set_id) macht das zur
Datenbankgarantie. Dieselbe Auswahlansicht steckt eingebettet auf der
Wunschlisten-Seite, vorgefiltert auf Sets, die noch nicht drauf sind.

Die oeffentliche Ansicht unter /w/[shareToken] braucht keine Anmeldung,
gibt nichts ausser den Wunschlisten-Daten preis und ist noindex.
Besucher reservieren mit ihrem Namen und bekommen einen Storno-Link;
in der Datenbank liegt nur der SHA-256-Hash des Tokens. Die Reservierung
ist ein bedingtes UPDATE, damit bei zwei gleichzeitigen Klicks nur einer
gewinnt. Erfuellte Wuensche verschwinden aus der oeffentlichen Ansicht.

Dazu die Sammlungsuebersicht mit Statistik-Kacheln und Filtern nach
Status, Vollstaendigkeit, Standort und Hersteller.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
unknown
2026-09-06 23:06:19 +02:00
co-authored by Claude Opus 5
parent 856800b4e4
commit 79b0133083
14 changed files with 1843 additions and 0 deletions
+150
View File
@@ -0,0 +1,150 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { Boxes, Euro, ImageOff, Layers, PackageSearch } from 'lucide-react';
import { CollectionFilters } from '@/components/sammlung/collection-filters';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
import { formatDate, formatNumber, pluralize } from '@/lib/format';
import { setImageSrc } from '@/lib/images';
import { completenessLabel, statusLabel } from '@/lib/labels';
import { formatEuro } from '@/lib/money';
import {
countMissingSets,
getCollectionStats,
listCollectionItems,
type CollectionFilters as Filters,
} from '@/lib/queries/collection';
import { listLocations, listManufacturers } from '@/lib/queries/master-data';
import { requireUser } from '@/lib/session';
import type { CollectionStatus, Completeness } from '@/db/types';
export const metadata: Metadata = { title: 'Sammlung' };
type SearchParams = Promise<Record<string, string | string[] | undefined>>;
function single(value: string | string[] | undefined): string | undefined {
const raw = Array.isArray(value) ? value[0] : value;
return raw && raw.length > 0 ? raw : undefined;
}
export default async function SammlungPage({ searchParams }: { searchParams: SearchParams }) {
await requireUser();
const params = await searchParams;
const filters: Filters = {
q: single(params.q),
status: single(params.status) as CollectionStatus | undefined,
completeness: single(params.zustand) as Completeness | undefined,
locationId: single(params.standort),
manufacturerId: single(params.hersteller),
};
const items = listCollectionItems(filters);
const stats = getCollectionStats();
const missing = countMissingSets();
const tiles = [
{ icon: Boxes, label: 'Sets', value: formatNumber(stats.setCount) },
{ icon: Layers, label: 'Teile gesamt', value: formatNumber(stats.pieceCount) },
{ icon: Euro, label: 'Wert nach UVP', value: formatEuro(stats.retailValueCents) },
{ icon: PackageSearch, label: 'Fehlt noch', value: formatNumber(missing) },
] as const;
return (
<div className="space-y-5">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Sammlung</h1>
<p className="text-sm text-muted-foreground">Alles, was tatsächlich da ist.</p>
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{tiles.map((tile) => (
<Card key={tile.label}>
<CardContent className="space-y-1 p-4">
<tile.icon className="size-4 text-muted-foreground" aria-hidden />
<p className="text-xl font-semibold tabular-nums">{tile.value}</p>
<p className="text-xs text-muted-foreground">{tile.label}</p>
</CardContent>
</Card>
))}
</div>
{stats.byManufacturer.length > 0 ? (
<div className="flex flex-wrap gap-2">
{stats.byManufacturer.map((row) => (
<Badge key={row.name} variant="secondary">
{row.name}: {formatNumber(row.count)}
</Badge>
))}
</div>
) : null}
<CollectionFilters
locations={listLocations().map((row) => ({ id: row.id, name: row.name }))}
manufacturers={listManufacturers().map((row) => ({ id: row.id, name: row.name }))}
/>
<p className="text-sm text-muted-foreground">
{pluralize(items.length, 'Eintrag', 'Einträge')}
</p>
{items.length === 0 ? (
<div className="space-y-3 rounded-lg border border-dashed border-border p-8 text-center">
<p className="text-sm text-muted-foreground">
Hier ist noch nichts. Sets kommen über den Katalog in die Sammlung.
</p>
<Link href="/katalog" className="text-sm underline underline-offset-4">
Zum Katalog
</Link>
</div>
) : (
<ul className="divide-y divide-border rounded-lg border border-border">
{items.map((item) => {
const image = setImageSrc(item);
return (
<li key={item.id}>
<Link
href={`/katalog/${item.setId}`}
className="flex items-center gap-3 p-3 transition-colors hover:bg-muted/50"
>
<div className="size-14 shrink-0 overflow-hidden rounded-lg border border-border bg-muted">
{image ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={image} alt="" loading="lazy" className="size-full object-contain" />
) : (
<div className="flex size-full items-center justify-center text-muted-foreground">
<ImageOff className="size-5" aria-hidden />
</div>
)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{item.setName}</p>
<p className="truncate text-xs text-muted-foreground">
{[item.setNumber, item.manufacturerName, item.locationName]
.filter(Boolean)
.join(' · ')}
</p>
<div className="mt-1 flex flex-wrap items-center gap-1">
<Badge variant={item.status === 'sold' ? 'outline' : 'default'}>
{statusLabel(item.status)}
</Badge>
{item.completeness !== 'unknown' ? (
<Badge variant="secondary">{completenessLabel(item.completeness)}</Badge>
) : null}
{item.acquiredAt ? (
<span className="text-xs text-muted-foreground">
seit {formatDate(item.acquiredAt)}
</span>
) : null}
</div>
</div>
</Link>
</li>
);
})}
</ul>
)}
</div>
);
}
+135
View File
@@ -0,0 +1,135 @@
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { CatalogFilters } from '@/components/katalog/catalog-filters';
import { CatalogGrid } from '@/components/katalog/catalog-grid';
import { Pagination } from '@/components/katalog/pagination';
import { ShareCard } from '@/components/wunschlisten/share-card';
import { WishlistHeaderActions } from '@/components/wunschlisten/wishlist-header-actions';
import { WishlistItems } from '@/components/wunschlisten/wishlist-items';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { formatDate, pluralize } from '@/lib/format';
import { occasionLabel } from '@/lib/labels';
import { CATALOG_PAGE_SIZE, listSets, listSetYears } from '@/lib/queries/catalog';
import { listManufacturers, listThemes } from '@/lib/queries/master-data';
import { getWishlist, listWishlistItems } from '@/lib/queries/wishlists';
import { requireUser } from '@/lib/session';
type Props = {
params: Promise<{ id: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
};
function single(value: string | string[] | undefined): string | undefined {
const raw = Array.isArray(value) ? value[0] : value;
return raw && raw.length > 0 ? raw : undefined;
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { id } = await params;
const list = getWishlist(id);
return { title: list?.title ?? 'Wunschliste' };
}
export default async function WunschlisteDetailPage({ params, searchParams }: Props) {
const user = await requireUser();
const { id } = await params;
const query = await searchParams;
const list = getWishlist(id);
if (!list || list.ownerUserId !== user.id) notFound();
const items = listWishlistItems(list.id);
// The embedded picker: missing sets that are not on this list yet. It shares
// the URL parameters with the catalog, so the filters behave identically.
const yearParam = single(query.jahr);
const page = Number(single(query.seite) ?? '1') || 1;
const missing = listSets({
q: single(query.q),
manufacturerId: single(query.hersteller),
themeId: single(query.thema),
year: yearParam ? Number(yearParam) : undefined,
ownership: 'fehlt',
excludeWishlistId: list.id,
page,
});
return (
<div className="space-y-6">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary">{occasionLabel(list.occasion)}</Badge>
{list.eventDate ? <Badge variant="outline">{formatDate(list.eventDate)}</Badge> : null}
{list.surpriseMode ? <Badge variant="outline">Überraschungsmodus</Badge> : null}
</div>
<h1 className="mt-2 truncate text-2xl font-semibold tracking-tight">{list.title}</h1>
<p className="text-sm text-muted-foreground">
{pluralize(items.length, 'Wunsch', 'Wünsche')}
</p>
</div>
<WishlistHeaderActions
wishlist={{
id: list.id,
title: list.title,
occasion: list.occasion,
eventDate: list.eventDate,
note: list.note,
isPublic: list.isPublic,
surpriseMode: list.surpriseMode,
}}
/>
</div>
<ShareCard
wishlistId={list.id}
shareToken={list.shareToken}
isPublic={list.isPublic}
title={list.title}
/>
{list.note ? (
<p className="rounded-lg bg-muted p-3 text-sm whitespace-pre-line">{list.note}</p>
) : null}
<WishlistItems wishlistId={list.id} items={items} surpriseMode={list.surpriseMode} />
<Card>
<CardHeader>
<CardTitle>Aus fehlenden Sets hinzufügen</CardTitle>
<CardDescription>
Nur Sets, die noch nicht in der Sammlung und noch nicht auf dieser Liste sind. Kachel
lange drücken oder „Auswählen“ tippen, dann mehrere auf einmal übernehmen.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<CatalogFilters
options={{
manufacturers: listManufacturers().map((row) => ({ id: row.id, name: row.name })),
themes: listThemes().map((row) => ({
id: row.id,
name: row.name,
manufacturerId: row.manufacturerId,
})),
years: listSetYears(),
}}
/>
<p className="text-sm text-muted-foreground">
{pluralize(missing.total, 'Set kommt infrage', 'Sets kommen infrage')}
</p>
<CatalogGrid
sets={missing.rows}
wishlists={[{ id: list.id, title: list.title }]}
defaultWishlistId={list.id}
emptyState="Es fehlt gerade nichts, was noch nicht auf dieser Liste steht."
/>
<Pagination page={page} total={missing.total} pageSize={CATALOG_PAGE_SIZE} />
</CardContent>
</Card>
</div>
);
}
+86
View File
@@ -0,0 +1,86 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { CalendarDays, Gift, Link2Off } from 'lucide-react';
import { NewWishlistButton } from '@/components/wunschlisten/wishlist-form-dialog';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { formatDate, pluralize } from '@/lib/format';
import { occasionLabel } from '@/lib/labels';
import { listWishlists } from '@/lib/queries/wishlists';
import { requireUser } from '@/lib/session';
export const metadata: Metadata = { title: 'Wunschlisten' };
export default async function WunschlistenPage() {
const user = await requireUser();
const lists = listWishlists(user.id);
return (
<div className="space-y-5">
<div className="flex items-start justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Wunschlisten</h1>
<p className="text-sm text-muted-foreground">
Zum Teilen mit Familie und Freunden – ganz ohne Konto für die anderen.
</p>
</div>
<NewWishlistButton />
</div>
{lists.length === 0 ? (
<div className="space-y-2 rounded-lg border border-dashed border-border p-8 text-center">
<p className="text-sm text-muted-foreground">
Noch keine Wunschliste. Der schnellste Weg: im Katalog auf „Fehlt noch“ filtern,
mehrere Sets auswählen und daraus eine Liste anlegen.
</p>
<Link href="/katalog?besitz=fehlt" className="text-sm underline underline-offset-4">
Zu den fehlenden Sets
</Link>
</div>
) : (
<ul className="grid gap-3 sm:grid-cols-2">
{lists.map((list) => (
<li key={list.id}>
<Link href={`/wunschlisten/${list.id}`} className="group block h-full">
<Card className="h-full transition-colors group-hover:border-primary/40">
<CardHeader>
<div className="flex items-center gap-2">
<Gift className="size-5 text-primary" aria-hidden />
<CardTitle className="truncate">{list.title}</CardTitle>
</div>
<CardDescription>
{occasionLabel(list.occasion)}
{list.eventDate ? ` · ${formatDate(list.eventDate)}` : ''}
</CardDescription>
</CardHeader>
<CardContent className="flex flex-wrap items-center gap-2 text-xs">
<Badge variant="secondary">{pluralize(list.itemCount, 'Wunsch', 'Wünsche')}</Badge>
{list.openCount > 0 ? (
<Badge variant="outline">{list.openCount} noch offen</Badge>
) : list.itemCount > 0 ? (
<Badge className="bg-emerald-600 text-white hover:bg-emerald-600">
Alles vergeben
</Badge>
) : null}
{list.isPublic ? null : (
<Badge variant="outline" className="gap-1">
<Link2Off className="size-3" aria-hidden />
Link deaktiviert
</Badge>
)}
{list.eventDate ? (
<span className="ml-auto flex items-center gap-1 text-muted-foreground">
<CalendarDays className="size-3" aria-hidden />
{formatDate(list.eventDate)}
</span>
) : null}
</CardContent>
</Card>
</Link>
</li>
))}
</ul>
)}
</div>
);
}
+103
View File
@@ -0,0 +1,103 @@
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { Blocks, CalendarDays, Gift } from 'lucide-react';
import { PublicWishlist, type PublicItem } from '@/components/wunschlisten/public-wishlist';
import { Badge } from '@/components/ui/badge';
import { formatDate, pluralize } from '@/lib/format';
import { occasionLabel } from '@/lib/labels';
import { getWishlistByShareToken, listWishlistItems } from '@/lib/queries/wishlists';
type Props = { params: Promise<{ shareToken: string }> };
/**
* The public face of a wishlist.
*
* Reachable with the share token alone, and it exposes nothing beyond the
* wishlist itself - no owner name, no collection, no other lists. Search
* engines are kept out; this is meant to be shared, not found.
*/
export const metadata: Metadata = {
title: 'Wunschliste',
robots: { index: false, follow: false },
};
export default async function PublicWishlistPage({ params }: Props) {
const { shareToken } = await params;
const list = getWishlistByShareToken(shareToken);
if (!list || !list.isPublic) notFound();
const rows = listWishlistItems(list.id);
// Fulfilled wishes are gone from the public view: the present is already
// under the tree, no reason to let someone buy it twice.
const open = rows.filter((row) => row.fulfilledAt === null);
const items: PublicItem[] = open.map((row) => ({
id: row.id,
setNumber: row.setNumber,
setName: row.setName,
year: row.year,
pieceCount: row.pieceCount,
imageUrl: row.imageUrl,
imagePath: row.imagePath,
retailPriceCents: row.retailPriceCents,
manufacturerName: row.manufacturerName,
manufacturerWebsite: row.manufacturerWebsite,
themeName: row.themeName,
priority: row.priority,
note: row.note,
isReserved: row.reservedAt !== null,
reservedByName: row.reservedByName,
}));
const openCount = items.filter((item) => !item.isReserved).length;
return (
<div className="mx-auto min-h-dvh w-full max-w-2xl px-4 py-8">
<header className="space-y-3 border-b border-border pb-6">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Blocks className="size-4 text-primary" aria-hidden />
Bricktrack
</div>
<h1 className="text-2xl font-semibold tracking-tight">{list.title}</h1>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary" className="gap-1">
<Gift className="size-3" aria-hidden />
{occasionLabel(list.occasion)}
</Badge>
{list.eventDate ? (
<Badge variant="outline" className="gap-1">
<CalendarDays className="size-3" aria-hidden />
{formatDate(list.eventDate)}
</Badge>
) : null}
<span className="text-sm text-muted-foreground">
{pluralize(openCount, 'Wunsch noch offen', 'Wünsche noch offen')}
</span>
</div>
{list.note ? (
<p className="rounded-lg bg-muted p-3 text-sm whitespace-pre-line">{list.note}</p>
) : null}
<p className="text-xs text-muted-foreground">
Klick auf „Das schenke ich“, damit niemand dasselbe doppelt kauft. Eine Anmeldung
brauchst du dafür nicht.
</p>
</header>
<main className="py-6">
{items.length === 0 ? (
<p className="rounded-lg border border-dashed border-border p-8 text-center text-sm text-muted-foreground">
Auf dieser Liste steht gerade nichts.
</p>
) : (
<PublicWishlist shareToken={shareToken} items={items} />
)}
</main>
</div>
);
}
@@ -0,0 +1,38 @@
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { CancelReservation } from '@/components/wunschlisten/cancel-reservation';
import { getWishlistByShareToken, listWishlistItems } from '@/lib/queries/wishlists';
export const metadata: Metadata = {
title: 'Reservierung aufheben',
robots: { index: false, follow: false },
};
/** The link a visitor receives after reserving a gift. */
export default async function CancelReservationPage({
params,
}: {
params: Promise<{ shareToken: string; itemId: string; token: string }>;
}) {
const { shareToken, itemId, token } = await params;
const list = getWishlistByShareToken(shareToken);
if (!list || !list.isPublic) notFound();
const item = listWishlistItems(list.id).find((row) => row.id === itemId);
if (!item) notFound();
return (
<div className="mx-auto flex min-h-dvh w-full max-w-md flex-col justify-center px-4 py-10">
<CancelReservation
shareToken={shareToken}
itemId={itemId}
token={token}
setName={item.setName}
setNumber={item.setNumber}
isReserved={item.reservedAt !== null}
listTitle={list.title}
/>
</div>
);
}
+93
View File
@@ -0,0 +1,93 @@
'use server';
import { revalidatePath } from 'next/cache';
import { and, eq, isNull } from 'drizzle-orm';
import { db } from '@/db';
import { wishlist, wishlistItem } from '@/db/schema';
import { actionData, actionError, actionOk, parseForm, type ActionResult } from '@/lib/action-result';
import { createToken, hashToken, tokensMatch } from '@/lib/tokens';
import { reservationSchema } from '@/lib/validation';
/**
* Actions for the public wishlist page.
*
* These run without a session. The share token is the only key, and every
* query is scoped by it - nothing outside the one wishlist is reachable, and
* nothing but wishlist data is returned.
*/
function loadPublicList(shareToken: string) {
const list = db
.select({ id: wishlist.id, isPublic: wishlist.isPublic })
.from(wishlist)
.where(eq(wishlist.shareToken, shareToken))
.get();
return list && list.isPublic ? list : null;
}
export async function reserveItem(
shareToken: string,
input: unknown,
): Promise<ActionResult<{ cancelToken: string }>> {
const list = loadPublicList(shareToken);
if (!list) return actionError('Diese Wunschliste ist nicht mehr erreichbar.');
const parsed = parseForm(reservationSchema, input);
if (!parsed.ok) return parsed;
const cancelToken = createToken();
// The `reserved_at is null` condition makes this a claim, not a read-then-write:
// if two people tap at the same moment, only the first one wins.
const result = db
.update(wishlistItem)
.set({
reservedByName: parsed.data.name,
reservedAt: new Date(),
reservationTokenHash: hashToken(cancelToken),
})
.where(
and(
eq(wishlistItem.id, parsed.data.itemId),
eq(wishlistItem.wishlistId, list.id),
isNull(wishlistItem.reservedAt),
),
)
.run();
if (result.changes === 0) {
return actionError('Dieser Wunsch ist gerade eben schon von jemand anderem übernommen worden.');
}
revalidatePath(`/w/${shareToken}`);
return actionData({ cancelToken });
}
export async function cancelReservation(
shareToken: string,
itemId: string,
cancelToken: string,
): Promise<ActionResult> {
const list = loadPublicList(shareToken);
if (!list) return actionError('Diese Wunschliste ist nicht mehr erreichbar.');
const item = db
.select({ id: wishlistItem.id, hash: wishlistItem.reservationTokenHash })
.from(wishlistItem)
.where(and(eq(wishlistItem.id, itemId), eq(wishlistItem.wishlistId, list.id)))
.get();
if (!item) return actionError('Diesen Eintrag gibt es nicht mehr.');
if (!tokensMatch(cancelToken, item.hash)) {
return actionError('Dieser Link passt nicht zu der Reservierung.');
}
db.update(wishlistItem)
.set({ reservedByName: null, reservedAt: null, reservationTokenHash: null })
.where(eq(wishlistItem.id, itemId))
.run();
revalidatePath(`/w/${shareToken}`);
return actionOk();
}
@@ -0,0 +1,132 @@
'use client';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { useEffect, useState, useTransition } from 'react';
import { Loader2, Search, X } from 'lucide-react';
import { COMPLETENESS_LABELS, STATUS_LABELS } from '@/lib/labels';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { NativeSelect } from '@/components/ui/native-select';
export function CollectionFilters({
locations,
manufacturers,
}: {
locations: Array<{ id: string; name: string }>;
manufacturers: Array<{ id: string; name: string }>;
}) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [pending, startTransition] = useTransition();
const [query, setQuery] = useState(searchParams.get('q') ?? '');
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);
}
startTransition(() => router.push(`${pathname}?${params.toString()}`));
}
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 hasFilters = [...searchParams.keys()].length > 0;
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 oder Name"
aria-label="Sammlung 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="Status"
value={searchParams.get('status') ?? ''}
onChange={(event) => apply({ status: event.target.value || null })}
>
<option value="">Alle Status</option>
{Object.entries(STATUS_LABELS).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</NativeSelect>
<NativeSelect
aria-label="Vollständigkeit"
value={searchParams.get('zustand') ?? ''}
onChange={(event) => apply({ zustand: event.target.value || null })}
>
<option value="">Alle Vollständigkeiten</option>
{Object.entries(COMPLETENESS_LABELS).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</NativeSelect>
<NativeSelect
aria-label="Standort"
value={searchParams.get('standort') ?? ''}
onChange={(event) => apply({ standort: event.target.value || null })}
>
<option value="">Alle Standorte</option>
{locations.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</NativeSelect>
<NativeSelect
aria-label="Hersteller"
value={searchParams.get('hersteller') ?? ''}
onChange={(event) => apply({ hersteller: event.target.value || null })}
>
<option value="">Alle Hersteller</option>
{manufacturers.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</NativeSelect>
</div>
{hasFilters ? (
<Button
variant="ghost"
size="sm"
onClick={() => {
setQuery('');
startTransition(() => router.push(pathname));
}}
>
<X />
Filter zurücksetzen
</Button>
) : null}
</div>
);
}
@@ -0,0 +1,77 @@
'use client';
import Link from 'next/link';
import { useState, useTransition } from 'react';
import { Check, Loader2, Undo2 } from 'lucide-react';
import { toast } from 'sonner';
import { cancelReservation } from '@/app/w/actions';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
export function CancelReservation({
shareToken,
itemId,
token,
setName,
setNumber,
isReserved,
listTitle,
}: {
shareToken: string;
itemId: string;
token: string;
setName: string;
setNumber: string;
isReserved: boolean;
listTitle: string;
}) {
const [done, setDone] = useState(!isReserved);
const [pending, startTransition] = useTransition();
function cancel() {
startTransition(async () => {
const result = await cancelReservation(shareToken, itemId, token);
if (!result.ok) {
toast.error(result.error);
return;
}
setDone(true);
toast.success('Reservierung aufgehoben.');
});
}
return (
<Card>
<CardHeader>
<CardTitle>{done ? 'Nicht mehr reserviert' : 'Reservierung aufheben?'}</CardTitle>
<CardDescription>
{setNumber} · {setName} – aus der Liste „{listTitle}“.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{done ? (
<p className="flex items-center gap-2 text-sm text-emerald-600">
<Check className="size-4" aria-hidden />
Dieser Wunsch ist wieder für alle offen.
</p>
) : (
<p className="text-sm text-muted-foreground">
Danach kann jemand anderes das Geschenk übernehmen.
</p>
)}
<div className="flex gap-2">
{done ? null : (
<Button onClick={cancel} disabled={pending}>
{pending ? <Loader2 className="animate-spin" /> : <Undo2 />}
Reservierung aufheben
</Button>
)}
<Button asChild variant="outline">
<Link href={`/w/${shareToken}`}>Zur Wunschliste</Link>
</Button>
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,282 @@
'use client';
import { useEffect, useState, useTransition } from 'react';
import { Check, ExternalLink, Gift, ImageOff, Loader2, Undo2 } from 'lucide-react';
import { toast } from 'sonner';
import { cancelReservation, reserveItem } from '@/app/w/actions';
import { setImageSrc } from '@/lib/images';
import { formatEuro } from '@/lib/money';
import { priorityLabel } from '@/lib/wishlist-logic';
import { Badge } from '@/components/ui/badge';
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';
export type PublicItem = {
id: string;
setNumber: string;
setName: string;
year: number | null;
pieceCount: number | null;
imageUrl: string | null;
imagePath: string | null;
retailPriceCents: number | null;
manufacturerName: string;
manufacturerWebsite: string | null;
themeName: string | null;
priority: number;
note: string | null;
isReserved: boolean;
reservedByName: string | null;
};
/** Cancel tokens live in the visitor's browser - they have no account. */
const STORAGE_KEY = 'bricktrack.reservations';
function readTokens(): Record<string, string> {
if (typeof window === 'undefined') return {};
try {
return JSON.parse(window.localStorage.getItem(STORAGE_KEY) ?? '{}') as Record<string, string>;
} catch {
return {};
}
}
function writeTokens(tokens: Record<string, string>) {
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(tokens));
} catch {
/* private mode - the link shown after reserving is the fallback */
}
}
export function PublicWishlist({
shareToken,
items,
}: {
shareToken: string;
items: PublicItem[];
}) {
const [tokens, setTokens] = useState<Record<string, string>>({});
const [reserving, setReserving] = useState<PublicItem | null>(null);
const [name, setName] = useState('');
const [cancelLink, setCancelLink] = useState<string | null>(null);
const [pending, startTransition] = useTransition();
useEffect(() => {
setTokens(readTokens());
// Remember the name so the second gift does not need it typed again.
setName(window.localStorage.getItem('bricktrack.name') ?? '');
}, []);
function reserve() {
const item = reserving;
if (!item) return;
startTransition(async () => {
const result = await reserveItem(shareToken, { itemId: item.id, name });
if (!result.ok) {
toast.error(result.error);
return;
}
const next = { ...tokens, [item.id]: result.data.cancelToken };
setTokens(next);
writeTokens(next);
try {
window.localStorage.setItem('bricktrack.name', name.trim());
} catch {
/* ignore */
}
setCancelLink(
`${window.location.origin}/w/${shareToken}/storno/${item.id}/${result.data.cancelToken}`,
);
setReserving(null);
toast.success('Danke! Der Wunsch ist für dich reserviert.');
});
}
function undo(item: PublicItem) {
const token = tokens[item.id];
if (!token) return;
startTransition(async () => {
const result = await cancelReservation(shareToken, item.id, token);
if (!result.ok) {
toast.error(result.error);
return;
}
const next = { ...tokens };
delete next[item.id];
setTokens(next);
writeTokens(next);
toast.success('Reservierung aufgehoben.');
});
}
return (
<div className="space-y-3">
{items.map((item) => {
const image = setImageSrc(item);
const mine = Boolean(tokens[item.id]);
return (
<article
key={item.id}
className="flex gap-3 rounded-xl border border-border bg-card p-3"
>
<div className="size-20 shrink-0 overflow-hidden rounded-lg border border-border bg-muted sm:size-28">
{image ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={image} alt="" loading="lazy" className="size-full object-contain" />
) : (
<div className="flex size-full items-center justify-center text-muted-foreground">
<ImageOff className="size-6" aria-hidden />
</div>
)}
</div>
<div className="min-w-0 flex-1 space-y-2">
<div>
<div className="flex flex-wrap items-center gap-1.5">
<Badge variant="outline" className="font-mono text-xs">
{item.setNumber}
</Badge>
<Badge variant="secondary">{item.manufacturerName}</Badge>
{item.priority === 1 ? <Badge>{priorityLabel(1)}</Badge> : null}
</div>
<h2 className="mt-1 font-medium">{item.setName}</h2>
<p className="text-xs text-muted-foreground">
{[
item.themeName,
item.year,
item.pieceCount ? `${item.pieceCount} Teile` : null,
item.retailPriceCents !== null ? formatEuro(item.retailPriceCents) : null,
]
.filter(Boolean)
.join(' · ')}
</p>
{item.note ? <p className="mt-1 text-sm">{item.note}</p> : null}
</div>
<div className="flex flex-wrap items-center gap-2">
{mine ? (
<>
<Badge className="gap-1 bg-emerald-600 text-white hover:bg-emerald-600">
<Check className="size-3" aria-hidden />
Das schenkst du
</Badge>
<Button variant="ghost" size="sm" onClick={() => undo(item)} disabled={pending}>
<Undo2 />
Doch nicht
</Button>
</>
) : item.isReserved ? (
<Badge variant="secondary">
{item.reservedByName
? `Übernimmt ${item.reservedByName}`
: 'Schon reserviert'}
</Badge>
) : (
<Button
size="sm"
onClick={() => {
setReserving(item);
setCancelLink(null);
}}
disabled={pending}
>
<Gift />
Das schenke ich
</Button>
)}
{item.manufacturerWebsite ? (
<Button asChild variant="link" size="sm" className="px-0">
<a href={item.manufacturerWebsite} target="_blank" rel="noreferrer noopener">
Zum Shop
<ExternalLink />
</a>
</Button>
) : null}
</div>
</div>
</article>
);
})}
<Dialog open={reserving !== null} onOpenChange={(open) => !open && setReserving(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Das schenke ich</DialogTitle>
<DialogDescription>
Damit sieht jeder andere, dass dieser Wunsch schon vergeben ist. Dein Name ist nur
für die Familie sichtbar.
</DialogDescription>
</DialogHeader>
<Field label="Dein Name" htmlFor="reserver-name" required>
<Input
id="reserver-name"
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="Tante Anne"
maxLength={60}
autoFocus
/>
</Field>
<DialogFooter>
<Button variant="outline" onClick={() => setReserving(null)} disabled={pending}>
Abbrechen
</Button>
<Button onClick={reserve} disabled={pending || name.trim().length < 2}>
{pending ? <Loader2 className="animate-spin" /> : <Gift />}
Reservieren
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={cancelLink !== null} onOpenChange={(open) => !open && setCancelLink(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Reservierung gespeichert</DialogTitle>
<DialogDescription>
Merke dir diesen Link, falls du die Reservierung später von einem anderen Gerät
aufheben willst. Auf diesem Gerät geht es auch ohne.
</DialogDescription>
</DialogHeader>
<Input
readOnly
value={cancelLink ?? ''}
onFocus={(event) => event.currentTarget.select()}
className="font-mono text-xs"
/>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
if (cancelLink) void navigator.clipboard.writeText(cancelLink);
toast.success('Link kopiert.');
}}
>
Link kopieren
</Button>
<Button onClick={() => setCancelLink(null)}>Alles klar</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+122
View File
@@ -0,0 +1,122 @@
'use client';
import { useState, useTransition } from 'react';
import { Check, Copy, Link2Off, RefreshCw, Share2 } from 'lucide-react';
import { toast } from 'sonner';
import { rotateShareToken } from '@/app/(app)/wunschlisten/actions';
import { Button } from '@/components/ui/button';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Input } from '@/components/ui/input';
export function ShareCard({
wishlistId,
shareToken,
isPublic,
title,
}: {
wishlistId: string;
shareToken: string;
isPublic: boolean;
title: string;
}) {
const [token, setToken] = useState(shareToken);
const [copied, setCopied] = useState(false);
const [rotateOpen, setRotateOpen] = useState(false);
const [pending, startTransition] = useTransition();
// Built in the browser so the link always matches the host actually used.
const url = typeof window === 'undefined' ? '' : `${window.location.origin}/w/${token}`;
async function copy() {
try {
await navigator.clipboard.writeText(url);
setCopied(true);
toast.success('Link kopiert.');
setTimeout(() => setCopied(false), 2000);
} catch {
toast.error('Kopieren hat nicht geklappt. Markiere den Link und kopiere ihn von Hand.');
}
}
async function share() {
if (!navigator.share) {
void copy();
return;
}
try {
await navigator.share({ title, url });
} catch {
/* the user cancelled the share sheet */
}
}
function rotate() {
startTransition(async () => {
const result = await rotateShareToken(wishlistId);
if (!result.ok) {
toast.error(result.error);
return;
}
setToken(result.data.token);
setRotateOpen(false);
toast.success('Neuer Link erzeugt. Der alte funktioniert nicht mehr.');
});
}
if (!isPublic) {
return (
<div className="flex items-center gap-2 rounded-lg border border-dashed border-border p-3 text-sm text-muted-foreground">
<Link2Off className="size-4 shrink-0" aria-hidden />
Das Teilen ist für diese Liste ausgeschaltet. Aktiviere es beim Bearbeiten der Liste.
</div>
);
}
return (
<div className="space-y-2 rounded-lg border border-border p-3">
<p className="text-sm font-medium">Link zum Teilen</p>
<div className="flex gap-2">
<Input readOnly value={url} onFocus={(event) => event.currentTarget.select()} className="font-mono text-xs" />
<Button variant="outline" size="icon" onClick={copy} aria-label="Link kopieren">
{copied ? <Check className="text-emerald-600" /> : <Copy />}
</Button>
<Button variant="outline" size="icon" onClick={share} aria-label="Link teilen">
<Share2 />
</Button>
</div>
<p className="text-xs text-muted-foreground">
Wer den Link hat, sieht die Liste – ohne Anmeldung. Sonst wird nichts preisgegeben.
</p>
<Button variant="ghost" size="sm" onClick={() => setRotateOpen(true)} disabled={pending}>
<RefreshCw />
Neuen Link erzeugen
</Button>
<AlertDialog open={rotateOpen} onOpenChange={setRotateOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Neuen Link erzeugen?</AlertDialogTitle>
<AlertDialogDescription>
Der bisherige Link funktioniert danach nicht mehr. Bereits abgegebene
Reservierungen bleiben erhalten, aber die Storno-Links der Schenkenden werden
ungültig.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Abbrechen</AlertDialogCancel>
<AlertDialogAction onClick={rotate}>Neuen Link erzeugen</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -0,0 +1,195 @@
'use client';
import { useRouter } from 'next/navigation';
import { useState, useTransition } from 'react';
import { Loader2, Plus } from 'lucide-react';
import { toast } from 'sonner';
import { createWishlist, updateWishlist } from '@/app/(app)/wunschlisten/actions';
import type { Occasion } from '@/db/types';
import { toDateInputValue } from '@/lib/format';
import { OCCASION_LABELS } from '@/lib/labels';
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 { Label } from '@/components/ui/label';
import { NativeSelect } from '@/components/ui/native-select';
import { Textarea } from '@/components/ui/textarea';
export type WishlistFormValues = {
id: string;
title: string;
occasion: Occasion;
eventDate: Date | null;
note: string | null;
isPublic: boolean;
surpriseMode: boolean;
};
export function WishlistFormDialog({
wishlist,
open,
onOpenChange,
}: {
wishlist?: WishlistFormValues;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const setOpen = onOpenChange;
function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const input = Object.fromEntries(new FormData(event.currentTarget).entries());
startTransition(async () => {
if (wishlist) {
const result = await updateWishlist(wishlist.id, input);
if (!result.ok) {
toast.error(result.error);
return;
}
toast.success('Wunschliste gespeichert.');
setOpen(false);
router.refresh();
return;
}
const result = await createWishlist(input);
if (!result.ok) {
toast.error(result.error);
return;
}
toast.success('Wunschliste angelegt.');
setOpen(false);
router.push(`/wunschlisten/${result.data.id}`);
router.refresh();
});
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-h-[85dvh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{wishlist ? 'Wunschliste bearbeiten' : 'Neue Wunschliste'}</DialogTitle>
<DialogDescription>
Der Anlass und das Datum erscheinen auch auf der geteilten Ansicht.
</DialogDescription>
</DialogHeader>
<form id="wishlist-form" onSubmit={submit} className="space-y-4">
<Field label="Titel" htmlFor="title" required>
<Input
id="title"
name="title"
defaultValue={wishlist?.title ?? ''}
required
maxLength={80}
placeholder="Weihnachten 2026"
/>
</Field>
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Anlass" htmlFor="occasion">
<NativeSelect
id="occasion"
name="occasion"
defaultValue={wishlist?.occasion ?? 'other'}
>
{Object.entries(OCCASION_LABELS).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</NativeSelect>
</Field>
<Field label="Termin" htmlFor="eventDate">
<Input
id="eventDate"
name="eventDate"
type="date"
defaultValue={toDateInputValue(wishlist?.eventDate)}
/>
</Field>
</div>
<Field
label="Hinweis an die Schenkenden"
htmlFor="note"
hint="Steht oben auf der geteilten Liste."
>
<Textarea
id="note"
name="note"
defaultValue={wishlist?.note ?? ''}
rows={3}
placeholder="Bitte keine Doppelungen."
/>
</Field>
<div className="space-y-3 rounded-lg border border-border p-3">
<label className="flex items-start gap-3">
<Checkbox name="isPublic" value="on" defaultChecked={wishlist?.isPublic ?? true} />
<span>
<Label className="font-medium">Über Link teilbar</Label>
<span className="block text-xs text-muted-foreground">
Nur wer den Link kennt, sieht die Liste. Ohne diesen Haken ist der Link tot.
</span>
</span>
</label>
<label className="flex items-start gap-3">
<Checkbox
name="surpriseMode"
value="on"
defaultChecked={wishlist?.surpriseMode ?? false}
/>
<span>
<Label className="font-medium">Überraschungsmodus</Label>
<span className="block text-xs text-muted-foreground">
Du siehst dann nur, dass etwas reserviert ist – nicht von wem.
</span>
</span>
</label>
</div>
</form>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)} disabled={pending}>
Abbrechen
</Button>
<Button type="submit" form="wishlist-form" disabled={pending}>
{pending ? <Loader2 className="animate-spin" /> : <Plus />}
{wishlist ? 'Speichern' : 'Anlegen'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
/** Button plus dialog, for the wishlist overview page. */
export function NewWishlistButton() {
const [open, setOpen] = useState(false);
return (
<>
<Button size="lg" onClick={() => setOpen(true)}>
<Plus />
Neue Wunschliste
</Button>
<WishlistFormDialog open={open} onOpenChange={setOpen} />
</>
);
}
@@ -0,0 +1,74 @@
'use client';
import { useRouter } from 'next/navigation';
import { useState, useTransition } from 'react';
import { Pencil, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { deleteWishlist } from '@/app/(app)/wunschlisten/actions';
import { WishlistFormDialog, type WishlistFormValues } from './wishlist-form-dialog';
import { Button } from '@/components/ui/button';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
export function WishlistHeaderActions({ wishlist }: { wishlist: WishlistFormValues }) {
const router = useRouter();
const [editOpen, setEditOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [pending, startTransition] = useTransition();
function remove() {
startTransition(async () => {
const result = await deleteWishlist(wishlist.id);
if (!result.ok) {
toast.error(result.error);
return;
}
toast.success('Wunschliste gelöscht.');
router.push('/wunschlisten');
router.refresh();
});
}
return (
<div className="flex shrink-0 gap-2">
<Button variant="outline" size="icon-lg" aria-label="Bearbeiten" onClick={() => setEditOpen(true)}>
<Pencil />
</Button>
<Button
variant="ghost"
size="icon-lg"
aria-label="Wunschliste löschen"
onClick={() => setDeleteOpen(true)}
disabled={pending}
>
<Trash2 className="text-destructive" />
</Button>
<WishlistFormDialog wishlist={wishlist} open={editOpen} onOpenChange={setEditOpen} />
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Wunschliste löschen?</AlertDialogTitle>
<AlertDialogDescription>
„{wishlist.title}“ und alle Einträge darauf werden gelöscht. Der geteilte Link
funktioniert danach nicht mehr. Die Sets selbst bleiben im Katalog.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Abbrechen</AlertDialogCancel>
<AlertDialogAction onClick={remove}>Löschen</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -0,0 +1,196 @@
'use client';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useState, useTransition } from 'react';
import { ArrowDown, ArrowUp, Check, ImageOff, Loader2, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import {
removeWishlistItem,
reorderWishlistItems,
updateWishlistItem,
} from '@/app/(app)/wunschlisten/actions';
import { formatDate } from '@/lib/format';
import { setImageSrc } from '@/lib/images';
import { formatEuro } from '@/lib/money';
import type { WishlistItemRow } from '@/lib/queries/wishlists';
import { PRIORITY_LABELS, priorityLabel } from '@/lib/wishlist-logic';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { NativeSelect } from '@/components/ui/native-select';
export function WishlistItems({
wishlistId,
items,
surpriseMode,
}: {
wishlistId: string;
items: WishlistItemRow[];
surpriseMode: boolean;
}) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [order, setOrder] = useState(items);
// Keep the local order in sync when the server sends a new list.
if (items.map((item) => item.id).join() !== order.map((item) => item.id).join()) {
setOrder(items);
}
function move(index: number, direction: -1 | 1) {
const target = index + direction;
if (target < 0 || target >= order.length) return;
const next = [...order];
const [moved] = next.splice(index, 1);
if (!moved) return;
next.splice(target, 0, moved);
setOrder(next);
startTransition(async () => {
const result = await reorderWishlistItems(
wishlistId,
next.map((item) => item.id),
);
if (!result.ok) {
toast.error(result.error);
setOrder(items);
return;
}
router.refresh();
});
}
function setPriority(itemId: string, priority: string, note: string | null) {
startTransition(async () => {
const result = await updateWishlistItem(itemId, { priority, note: note ?? '' });
if (!result.ok) {
toast.error(result.error);
return;
}
router.refresh();
});
}
function remove(itemId: string) {
startTransition(async () => {
const result = await removeWishlistItem(itemId);
if (!result.ok) {
toast.error(result.error);
return;
}
toast.success('Vom Wunschzettel entfernt.');
router.refresh();
});
}
if (order.length === 0) {
return (
<p className="rounded-lg border border-dashed border-border p-6 text-center text-sm text-muted-foreground">
Noch nichts drauf. Nimm unten Sets aus der Liste der fehlenden Sets dazu.
</p>
);
}
return (
<ul className="divide-y divide-border rounded-lg border border-border">
{order.map((item, index) => {
const image = setImageSrc(item);
return (
<li key={item.id} className="flex items-start gap-3 p-3">
<div className="flex flex-col gap-1 pt-1">
<Button
variant="ghost"
size="icon-xs"
aria-label="Nach oben"
onClick={() => move(index, -1)}
disabled={pending || index === 0}
>
<ArrowUp />
</Button>
<Button
variant="ghost"
size="icon-xs"
aria-label="Nach unten"
onClick={() => move(index, 1)}
disabled={pending || index === order.length - 1}
>
<ArrowDown />
</Button>
</div>
<Link
href={`/katalog/${item.setId}`}
className="size-16 shrink-0 overflow-hidden rounded-lg border border-border bg-muted"
>
{image ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={image} alt="" loading="lazy" className="size-full object-contain" />
) : (
<div className="flex size-full items-center justify-center text-muted-foreground">
<ImageOff className="size-5" aria-hidden />
</div>
)}
</Link>
<div className="min-w-0 flex-1 space-y-2">
<div>
<Link href={`/katalog/${item.setId}`} className="text-sm font-medium hover:underline">
{item.setName}
</Link>
<p className="text-xs text-muted-foreground">
{[item.setNumber, item.manufacturerName, formatEuro(item.retailPriceCents)]
.filter(Boolean)
.join(' · ')}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<NativeSelect
aria-label={`Priorität für ${item.setName}`}
className="h-9 w-auto text-sm"
value={String(item.priority)}
onChange={(event) => setPriority(item.id, event.target.value, item.note)}
disabled={pending}
>
{Object.entries(PRIORITY_LABELS).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</NativeSelect>
{item.fulfilledAt ? (
<Badge className="gap-1 bg-emerald-600 text-white hover:bg-emerald-600">
<Check className="size-3" aria-hidden />
Erfüllt – schon in der Sammlung
</Badge>
) : item.reservedAt ? (
<Badge variant="secondary">
{surpriseMode
? 'Reserviert'
: `Reserviert von ${item.reservedByName ?? 'jemandem'}`}
{` · ${formatDate(item.reservedAt)}`}
</Badge>
) : (
<Badge variant="outline">{priorityLabel(item.priority)} · noch offen</Badge>
)}
</div>
</div>
<Button
variant="ghost"
size="icon-sm"
aria-label="Entfernen"
onClick={() => remove(item.id)}
disabled={pending}
>
{pending ? <Loader2 className="animate-spin" /> : <Trash2 className="text-destructive" />}
</Button>
</li>
);
})}
</ul>
);
}
+160
View File
@@ -0,0 +1,160 @@
import 'server-only';
import { and, asc, countDistinct, eq, sql, type SQL } from 'drizzle-orm';
import { db } from '@/db';
import { collectionItem, location, manufacturer, set, theme } from '@/db/schema';
import { SOLD_STATUS } from '@/lib/ownership';
import type { CollectionStatus, Completeness, Condition } from '@/db/types';
export type CollectionFilters = {
q?: string;
status?: CollectionStatus;
completeness?: Completeness;
locationId?: string;
manufacturerId?: string;
};
export type CollectionRow = {
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;
setNumber: string;
setName: string;
year: number | null;
pieceCount: number | null;
imageUrl: string | null;
imagePath: string | null;
retailPriceCents: number | null;
manufacturerName: string;
themeName: string | null;
};
function buildWhere(filters: CollectionFilters): SQL | undefined {
const conditions: SQL[] = [];
const q = filters.q?.trim().toLowerCase();
if (q) {
conditions.push(
sql`(lower(${set.setNumber}) like ${`%${q}%`} or lower(${set.name}) like ${`%${q}%`})`,
);
}
if (filters.status) conditions.push(eq(collectionItem.status, filters.status));
if (filters.completeness) conditions.push(eq(collectionItem.completeness, filters.completeness));
if (filters.locationId) conditions.push(eq(collectionItem.locationId, filters.locationId));
if (filters.manufacturerId) conditions.push(eq(set.manufacturerId, filters.manufacturerId));
return conditions.length > 0 ? and(...conditions) : undefined;
}
export function listCollectionItems(filters: CollectionFilters): CollectionRow[] {
return db
.select({
id: collectionItem.id,
setId: collectionItem.setId,
status: collectionItem.status,
completeness: collectionItem.completeness,
condition: collectionItem.condition,
locationId: collectionItem.locationId,
locationName: location.name,
acquiredAt: collectionItem.acquiredAt,
acquiredPriceCents: collectionItem.acquiredPriceCents,
acquiredFrom: collectionItem.acquiredFrom,
giftedBy: collectionItem.giftedBy,
lentOutTo: collectionItem.lentOutTo,
notes: collectionItem.notes,
setNumber: set.setNumber,
setName: set.name,
year: set.year,
pieceCount: set.pieceCount,
imageUrl: set.imageUrl,
imagePath: set.imagePath,
retailPriceCents: set.retailPriceCents,
manufacturerName: manufacturer.name,
themeName: theme.name,
})
.from(collectionItem)
.innerJoin(set, eq(set.id, collectionItem.setId))
.innerJoin(manufacturer, eq(manufacturer.id, set.manufacturerId))
.leftJoin(theme, eq(theme.id, set.themeId))
.leftJoin(location, eq(location.id, collectionItem.locationId))
.where(buildWhere(filters))
.orderBy(asc(manufacturer.name), asc(set.setNumber))
.all();
}
export type CollectionStats = {
setCount: number;
itemCount: number;
pieceCount: number;
retailValueCents: number;
paidCents: number;
byManufacturer: Array<{ name: string; count: number }>;
};
/**
* The numbers on top of the collection page. Sold copies are history and do
* not count anywhere - same rule as everywhere else in the app.
*/
export function getCollectionStats(): CollectionStats {
const active = sql`${collectionItem.status} <> ${SOLD_STATUS}`;
const [totals] = db
.select({
setCount: countDistinct(collectionItem.setId),
itemCount: sql<number>`count(*)`.mapWith(Number),
pieceCount: sql<number>`coalesce(sum(${set.pieceCount}), 0)`.mapWith(Number),
retailValueCents: sql<number>`coalesce(sum(${set.retailPriceCents}), 0)`.mapWith(Number),
paidCents: sql<number>`coalesce(sum(${collectionItem.acquiredPriceCents}), 0)`.mapWith(Number),
})
.from(collectionItem)
.innerJoin(set, eq(set.id, collectionItem.setId))
.where(active)
.all();
const byManufacturer = db
.select({
name: manufacturer.name,
count: sql<number>`count(*)`.mapWith(Number),
})
.from(collectionItem)
.innerJoin(set, eq(set.id, collectionItem.setId))
.innerJoin(manufacturer, eq(manufacturer.id, set.manufacturerId))
.where(active)
.groupBy(manufacturer.id)
.orderBy(asc(manufacturer.name))
.all();
return {
setCount: totals?.setCount ?? 0,
itemCount: totals?.itemCount ?? 0,
pieceCount: totals?.pieceCount ?? 0,
retailValueCents: totals?.retailValueCents ?? 0,
paidCents: totals?.paidCents ?? 0,
byManufacturer,
};
}
/** Number of catalog sets that are not in the collection - the "fehlt" count. */
export function countMissingSets(): number {
const [row] = db
.select({ total: sql<number>`count(*)`.mapWith(Number) })
.from(set)
.where(
sql`not exists (
select 1 from collection_item ci
where ci.set_id = ${set.id} and ci.status <> ${SOLD_STATUS}
)`,
)
.all();
return row?.total ?? 0;
}