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:
@@ -0,0 +1,58 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { SetForm } from '@/components/katalog/set-form';
|
||||
import { getSetById } from '@/lib/queries/catalog';
|
||||
import { listManufacturers, listThemes } from '@/lib/queries/master-data';
|
||||
import { requireUser } from '@/lib/session';
|
||||
|
||||
export const metadata: Metadata = { title: 'Set bearbeiten' };
|
||||
|
||||
export default async function SetBearbeitenPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
await requireUser();
|
||||
const { id } = await params;
|
||||
|
||||
const set = getSetById(id);
|
||||
if (!set) notFound();
|
||||
|
||||
const manufacturers = listManufacturers().map((row) => ({ id: row.id, name: row.name }));
|
||||
const themes = listThemes().map((row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
manufacturerId: row.manufacturerId,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Set bearbeiten</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{set.setNumber} · {set.name}
|
||||
</p>
|
||||
</div>
|
||||
<SetForm
|
||||
manufacturers={manufacturers}
|
||||
themes={themes}
|
||||
set={{
|
||||
id: set.id,
|
||||
manufacturerId: set.manufacturerId,
|
||||
themeId: set.themeId,
|
||||
setNumber: set.setNumber,
|
||||
name: set.name,
|
||||
year: set.year,
|
||||
pieceCount: set.pieceCount,
|
||||
minifigCount: set.minifigCount,
|
||||
ean: set.ean,
|
||||
ageRange: set.ageRange,
|
||||
imageUrl: set.imageUrl,
|
||||
imagePath: set.imagePath,
|
||||
retailPriceCents: set.retailPriceCents,
|
||||
notes: set.notes,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { Check, ExternalLink, Gift, ImageOff, Pencil } from 'lucide-react';
|
||||
import { InstructionsSection } from '@/components/katalog/instructions-section';
|
||||
import { PhotosSection } from '@/components/katalog/photos-section';
|
||||
import { SetActions } from '@/components/katalog/set-actions';
|
||||
import { CollectionItemsSection } from '@/components/sammlung/collection-items-section';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { formatNumber } from '@/lib/format';
|
||||
import { setImageSrc } from '@/lib/images';
|
||||
import { formatEuro } from '@/lib/money';
|
||||
import {
|
||||
getSetById,
|
||||
listCollectionItemsForSet,
|
||||
listInstructionsForSet,
|
||||
listPhotosForSet,
|
||||
} from '@/lib/queries/catalog';
|
||||
import { listLocations } from '@/lib/queries/master-data';
|
||||
import { listWishlistsForSet, listWishlistTargets } from '@/lib/queries/wishlists';
|
||||
import { requireUser } from '@/lib/session';
|
||||
|
||||
type Props = { params: Promise<{ id: string }> };
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { id } = await params;
|
||||
const set = getSetById(id);
|
||||
return { title: set ? `${set.setNumber} · ${set.name}` : 'Set' };
|
||||
}
|
||||
|
||||
export default async function SetDetailPage({ params }: Props) {
|
||||
await requireUser();
|
||||
const { id } = await params;
|
||||
|
||||
const set = getSetById(id);
|
||||
if (!set) notFound();
|
||||
|
||||
const image = setImageSrc(set);
|
||||
const owned = set.ownedCopies > 0;
|
||||
const locations = listLocations().map((row) => ({ id: row.id, name: row.name }));
|
||||
const locationNames = new Map(locations.map((row) => [row.id, row.name]));
|
||||
|
||||
const collectionItems = listCollectionItemsForSet(set.id).map((item) => ({
|
||||
...item,
|
||||
locationName: item.locationId ? (locationNames.get(item.locationId) ?? null) : null,
|
||||
}));
|
||||
|
||||
const facts: Array<[string, string]> = [
|
||||
['Hersteller', set.manufacturerName],
|
||||
['Thema', set.themeName ?? '–'],
|
||||
['Setnummer', set.setNumber],
|
||||
['Jahr', set.year ? String(set.year) : '–'],
|
||||
['Teile', formatNumber(set.pieceCount)],
|
||||
['Minifiguren', formatNumber(set.minifigCount)],
|
||||
['Alter', set.ageRange ?? '–'],
|
||||
['EAN', set.ean ?? '–'],
|
||||
['UVP', formatEuro(set.retailPriceCents)],
|
||||
];
|
||||
|
||||
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="outline" className="font-mono">
|
||||
{set.setNumber}
|
||||
</Badge>
|
||||
<Badge variant="secondary">{set.manufacturerName}</Badge>
|
||||
{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>
|
||||
) : (
|
||||
<Badge variant="outline">Fehlt noch</Badge>
|
||||
)}
|
||||
{set.wishlistCount > 0 ? (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Gift className="size-3" aria-hidden />
|
||||
Auf Wunschliste
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<h1 className="mt-2 text-2xl font-semibold tracking-tight">{set.name}</h1>
|
||||
</div>
|
||||
<Button asChild variant="outline" size="icon-lg" aria-label="Set bearbeiten">
|
||||
<Link href={`/katalog/${set.id}/bearbeiten`}>
|
||||
<Pencil />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-[minmax(0,320px)_1fr]">
|
||||
<div className="overflow-hidden rounded-xl border border-border bg-muted">
|
||||
{image ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={image} alt="" className="aspect-square w-full object-contain p-3" />
|
||||
) : (
|
||||
<div className="flex aspect-square items-center justify-center text-muted-foreground">
|
||||
<ImageOff className="size-10" aria-hidden />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<SetActions
|
||||
setId={set.id}
|
||||
setName={`${set.setNumber} · ${set.name}`}
|
||||
owned={owned}
|
||||
wishlists={listWishlistTargets()}
|
||||
/>
|
||||
|
||||
<dl className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm sm:grid-cols-3">
|
||||
{facts.map(([label, value]) => (
|
||||
<div key={label}>
|
||||
<dt className="text-xs text-muted-foreground">{label}</dt>
|
||||
<dd className="font-medium">{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
|
||||
{set.manufacturerWebsite ? (
|
||||
<Button asChild variant="link" size="sm" className="px-0">
|
||||
<a href={set.manufacturerWebsite} target="_blank" rel="noreferrer noopener">
|
||||
Zum Hersteller
|
||||
<ExternalLink />
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{set.notes ? (
|
||||
<p className="rounded-lg bg-muted p-3 text-sm whitespace-pre-line">{set.notes}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>In der Sammlung</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CollectionItemsSection items={collectionItems} locations={locations} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Anleitungen</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<InstructionsSection setId={set.id} instructions={listInstructionsForSet(set.id)} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Fotos</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<PhotosSection setId={set.id} photos={listPhotosForSet(set.id)} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<WishlistMemberships setId={set.id} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WishlistMemberships({ setId }: { setId: string }) {
|
||||
const lists = listWishlistsForSet(setId);
|
||||
if (lists.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Auf diesen Wunschlisten</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap gap-2">
|
||||
{lists.map((list) => (
|
||||
<Button key={list.id} asChild variant="outline" size="sm">
|
||||
<Link href={`/wunschlisten/${list.id}`}>
|
||||
<Gift />
|
||||
{list.title}
|
||||
</Link>
|
||||
</Button>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { and, eq, ne } from 'drizzle-orm';
|
||||
import { db } from '@/db';
|
||||
import { instruction, photo, set } from '@/db/schema';
|
||||
import { actionData, actionError, actionOk, formToObject, parseForm, type ActionResult } from '@/lib/action-result';
|
||||
import { requireUser } from '@/lib/session';
|
||||
import { deleteStoredFile, storeImage, storePdf, UploadError } from '@/lib/uploads';
|
||||
import { optionalText, optionalUrl, setSchema } from '@/lib/validation';
|
||||
import { z } from 'zod';
|
||||
|
||||
function now() {
|
||||
return new Date();
|
||||
}
|
||||
|
||||
function revalidateSet(id?: string) {
|
||||
revalidatePath('/katalog');
|
||||
revalidatePath('/sammlung');
|
||||
if (id) revalidatePath(`/katalog/${id}`);
|
||||
}
|
||||
|
||||
function fileFrom(formData: FormData, field: string): File | null {
|
||||
const value = formData.get(field);
|
||||
return value instanceof File && value.size > 0 ? value : null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sets
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function createSet(formData: FormData): Promise<ActionResult<{ id: string }>> {
|
||||
await requireUser();
|
||||
|
||||
const parsed = parseForm(setSchema, formToObject(formData));
|
||||
if (!parsed.ok) return parsed;
|
||||
|
||||
const clash = db
|
||||
.select({ id: set.id })
|
||||
.from(set)
|
||||
.where(
|
||||
and(
|
||||
eq(set.manufacturerId, parsed.data.manufacturerId),
|
||||
eq(set.setNumber, parsed.data.setNumber),
|
||||
),
|
||||
)
|
||||
.get();
|
||||
if (clash) {
|
||||
return actionError('Dieses Set gibt es beim gewählten Hersteller schon.');
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
let imagePath: string | null = null;
|
||||
|
||||
const image = fileFrom(formData, 'image');
|
||||
if (image) {
|
||||
try {
|
||||
imagePath = (await storeImage(image, 'set-image', id)).filePath;
|
||||
} catch (error) {
|
||||
if (error instanceof UploadError) return actionError(error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
db.insert(set)
|
||||
.values({
|
||||
id,
|
||||
...parsed.data,
|
||||
imagePath,
|
||||
createdAt: now(),
|
||||
updatedAt: now(),
|
||||
})
|
||||
.run();
|
||||
|
||||
revalidateSet(id);
|
||||
return actionData({ id });
|
||||
}
|
||||
|
||||
export async function updateSet(id: string, formData: FormData): Promise<ActionResult> {
|
||||
await requireUser();
|
||||
|
||||
const parsed = parseForm(setSchema, formToObject(formData));
|
||||
if (!parsed.ok) return parsed;
|
||||
|
||||
const existing = db.select().from(set).where(eq(set.id, id)).get();
|
||||
if (!existing) return actionError('Das Set gibt es nicht mehr.');
|
||||
|
||||
const clash = db
|
||||
.select({ id: set.id })
|
||||
.from(set)
|
||||
.where(
|
||||
and(
|
||||
eq(set.manufacturerId, parsed.data.manufacturerId),
|
||||
eq(set.setNumber, parsed.data.setNumber),
|
||||
ne(set.id, id),
|
||||
),
|
||||
)
|
||||
.get();
|
||||
if (clash) return actionError('Ein anderes Set des Herstellers hat diese Nummer bereits.');
|
||||
|
||||
let imagePath = existing.imagePath;
|
||||
const image = fileFrom(formData, 'image');
|
||||
if (image) {
|
||||
try {
|
||||
const stored = await storeImage(image, 'set-image', id);
|
||||
await deleteStoredFile(existing.imagePath);
|
||||
imagePath = stored.filePath;
|
||||
} catch (error) {
|
||||
if (error instanceof UploadError) return actionError(error.message);
|
||||
throw error;
|
||||
}
|
||||
} else if (formData.get('removeImage') === 'on') {
|
||||
await deleteStoredFile(existing.imagePath);
|
||||
imagePath = null;
|
||||
}
|
||||
|
||||
db.update(set)
|
||||
.set({ ...parsed.data, imagePath, updatedAt: now() })
|
||||
.where(eq(set.id, id))
|
||||
.run();
|
||||
|
||||
revalidateSet(id);
|
||||
return actionOk();
|
||||
}
|
||||
|
||||
export async function deleteSet(id: string): Promise<ActionResult> {
|
||||
await requireUser();
|
||||
|
||||
const existing = db.select().from(set).where(eq(set.id, id)).get();
|
||||
if (!existing) return actionOk();
|
||||
|
||||
const files = [
|
||||
existing.imagePath,
|
||||
...db
|
||||
.select({ path: photo.filePath, thumb: photo.thumbnailPath })
|
||||
.from(photo)
|
||||
.where(eq(photo.setId, id))
|
||||
.all()
|
||||
.flatMap((row) => [row.path, row.thumb]),
|
||||
...db
|
||||
.select({ path: instruction.filePath })
|
||||
.from(instruction)
|
||||
.where(eq(instruction.setId, id))
|
||||
.all()
|
||||
.map((row) => row.path),
|
||||
];
|
||||
|
||||
// Rows go first: if a file is already gone the delete still has to succeed.
|
||||
db.delete(set).where(eq(set.id, id)).run();
|
||||
await Promise.all(files.map((file) => deleteStoredFile(file)));
|
||||
|
||||
revalidateSet(id);
|
||||
return actionOk();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Instructions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const instructionUrlSchema = z.object({
|
||||
label: optionalText(80),
|
||||
url: optionalUrl,
|
||||
});
|
||||
|
||||
export async function addInstructionPdf(setId: string, formData: FormData): Promise<ActionResult> {
|
||||
await requireUser();
|
||||
|
||||
const file = fileFrom(formData, 'file');
|
||||
if (!file) return actionError('Bitte wähle eine PDF-Datei aus.');
|
||||
|
||||
const parsed = parseForm(z.object({ label: optionalText(80) }), formToObject(formData));
|
||||
if (!parsed.ok) return parsed;
|
||||
|
||||
try {
|
||||
const stored = await storePdf(file, setId);
|
||||
db.insert(instruction)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
setId,
|
||||
kind: 'pdf_upload',
|
||||
filePath: stored.filePath,
|
||||
fileSizeBytes: stored.fileSizeBytes,
|
||||
label: parsed.data.label,
|
||||
sortOrder: nextInstructionSortOrder(setId),
|
||||
createdAt: now(),
|
||||
})
|
||||
.run();
|
||||
} catch (error) {
|
||||
if (error instanceof UploadError) return actionError(error.message);
|
||||
throw error;
|
||||
}
|
||||
|
||||
revalidateSet(setId);
|
||||
return actionOk();
|
||||
}
|
||||
|
||||
export async function addInstructionUrl(setId: string, input: unknown): Promise<ActionResult> {
|
||||
await requireUser();
|
||||
|
||||
const parsed = parseForm(instructionUrlSchema, input);
|
||||
if (!parsed.ok) return parsed;
|
||||
if (!parsed.data.url) return actionError('Bitte gib die Adresse der Anleitung an.');
|
||||
|
||||
db.insert(instruction)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
setId,
|
||||
kind: 'external_url',
|
||||
url: parsed.data.url,
|
||||
label: parsed.data.label,
|
||||
sortOrder: nextInstructionSortOrder(setId),
|
||||
createdAt: now(),
|
||||
})
|
||||
.run();
|
||||
|
||||
revalidateSet(setId);
|
||||
return actionOk();
|
||||
}
|
||||
|
||||
export async function deleteInstruction(id: string): Promise<ActionResult> {
|
||||
await requireUser();
|
||||
|
||||
const existing = db.select().from(instruction).where(eq(instruction.id, id)).get();
|
||||
if (!existing) return actionOk();
|
||||
|
||||
db.delete(instruction).where(eq(instruction.id, id)).run();
|
||||
await deleteStoredFile(existing.filePath);
|
||||
|
||||
revalidateSet(existing.setId);
|
||||
return actionOk();
|
||||
}
|
||||
|
||||
function nextInstructionSortOrder(setId: string): number {
|
||||
const rows = db
|
||||
.select({ sortOrder: instruction.sortOrder })
|
||||
.from(instruction)
|
||||
.where(eq(instruction.setId, setId))
|
||||
.all();
|
||||
return rows.reduce((max, row) => Math.max(max, row.sortOrder), -1) + 1;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Photos
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function addSetPhoto(setId: string, formData: FormData): Promise<ActionResult> {
|
||||
await requireUser();
|
||||
|
||||
const file = fileFrom(formData, 'file');
|
||||
if (!file) return actionError('Bitte wähle ein Bild aus.');
|
||||
|
||||
const parsed = parseForm(z.object({ caption: optionalText(160) }), formToObject(formData));
|
||||
if (!parsed.ok) return parsed;
|
||||
|
||||
try {
|
||||
const stored = await storeImage(file, 'photo', setId);
|
||||
db.insert(photo)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
setId,
|
||||
filePath: stored.filePath,
|
||||
thumbnailPath: stored.thumbnailPath,
|
||||
caption: parsed.data.caption,
|
||||
sortOrder: 0,
|
||||
createdAt: now(),
|
||||
})
|
||||
.run();
|
||||
} catch (error) {
|
||||
if (error instanceof UploadError) return actionError(error.message);
|
||||
throw error;
|
||||
}
|
||||
|
||||
revalidateSet(setId);
|
||||
return actionOk();
|
||||
}
|
||||
|
||||
export async function deletePhoto(id: string): Promise<ActionResult> {
|
||||
await requireUser();
|
||||
|
||||
const existing = db.select().from(photo).where(eq(photo.id, id)).get();
|
||||
if (!existing) return actionOk();
|
||||
|
||||
db.delete(photo).where(eq(photo.id, id)).run();
|
||||
await Promise.all([
|
||||
deleteStoredFile(existing.filePath),
|
||||
deleteStoredFile(existing.thumbnailPath),
|
||||
]);
|
||||
|
||||
if (existing.setId) revalidateSet(existing.setId);
|
||||
return actionOk();
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { SetForm } from '@/components/katalog/set-form';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { listManufacturers, listThemes } from '@/lib/queries/master-data';
|
||||
import { requireUser } from '@/lib/session';
|
||||
|
||||
export const metadata: Metadata = { title: 'Neues Set' };
|
||||
|
||||
type SearchParams = Promise<Record<string, string | string[] | undefined>>;
|
||||
|
||||
export default async function NeuesSetPage({ searchParams }: { searchParams: SearchParams }) {
|
||||
await requireUser();
|
||||
const params = await searchParams;
|
||||
const ean = typeof params.ean === 'string' ? params.ean : undefined;
|
||||
|
||||
const manufacturers = listManufacturers().map((row) => ({ id: row.id, name: row.name }));
|
||||
const themes = listThemes().map((row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
manufacturerId: row.manufacturerId,
|
||||
}));
|
||||
|
||||
if (manufacturers.length === 0) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Neues Set</h1>
|
||||
<p className="rounded-lg border border-dashed border-border p-6 text-sm text-muted-foreground">
|
||||
Jedes Set gehört zu einem Hersteller. Leg zuerst einen an – Lego, Playmobil, Cobi oder
|
||||
was auch immer im Kinderzimmer steht.
|
||||
</p>
|
||||
<Button asChild size="lg">
|
||||
<Link href="/verwaltung/hersteller">Hersteller anlegen</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Neues Set</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Ein Set im Katalog heißt noch nicht, dass es da ist – das legst du danach über „Zur
|
||||
Sammlung hinzufügen“ fest.
|
||||
</p>
|
||||
</div>
|
||||
<SetForm manufacturers={manufacturers} themes={themes} defaultEan={ean} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { CatalogFilters } from '@/components/katalog/catalog-filters';
|
||||
import { CatalogGrid } from '@/components/katalog/catalog-grid';
|
||||
import { Pagination } from '@/components/katalog/pagination';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { pluralize } from '@/lib/format';
|
||||
import { OWNERSHIP_FILTER_LABELS, parseOwnershipFilter } from '@/lib/ownership';
|
||||
import { CATALOG_PAGE_SIZE, listSets, listSetYears } from '@/lib/queries/catalog';
|
||||
import { listManufacturers, listThemes } from '@/lib/queries/master-data';
|
||||
import { listWishlistTargets } from '@/lib/queries/wishlists';
|
||||
import { requireUser } from '@/lib/session';
|
||||
|
||||
export const metadata: Metadata = { title: 'Katalog' };
|
||||
|
||||
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 KatalogPage({ searchParams }: { searchParams: SearchParams }) {
|
||||
await requireUser();
|
||||
const params = await searchParams;
|
||||
|
||||
const ownership = parseOwnershipFilter(single(params.besitz));
|
||||
const yearParam = single(params.jahr);
|
||||
const page = Number(single(params.seite) ?? '1') || 1;
|
||||
|
||||
const filters = {
|
||||
q: single(params.q),
|
||||
manufacturerId: single(params.hersteller),
|
||||
themeId: single(params.thema),
|
||||
year: yearParam ? Number(yearParam) : undefined,
|
||||
ownership,
|
||||
page,
|
||||
};
|
||||
|
||||
const { rows, total } = listSets(filters);
|
||||
const manufacturers = listManufacturers().map((row) => ({ id: row.id, name: row.name }));
|
||||
const themes = listThemes().map((row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
manufacturerId: row.manufacturerId,
|
||||
}));
|
||||
const wishlists = listWishlistTargets();
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
{ownership === 'fehlt' ? 'Fehlende Sets' : 'Katalog'}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{ownership === 'fehlt'
|
||||
? 'Sets, die noch nicht in der Sammlung sind.'
|
||||
: 'Alle bekannten Sets – unabhängig davon, ob sie schon da sind.'}
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild size="lg" className="shrink-0">
|
||||
<Link href="/katalog/neu">
|
||||
<Plus />
|
||||
<span className="hidden sm:inline">Set anlegen</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<CatalogFilters options={{ manufacturers, themes, years: listSetYears() }} />
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{pluralize(total, 'Set', 'Sets')}
|
||||
{ownership !== 'alle' ? ` · ${OWNERSHIP_FILTER_LABELS[ownership]}` : ''}
|
||||
</p>
|
||||
|
||||
<CatalogGrid sets={rows} wishlists={wishlists} />
|
||||
|
||||
<Pagination page={page} total={total} pageSize={CATALOG_PAGE_SIZE} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db } from '@/db';
|
||||
import { collectionItem, set } from '@/db/schema';
|
||||
import {
|
||||
actionData,
|
||||
actionError,
|
||||
actionOk,
|
||||
parseForm,
|
||||
type ActionResult,
|
||||
} from '@/lib/action-result';
|
||||
import { reconcileFulfilledWishlistItems } from '@/lib/queries/wishlists';
|
||||
import { requireUser } from '@/lib/session';
|
||||
import { collectionItemSchema } from '@/lib/validation';
|
||||
import type { Condition } from '@/db/types';
|
||||
|
||||
function now() {
|
||||
return new Date();
|
||||
}
|
||||
|
||||
function revalidateCollection(setId?: string) {
|
||||
revalidatePath('/sammlung');
|
||||
revalidatePath('/katalog');
|
||||
revalidatePath('/wunschlisten');
|
||||
revalidatePath('/');
|
||||
if (setId) revalidatePath(`/katalog/${setId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick add from the set detail page: one tap, sensible defaults, details can
|
||||
* be filled in later.
|
||||
*/
|
||||
export async function addSetToCollection(setId: string): Promise<ActionResult<{ id: string }>> {
|
||||
await requireUser();
|
||||
|
||||
const exists = db.select({ id: set.id }).from(set).where(eq(set.id, setId)).get();
|
||||
if (!exists) return actionError('Das Set gibt es nicht mehr.');
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
db.insert(collectionItem)
|
||||
.values({
|
||||
id,
|
||||
setId,
|
||||
status: 'owned',
|
||||
completeness: 'unknown',
|
||||
createdAt: now(),
|
||||
updatedAt: now(),
|
||||
})
|
||||
.run();
|
||||
|
||||
// A wish that just came true is marked as fulfilled right away.
|
||||
reconcileFulfilledWishlistItems();
|
||||
|
||||
revalidateCollection(setId);
|
||||
return actionData({ id });
|
||||
}
|
||||
|
||||
export async function updateCollectionItem(id: string, input: unknown): Promise<ActionResult> {
|
||||
await requireUser();
|
||||
|
||||
const parsed = parseForm(collectionItemSchema, input);
|
||||
if (!parsed.ok) return parsed;
|
||||
|
||||
const existing = db.select().from(collectionItem).where(eq(collectionItem.id, id)).get();
|
||||
if (!existing) return actionError('Dieser Sammlungseintrag gibt es nicht mehr.');
|
||||
|
||||
db.update(collectionItem)
|
||||
.set({
|
||||
status: parsed.data.status,
|
||||
completeness: parsed.data.completeness,
|
||||
condition: parsed.data.condition as Condition | null,
|
||||
locationId: parsed.data.locationId,
|
||||
acquiredAt: parsed.data.acquiredAt,
|
||||
acquiredPriceCents: parsed.data.acquiredPriceCents,
|
||||
acquiredFrom: parsed.data.acquiredFrom,
|
||||
giftedBy: parsed.data.giftedBy,
|
||||
lentOutTo: parsed.data.lentOutTo,
|
||||
notes: parsed.data.notes,
|
||||
updatedAt: now(),
|
||||
})
|
||||
.where(eq(collectionItem.id, id))
|
||||
.run();
|
||||
|
||||
// Selling the last copy puts the set back into "fehlt" - and back onto the
|
||||
// wishlists as an open wish.
|
||||
reconcileFulfilledWishlistItems();
|
||||
|
||||
revalidateCollection(existing.setId);
|
||||
return actionOk();
|
||||
}
|
||||
|
||||
export async function deleteCollectionItem(id: string): Promise<ActionResult> {
|
||||
await requireUser();
|
||||
|
||||
const existing = db.select().from(collectionItem).where(eq(collectionItem.id, id)).get();
|
||||
if (!existing) return actionOk();
|
||||
|
||||
db.delete(collectionItem).where(eq(collectionItem.id, id)).run();
|
||||
reconcileFulfilledWishlistItems();
|
||||
|
||||
revalidateCollection(existing.setId);
|
||||
return actionOk();
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { and, eq, inArray } from 'drizzle-orm';
|
||||
import { db } from '@/db';
|
||||
import { set, wishlist, wishlistItem } from '@/db/schema';
|
||||
import {
|
||||
actionData,
|
||||
actionError,
|
||||
actionOk,
|
||||
parseForm,
|
||||
type ActionResult,
|
||||
} from '@/lib/action-result';
|
||||
import { ownedCopiesSql } from '@/lib/ownership';
|
||||
import {
|
||||
findExistingWishlistSetIds,
|
||||
getWishlist,
|
||||
nextWishlistSortOrder,
|
||||
} from '@/lib/queries/wishlists';
|
||||
import { requireUser } from '@/lib/session';
|
||||
import { createToken } from '@/lib/tokens';
|
||||
import { describeBulkAdd, planBulkAdd } from '@/lib/wishlist-logic';
|
||||
import {
|
||||
addSetsToWishlistSchema,
|
||||
wishlistItemSchema,
|
||||
wishlistSchema,
|
||||
} from '@/lib/validation';
|
||||
|
||||
function now() {
|
||||
return new Date();
|
||||
}
|
||||
|
||||
function revalidateWishlist(id?: string) {
|
||||
revalidatePath('/wunschlisten');
|
||||
revalidatePath('/katalog');
|
||||
if (id) revalidatePath(`/wunschlisten/${id}`);
|
||||
}
|
||||
|
||||
/** Every wishlist action first proves the current user owns the list. */
|
||||
async function requireOwnedWishlist(id: string) {
|
||||
const user = await requireUser();
|
||||
const list = getWishlist(id);
|
||||
if (!list || list.ownerUserId !== user.id) return null;
|
||||
return list;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lists
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function createWishlist(input: unknown): Promise<ActionResult<{ id: string }>> {
|
||||
const user = await requireUser();
|
||||
const parsed = parseForm(wishlistSchema, input);
|
||||
if (!parsed.ok) return parsed;
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
db.insert(wishlist)
|
||||
.values({
|
||||
id,
|
||||
ownerUserId: user.id,
|
||||
title: parsed.data.title,
|
||||
occasion: parsed.data.occasion,
|
||||
eventDate: parsed.data.eventDate,
|
||||
note: parsed.data.note,
|
||||
isPublic: parsed.data.isPublic,
|
||||
surpriseMode: parsed.data.surpriseMode,
|
||||
shareToken: createToken(),
|
||||
createdAt: now(),
|
||||
updatedAt: now(),
|
||||
})
|
||||
.run();
|
||||
|
||||
revalidateWishlist(id);
|
||||
return actionData({ id });
|
||||
}
|
||||
|
||||
export async function updateWishlist(id: string, input: unknown): Promise<ActionResult> {
|
||||
const list = await requireOwnedWishlist(id);
|
||||
if (!list) return actionError('Diese Wunschliste gibt es nicht.');
|
||||
|
||||
const parsed = parseForm(wishlistSchema, input);
|
||||
if (!parsed.ok) return parsed;
|
||||
|
||||
db.update(wishlist)
|
||||
.set({
|
||||
title: parsed.data.title,
|
||||
occasion: parsed.data.occasion,
|
||||
eventDate: parsed.data.eventDate,
|
||||
note: parsed.data.note,
|
||||
isPublic: parsed.data.isPublic,
|
||||
surpriseMode: parsed.data.surpriseMode,
|
||||
updatedAt: now(),
|
||||
})
|
||||
.where(eq(wishlist.id, id))
|
||||
.run();
|
||||
|
||||
revalidateWishlist(id);
|
||||
return actionOk();
|
||||
}
|
||||
|
||||
export async function deleteWishlist(id: string): Promise<ActionResult> {
|
||||
const list = await requireOwnedWishlist(id);
|
||||
if (!list) return actionError('Diese Wunschliste gibt es nicht.');
|
||||
|
||||
db.delete(wishlist).where(eq(wishlist.id, id)).run();
|
||||
revalidateWishlist(id);
|
||||
return actionOk();
|
||||
}
|
||||
|
||||
/**
|
||||
* Issues a fresh share token. The old link stops working immediately - the way
|
||||
* out when a list was shared with the wrong people.
|
||||
*/
|
||||
export async function rotateShareToken(id: string): Promise<ActionResult<{ token: string }>> {
|
||||
const list = await requireOwnedWishlist(id);
|
||||
if (!list) return actionError('Diese Wunschliste gibt es nicht.');
|
||||
|
||||
const token = createToken();
|
||||
db.update(wishlist)
|
||||
.set({ shareToken: token, updatedAt: now() })
|
||||
.where(eq(wishlist.id, id))
|
||||
.run();
|
||||
|
||||
revalidateWishlist(id);
|
||||
return actionData({ token });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Items
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The central path from "fehlende Sets" onto a wishlist.
|
||||
*
|
||||
* Sets that are already on the list are skipped and named in the result, and
|
||||
* so are sets that are already in the collection. The unique index on
|
||||
* (wishlist_id, set_id) makes that a guarantee rather than a hope.
|
||||
*/
|
||||
export async function addSetsToWishlist(
|
||||
input: unknown,
|
||||
): Promise<ActionResult<{ added: number; message: string }>> {
|
||||
await requireUser();
|
||||
|
||||
const parsed = parseForm(addSetsToWishlistSchema, input);
|
||||
if (!parsed.ok) return parsed;
|
||||
|
||||
const list = await requireOwnedWishlist(parsed.data.wishlistId);
|
||||
if (!list) return actionError('Diese Wunschliste gibt es nicht.');
|
||||
|
||||
const setIds = parsed.data.setIds;
|
||||
const known = db
|
||||
.select({
|
||||
id: set.id,
|
||||
name: set.name,
|
||||
setNumber: set.setNumber,
|
||||
ownedCopies: ownedCopiesSql(set.id).mapWith(Number),
|
||||
})
|
||||
.from(set)
|
||||
.where(inArray(set.id, setIds))
|
||||
.all();
|
||||
|
||||
if (known.length === 0) return actionError('Die ausgewählten Sets gibt es nicht mehr.');
|
||||
|
||||
const names = new Map(known.map((row) => [row.id, `${row.setNumber} ${row.name}`]));
|
||||
const ownedSetIds = new Set(known.filter((row) => row.ownedCopies > 0).map((row) => row.id));
|
||||
const existing = findExistingWishlistSetIds(list.id, setIds);
|
||||
|
||||
const plan = planBulkAdd(
|
||||
setIds.filter((id) => names.has(id)),
|
||||
existing,
|
||||
ownedSetIds,
|
||||
);
|
||||
|
||||
if (plan.toAdd.length > 0) {
|
||||
let sortOrder = nextWishlistSortOrder(list.id);
|
||||
db.insert(wishlistItem)
|
||||
.values(
|
||||
plan.toAdd.map((setId) => ({
|
||||
id: crypto.randomUUID(),
|
||||
wishlistId: list.id,
|
||||
setId,
|
||||
priority: parsed.data.priority,
|
||||
sortOrder: sortOrder++,
|
||||
createdAt: now(),
|
||||
})),
|
||||
)
|
||||
// Belt and braces: a concurrent add must not blow up the whole batch.
|
||||
.onConflictDoNothing()
|
||||
.run();
|
||||
}
|
||||
|
||||
revalidateWishlist(list.id);
|
||||
return actionData({ added: plan.toAdd.length, message: describeBulkAdd(plan, names) });
|
||||
}
|
||||
|
||||
export async function updateWishlistItem(itemId: string, input: unknown): Promise<ActionResult> {
|
||||
const user = await requireUser();
|
||||
const parsed = parseForm(wishlistItemSchema, input);
|
||||
if (!parsed.ok) return parsed;
|
||||
|
||||
const item = db
|
||||
.select({ id: wishlistItem.id, wishlistId: wishlistItem.wishlistId })
|
||||
.from(wishlistItem)
|
||||
.innerJoin(wishlist, eq(wishlist.id, wishlistItem.wishlistId))
|
||||
.where(and(eq(wishlistItem.id, itemId), eq(wishlist.ownerUserId, user.id)))
|
||||
.get();
|
||||
if (!item) return actionError('Dieser Eintrag gehört nicht zu deinen Wunschlisten.');
|
||||
|
||||
db.update(wishlistItem)
|
||||
.set({ priority: parsed.data.priority, note: parsed.data.note })
|
||||
.where(eq(wishlistItem.id, itemId))
|
||||
.run();
|
||||
|
||||
revalidateWishlist(item.wishlistId);
|
||||
return actionOk();
|
||||
}
|
||||
|
||||
export async function removeWishlistItem(itemId: string): Promise<ActionResult> {
|
||||
const user = await requireUser();
|
||||
|
||||
const item = db
|
||||
.select({ id: wishlistItem.id, wishlistId: wishlistItem.wishlistId })
|
||||
.from(wishlistItem)
|
||||
.innerJoin(wishlist, eq(wishlist.id, wishlistItem.wishlistId))
|
||||
.where(and(eq(wishlistItem.id, itemId), eq(wishlist.ownerUserId, user.id)))
|
||||
.get();
|
||||
if (!item) return actionOk();
|
||||
|
||||
db.delete(wishlistItem).where(eq(wishlistItem.id, itemId)).run();
|
||||
|
||||
revalidateWishlist(item.wishlistId);
|
||||
return actionOk();
|
||||
}
|
||||
|
||||
/** Persists the order after drag and drop or the move buttons. */
|
||||
export async function reorderWishlistItems(
|
||||
wishlistId: string,
|
||||
orderedItemIds: string[],
|
||||
): Promise<ActionResult> {
|
||||
const list = await requireOwnedWishlist(wishlistId);
|
||||
if (!list) return actionError('Diese Wunschliste gibt es nicht.');
|
||||
|
||||
db.transaction((tx) => {
|
||||
orderedItemIds.forEach((itemId, index) => {
|
||||
tx.update(wishlistItem)
|
||||
.set({ sortOrder: index })
|
||||
.where(and(eq(wishlistItem.id, itemId), eq(wishlistItem.wishlistId, wishlistId)))
|
||||
.run();
|
||||
});
|
||||
});
|
||||
|
||||
revalidateWishlist(wishlistId);
|
||||
return actionOk();
|
||||
}
|
||||
Reference in New Issue
Block a user