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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user