feat(katalog): set-katalog mit filtern, detailseite und uploads

Kachelliste mit Suche ueber Setnummer, Name und EAN sowie Filtern nach
Hersteller, Thema, Jahr und Besitzstatus. Besitz kommt aus genau einer
Stelle: src/lib/ownership.ts definiert "hat er nicht" als Katalogeintrag
ohne collection_item mit Status ungleich sold, als korrelierte
Subquery, damit mehrere Exemplare die Liste nicht verdoppeln.

Detailseite mit Metadaten, Anleitungen (PDF-Upload plus pdf.js-Viewer
ohne CDN, oder externer Link), Fotos, Sammlungseintraegen und den
Aktionen "Zur Sammlung" und "Auf Wunschliste".

Upload-Pipeline prueft Magic Bytes statt des gemeldeten MIME-Typs,
erzeugt Dateinamen serverseitig, wandelt Bilder in WebP inklusive
Thumbnail und liefert Dateien ueber /api/files aus - Set-Bilder
oeffentlich, Fotos und PDFs nur mit Session.

Dazu das Seed-Skript mit 3 Herstellern, 6 Themen, 10 Sets und
4 Sammlungseintraegen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
unknown
2026-09-06 22:59:26 +02:00
co-authored by Claude Opus 5
parent af0ae039cb
commit 856800b4e4
29 changed files with 4273 additions and 0 deletions
@@ -0,0 +1,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>
);
}
+192
View File
@@ -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>
);
}
+291
View File
@@ -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();
}
+51
View File
@@ -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>
);
}
+83
View File
@@ -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>
);
}
+105
View File
@@ -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();
}
+254
View File
@@ -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();
}
+55
View File
@@ -0,0 +1,55 @@
import fs from 'node:fs/promises';
import { NextResponse, type NextRequest } from 'next/server';
import { contentTypeFor, isPublicFilePath, resolveFilePath, UploadError } from '@/lib/uploads';
import { getSession } from '@/lib/session';
/**
* Serves uploads from DATA_DIR/files.
*
* Set images stay public because the shared wishlist page needs them; photos
* and instruction PDFs require a session.
*/
export async function GET(
request: NextRequest,
context: { params: Promise<{ path: string[] }> },
) {
const { path: segments } = await context.params;
const relativePath = segments.join('/');
if (!isPublicFilePath(relativePath) && !(await getSession())) {
return new NextResponse('Nicht angemeldet', { status: 401 });
}
let absolute: string;
try {
absolute = resolveFilePath(relativePath);
} catch (error) {
if (error instanceof UploadError) return new NextResponse('Ungültiger Pfad', { status: 400 });
throw error;
}
try {
const file = await fs.readFile(absolute);
const stats = await fs.stat(absolute);
return new NextResponse(new Uint8Array(file), {
headers: {
'Content-Type': contentTypeFor(relativePath),
'Content-Length': String(stats.size),
// Uploaded files never change under the same name, so they can be
// cached hard - privately, since some of them need a session.
'Cache-Control': isPublicFilePath(relativePath)
? 'public, max-age=31536000, immutable'
: 'private, max-age=3600',
'Content-Disposition': `inline; filename="${encodeURIComponent(relativePath.split('/').pop() ?? 'datei')}"`,
'X-Content-Type-Options': 'nosniff',
},
});
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return new NextResponse('Nicht gefunden', { status: 404 });
}
console.error('[files] Datei konnte nicht gelesen werden', relativePath, error);
return new NextResponse('Fehler beim Lesen der Datei', { status: 500 });
}
}
+148
View File
@@ -0,0 +1,148 @@
'use client';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { useEffect, useState, useTransition } from 'react';
import { Loader2, Search, X } from 'lucide-react';
import { OWNERSHIP_FILTERS, OWNERSHIP_FILTER_LABELS } from '@/lib/ownership';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { NativeSelect } from '@/components/ui/native-select';
export type FilterOptions = {
manufacturers: Array<{ id: string; name: string }>;
themes: Array<{ id: string; name: string; manufacturerId: string }>;
years: number[];
};
/**
* Filters live in the URL, so every state of the catalog is a shareable link
* and the back button behaves the way people expect.
*/
export function CatalogFilters({ options }: { options: FilterOptions }) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [pending, startTransition] = useTransition();
const [query, setQuery] = useState(searchParams.get('q') ?? '');
const manufacturerId = searchParams.get('hersteller') ?? '';
const themeId = searchParams.get('thema') ?? '';
const year = searchParams.get('jahr') ?? '';
const ownership = searchParams.get('besitz') ?? 'alle';
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);
}
// Any filter change starts over at page one.
params.delete('seite');
startTransition(() => router.push(`${pathname}?${params.toString()}`));
}
// Debounced search so typing on a phone does not fire a request per keystroke.
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 visibleThemes = manufacturerId
? options.themes.filter((theme) => theme.manufacturerId === manufacturerId)
: options.themes;
const hasFilters = Boolean(manufacturerId || themeId || year || query) || ownership !== 'alle';
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, Name oder EAN"
aria-label="Sets 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="Besitzstatus"
value={ownership}
onChange={(event) => apply({ besitz: event.target.value })}
>
{OWNERSHIP_FILTERS.map((value) => (
<option key={value} value={value}>
{OWNERSHIP_FILTER_LABELS[value]}
</option>
))}
</NativeSelect>
<NativeSelect
aria-label="Hersteller"
value={manufacturerId}
onChange={(event) => apply({ hersteller: event.target.value || null, thema: null })}
>
<option value="">Alle Hersteller</option>
{options.manufacturers.map((manufacturer) => (
<option key={manufacturer.id} value={manufacturer.id}>
{manufacturer.name}
</option>
))}
</NativeSelect>
<NativeSelect
aria-label="Thema"
value={themeId}
onChange={(event) => apply({ thema: event.target.value || null })}
>
<option value="">Alle Themen</option>
{visibleThemes.map((theme) => (
<option key={theme.id} value={theme.id}>
{theme.name}
</option>
))}
</NativeSelect>
<NativeSelect
aria-label="Jahr"
value={year}
onChange={(event) => apply({ jahr: event.target.value || null })}
>
<option value="">Alle Jahre</option>
{options.years.map((value) => (
<option key={value} value={String(value)}>
{value}
</option>
))}
</NativeSelect>
</div>
{hasFilters ? (
<Button
variant="ghost"
size="sm"
onClick={() => {
setQuery('');
startTransition(() => router.push(pathname));
}}
>
<X />
Filter zurücksetzen
</Button>
) : null}
</div>
);
}
+306
View File
@@ -0,0 +1,306 @@
'use client';
import { useRouter } from 'next/navigation';
import { useRef, useState, useTransition } from 'react';
import { CheckCheck, Gift, Loader2, X } from 'lucide-react';
import { toast } from 'sonner';
import { cn } from 'cn';
import { addSetsToWishlist, createWishlist } from '@/app/(app)/wunschlisten/actions';
import type { CatalogRow } from '@/lib/queries/catalog';
import { PRIORITY_LABELS } from '@/lib/wishlist-logic';
import { SetCard, SetCardContent } from './set-card';
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 { NativeSelect } from '@/components/ui/native-select';
const LONG_PRESS_MS = 450;
const NEW_LIST = '__neu__';
export type WishlistTarget = { id: string; title: string };
type Props = {
sets: CatalogRow[];
wishlists: WishlistTarget[];
/** Preselected target, used by the embedded view on a wishlist page. */
defaultWishlistId?: string;
emptyState?: React.ReactNode;
};
/**
* The catalog grid with its selection mode.
*
* On a phone a long press starts the selection, afterwards a tap toggles.
* On a desktop the "Auswählen" button does the same job. Sets that are already
* in the collection cannot be selected - they do not belong on a wishlist.
*/
export function CatalogGrid({ sets, wishlists, defaultWishlistId, emptyState }: Props) {
const router = useRouter();
const [selecting, setSelecting] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [dialogOpen, setDialogOpen] = useState(false);
const [targetId, setTargetId] = useState(defaultWishlistId ?? wishlists[0]?.id ?? NEW_LIST);
const [newTitle, setNewTitle] = useState('');
const [priority, setPriority] = useState('2');
const [pending, startTransition] = useTransition();
const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const longPressFired = useRef(false);
const selectable = sets.filter((item) => item.ownedCopies === 0);
function toggle(setId: string) {
setSelected((current) => {
const next = new Set(current);
if (next.has(setId)) next.delete(setId);
else next.add(setId);
return next;
});
}
function startLongPress(item: CatalogRow) {
if (item.ownedCopies > 0) return;
longPressFired.current = false;
longPressTimer.current = setTimeout(() => {
longPressFired.current = true;
setSelecting(true);
toggle(item.id);
// A short buzz confirms the mode switch where the device supports it.
navigator.vibrate?.(15);
}, LONG_PRESS_MS);
}
function cancelLongPress() {
if (longPressTimer.current) clearTimeout(longPressTimer.current);
longPressTimer.current = null;
}
function endSelection() {
setSelecting(false);
setSelected(new Set());
}
function submit() {
const setIds = [...selected];
if (setIds.length === 0) return;
startTransition(async () => {
let wishlistId = targetId;
if (wishlistId === NEW_LIST) {
const created = await createWishlist({ title: newTitle, occasion: 'other' });
if (!created.ok) {
toast.error(created.error);
return;
}
wishlistId = created.data.id;
}
const result = await addSetsToWishlist({ wishlistId, setIds, priority });
if (!result.ok) {
toast.error(result.error);
return;
}
if (result.data.added === 0) toast.warning(result.data.message);
else toast.success(result.data.message);
setDialogOpen(false);
endSelection();
router.refresh();
});
}
if (sets.length === 0) {
return (
<div className="rounded-lg border border-dashed border-border p-8 text-center text-sm text-muted-foreground">
{emptyState ?? 'Keine Sets gefunden. Passe die Filter an oder leg ein neues Set an.'}
</div>
);
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between gap-2">
<p className="text-xs text-muted-foreground">
{selecting
? 'Tippe die Sets an, die auf die Wunschliste sollen.'
: 'Tipp: Kachel lange drücken, um mehrere Sets auszuwählen.'}
</p>
{selecting ? (
<Button variant="ghost" size="sm" onClick={endSelection}>
<X />
Abbrechen
</Button>
) : (
<Button
variant="outline"
size="sm"
onClick={() => setSelecting(true)}
disabled={selectable.length === 0}
>
<CheckCheck />
Auswählen
</Button>
)}
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
{sets.map((item) => {
const owned = item.ownedCopies > 0;
const isSelected = selected.has(item.id);
if (!selecting) {
return (
<div
key={item.id}
onPointerDown={() => startLongPress(item)}
onPointerUp={cancelLongPress}
onPointerLeave={cancelLongPress}
onPointerCancel={cancelLongPress}
onContextMenu={(event) => {
// The long press must not open the browser context menu.
if (longPressFired.current) event.preventDefault();
}}
onClickCapture={(event) => {
if (longPressFired.current) {
event.preventDefault();
event.stopPropagation();
longPressFired.current = false;
}
}}
>
<SetCard set={item} className="block h-full" />
</div>
);
}
return (
<button
key={item.id}
type="button"
disabled={owned}
aria-pressed={isSelected}
onClick={() => toggle(item.id)}
title={owned ? 'Schon in der Sammlung' : undefined}
className={cn(
'relative rounded-xl text-left outline-none transition-opacity',
'focus-visible:ring-3 focus-visible:ring-ring/50',
owned && 'cursor-not-allowed opacity-50',
isSelected && '[&>div]:border-primary [&>div]:ring-2 [&>div]:ring-primary/40',
)}
>
<SetCardContent set={item} />
{!owned ? (
<span className="absolute top-2 right-2 rounded-md bg-background/90 p-1 shadow-sm">
<Checkbox checked={isSelected} tabIndex={-1} aria-hidden />
</span>
) : null}
</button>
);
})}
</div>
{selecting ? (
<div className="fixed inset-x-0 bottom-0 z-50 border-t border-border bg-background/95 p-3 pb-[calc(env(safe-area-inset-bottom)+0.75rem)] backdrop-blur">
<div className="mx-auto flex max-w-5xl items-center gap-3">
<span className="text-sm font-medium">
{selected.size} ausgewählt
</span>
<div className="flex-1" />
<Button
size="lg"
disabled={selected.size === 0}
onClick={() => setDialogOpen(true)}
>
<Gift />
Zur Wunschliste hinzufügen
</Button>
</div>
</div>
) : null}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{selected.size === 1
? '1 Set auf die Wunschliste'
: `${selected.size} Sets auf die Wunschliste`}
</DialogTitle>
<DialogDescription>
Sets, die schon auf der Liste oder bereits in der Sammlung sind, werden
übersprungen.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<Field label="Wunschliste" htmlFor="target-wishlist" required>
<NativeSelect
id="target-wishlist"
value={targetId}
onChange={(event) => setTargetId(event.target.value)}
>
{wishlists.map((list) => (
<option key={list.id} value={list.id}>
{list.title}
</option>
))}
<option value={NEW_LIST}>+ Neue Wunschliste anlegen</option>
</NativeSelect>
</Field>
{targetId === NEW_LIST ? (
<Field label="Titel der neuen Liste" htmlFor="new-title" required>
<Input
id="new-title"
value={newTitle}
onChange={(event) => setNewTitle(event.target.value)}
placeholder="Weihnachten 2026"
maxLength={80}
/>
</Field>
) : null}
<Field
label="Priorität"
htmlFor="priority"
hint="Gilt für alle ausgewählten Sets und lässt sich später je Eintrag ändern."
>
<NativeSelect
id="priority"
value={priority}
onChange={(event) => setPriority(event.target.value)}
>
{Object.entries(PRIORITY_LABELS).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</NativeSelect>
</Field>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)} disabled={pending}>
Abbrechen
</Button>
<Button onClick={submit} disabled={pending}>
{pending ? <Loader2 className="animate-spin" /> : <Gift />}
Hinzufügen
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -0,0 +1,215 @@
'use client';
import { useRouter } from 'next/navigation';
import { useState, useTransition } from 'react';
import { Download, ExternalLink, FileText, Loader2, Plus, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import {
addInstructionPdf,
addInstructionUrl,
deleteInstruction,
} from '@/app/(app)/katalog/actions';
import { fileUrl } from '@/lib/images';
import { PdfViewer } from './pdf-viewer';
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';
import { NativeSelect } from '@/components/ui/native-select';
export type InstructionRow = {
id: string;
kind: 'pdf_upload' | 'external_url';
filePath: string | null;
url: string | null;
label: string | null;
fileSizeBytes: number | null;
};
function formatSize(bytes: number | null): string {
if (!bytes) return '';
const mb = bytes / (1024 * 1024);
return mb >= 1
? `${mb.toLocaleString('de-DE', { maximumFractionDigits: 1 })} MB`
: `${Math.round(bytes / 1024)} KB`;
}
export function InstructionsSection({
setId,
instructions,
}: {
setId: string;
instructions: InstructionRow[];
}) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [addOpen, setAddOpen] = useState(false);
const [kind, setKind] = useState<'pdf_upload' | 'external_url'>('pdf_upload');
const [viewing, setViewing] = useState<InstructionRow | null>(null);
function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const formData = new FormData(event.currentTarget);
startTransition(async () => {
const result =
kind === 'pdf_upload'
? await addInstructionPdf(setId, formData)
: await addInstructionUrl(setId, Object.fromEntries(formData.entries()));
if (!result.ok) {
toast.error(result.error);
return;
}
toast.success('Anleitung hinzugefügt.');
setAddOpen(false);
router.refresh();
});
}
function remove(id: string) {
startTransition(async () => {
const result = await deleteInstruction(id);
if (!result.ok) {
toast.error(result.error);
return;
}
toast.success('Anleitung entfernt.');
router.refresh();
});
}
return (
<div className="space-y-3">
{instructions.length === 0 ? (
<p className="text-sm text-muted-foreground">
Noch keine Anleitung hinterlegt. Lade das PDF hoch oder verlinke es beim Hersteller.
</p>
) : (
<ul className="divide-y divide-border rounded-lg border border-border">
{instructions.map((item) => (
<li key={item.id} className="flex items-center gap-3 p-3">
<FileText className="size-5 shrink-0 text-muted-foreground" aria-hidden />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">
{item.label ?? (item.kind === 'pdf_upload' ? 'Anleitung' : 'Externe Anleitung')}
</p>
<p className="text-xs text-muted-foreground">
{item.kind === 'pdf_upload' ? `PDF ${formatSize(item.fileSizeBytes)}` : 'Link'}
</p>
</div>
{item.kind === 'pdf_upload' && item.filePath ? (
<>
<Button variant="outline" size="sm" onClick={() => setViewing(item)}>
Ansehen
</Button>
<Button asChild variant="ghost" size="icon-sm" aria-label="Herunterladen">
<a href={fileUrl(item.filePath)} download>
<Download />
</a>
</Button>
</>
) : item.url ? (
<Button asChild variant="outline" size="sm">
<a href={item.url} target="_blank" rel="noreferrer noopener">
Öffnen
<ExternalLink />
</a>
</Button>
) : null}
<Button
variant="ghost"
size="icon-sm"
aria-label="Anleitung entfernen"
onClick={() => remove(item.id)}
disabled={pending}
>
<Trash2 className="text-destructive" />
</Button>
</li>
))}
</ul>
)}
<Button variant="outline" onClick={() => setAddOpen(true)}>
<Plus />
Anleitung hinzufügen
</Button>
<Dialog open={addOpen} onOpenChange={setAddOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Anleitung hinzufügen</DialogTitle>
<DialogDescription>
Ein Set kann mehrere Anleitungen haben – zum Beispiel „Buch 1 von 3“.
</DialogDescription>
</DialogHeader>
<form id="instruction-form" onSubmit={submit} className="space-y-4">
<Field label="Art" htmlFor="instruction-kind">
<NativeSelect
id="instruction-kind"
value={kind}
onChange={(event) => setKind(event.target.value as typeof kind)}
>
<option value="pdf_upload">PDF hochladen</option>
<option value="external_url">Link zur Anleitung</option>
</NativeSelect>
</Field>
<Field label="Bezeichnung" htmlFor="label" hint="Optional, z. B. „Buch 1 von 3“.">
<Input id="label" name="label" maxLength={80} />
</Field>
{kind === 'pdf_upload' ? (
<Field label="PDF-Datei" htmlFor="file" required>
<Input id="file" name="file" type="file" accept="application/pdf" required />
</Field>
) : (
<Field label="Adresse" htmlFor="url" required>
<Input
id="url"
name="url"
type="url"
inputMode="url"
required
placeholder="https://www.lego.com/…"
/>
</Field>
)}
</form>
<DialogFooter>
<Button variant="outline" onClick={() => setAddOpen(false)} disabled={pending}>
Abbrechen
</Button>
<Button type="submit" form="instruction-form" disabled={pending}>
{pending ? <Loader2 className="animate-spin" /> : null}
Hinzufügen
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={viewing !== null} onOpenChange={(open) => !open && setViewing(null)}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>{viewing?.label ?? 'Anleitung'}</DialogTitle>
</DialogHeader>
{viewing?.filePath ? (
<PdfViewer url={fileUrl(viewing.filePath)} label={viewing.label ?? 'Anleitung'} />
) : null}
</DialogContent>
</Dialog>
</div>
);
}
+54
View File
@@ -0,0 +1,54 @@
'use client';
import { usePathname, useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { Button } from '@/components/ui/button';
export function Pagination({
page,
total,
pageSize,
}: {
page: number;
total: number;
pageSize: number;
}) {
const pathname = usePathname();
const searchParams = useSearchParams();
const lastPage = Math.max(1, Math.ceil(total / pageSize));
if (lastPage <= 1) return null;
function hrefFor(target: number) {
const params = new URLSearchParams(searchParams.toString());
if (target <= 1) params.delete('seite');
else params.set('seite', String(target));
const query = params.toString();
return query ? `${pathname}?${query}` : pathname;
}
return (
<nav className="flex items-center justify-between gap-3" aria-label="Seiten">
<Button asChild variant="outline" disabled={page <= 1}>
<Link href={hrefFor(page - 1)} aria-disabled={page <= 1} className={page <= 1 ? 'pointer-events-none opacity-50' : ''}>
<ChevronLeft />
Zurück
</Link>
</Button>
<span className="text-sm text-muted-foreground">
Seite {page} von {lastPage}
</span>
<Button asChild variant="outline" disabled={page >= lastPage}>
<Link
href={hrefFor(page + 1)}
aria-disabled={page >= lastPage}
className={page >= lastPage ? 'pointer-events-none opacity-50' : ''}
>
Weiter
<ChevronRight />
</Link>
</Button>
</nav>
);
}
+155
View File
@@ -0,0 +1,155 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import { ChevronLeft, ChevronRight, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
/**
* Minimal pdf.js viewer for building instructions.
*
* The library and its worker are bundled with the app - no CDN, no external
* request. One page at a time, because that is how you actually build a set,
* and because rendering 200 pages at once would stall a phone.
*/
export function PdfViewer({ url, label }: { url: string; label: string }) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const documentRef = useRef<any>(null);
const renderTaskRef = useRef<{ cancel: () => void } | null>(null);
const loadingTaskRef = useRef<{ destroy: () => Promise<void> } | null>(null);
const [page, setPage] = useState(1);
const [pageCount, setPageCount] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
async function load() {
try {
const pdfjs = await import('pdfjs-dist');
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url,
).toString();
const loadingTask = pdfjs.getDocument({ url });
const document = await loadingTask.promise;
if (cancelled) {
void loadingTask.destroy();
return;
}
loadingTaskRef.current = loadingTask;
documentRef.current = document;
setPageCount(document.numPages);
setPage(1);
} catch (cause) {
console.error('[pdf] konnte nicht geladen werden', cause);
if (!cancelled) setError('Die Anleitung konnte nicht geladen werden.');
}
}
void load();
return () => {
cancelled = true;
renderTaskRef.current?.cancel();
void loadingTaskRef.current?.destroy();
loadingTaskRef.current = null;
documentRef.current = null;
};
}, [url]);
const renderPage = useCallback(async (pageNumber: number) => {
const document = documentRef.current;
const canvas = canvasRef.current;
const container = containerRef.current;
if (!document || !canvas || !container) return;
setLoading(true);
renderTaskRef.current?.cancel();
try {
const pdfPage = await document.getPage(pageNumber);
const unscaled = pdfPage.getViewport({ scale: 1 });
// Fit the width of the container, but stay sharp on high-DPI screens.
const scale = (container.clientWidth || 600) / unscaled.width;
const ratio = Math.min(window.devicePixelRatio || 1, 2);
const viewport = pdfPage.getViewport({ scale: scale * ratio });
canvas.width = viewport.width;
canvas.height = viewport.height;
canvas.style.width = '100%';
canvas.style.height = 'auto';
const context = canvas.getContext('2d');
if (!context) return;
const task = pdfPage.render({ canvas, canvasContext: context, viewport });
renderTaskRef.current = task;
await task.promise;
} catch (cause) {
if ((cause as { name?: string })?.name !== 'RenderingCancelledException') {
console.error('[pdf] Seite konnte nicht gezeichnet werden', cause);
}
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (pageCount > 0) void renderPage(page);
}, [page, pageCount, renderPage]);
if (error) {
return (
<div className="space-y-3 rounded-lg border border-border p-4 text-sm">
<p className="text-destructive">{error}</p>
<Button asChild variant="outline" size="sm">
<a href={url} target="_blank" rel="noreferrer">
PDF direkt öffnen
</a>
</Button>
</div>
);
}
return (
<div className="space-y-3">
<div ref={containerRef} className="relative rounded-lg border border-border bg-muted/30 p-2">
<canvas ref={canvasRef} aria-label={`${label}, Seite ${page}`} className="mx-auto block" />
{loading ? (
<div className="absolute inset-0 flex items-center justify-center bg-background/60">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
) : null}
</div>
<div className="flex items-center justify-between gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPage((current) => Math.max(1, current - 1))}
disabled={page <= 1 || loading}
>
<ChevronLeft />
Zurück
</Button>
<span className="text-sm text-muted-foreground">
Seite {page} von {pageCount || '…'}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setPage((current) => Math.min(pageCount, current + 1))}
disabled={page >= pageCount || loading}
>
Weiter
<ChevronRight />
</Button>
</div>
</div>
);
}
+138
View File
@@ -0,0 +1,138 @@
'use client';
import { useRouter } from 'next/navigation';
import { useState, useTransition } from 'react';
import { Camera, Loader2, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { addSetPhoto, deletePhoto } from '@/app/(app)/katalog/actions';
import { fileUrl } from '@/lib/images';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Field } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
export type PhotoRow = {
id: string;
filePath: string;
thumbnailPath: string | null;
caption: string | null;
};
export function PhotosSection({ setId, photos }: { setId: string; photos: PhotoRow[] }) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [addOpen, setAddOpen] = useState(false);
function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const formData = new FormData(event.currentTarget);
startTransition(async () => {
const result = await addSetPhoto(setId, formData);
if (!result.ok) {
toast.error(result.error);
return;
}
toast.success('Foto gespeichert.');
setAddOpen(false);
router.refresh();
});
}
function remove(id: string) {
startTransition(async () => {
const result = await deletePhoto(id);
if (!result.ok) {
toast.error(result.error);
return;
}
toast.success('Foto gelöscht.');
router.refresh();
});
}
return (
<div className="space-y-3">
{photos.length > 0 ? (
<ul className="grid grid-cols-3 gap-2 sm:grid-cols-4">
{photos.map((item) => (
<li key={item.id} className="group relative">
<a
href={fileUrl(item.filePath)}
target="_blank"
rel="noreferrer"
className="block overflow-hidden rounded-lg border border-border"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={fileUrl(item.thumbnailPath ?? item.filePath)}
alt={item.caption ?? ''}
loading="lazy"
className="aspect-square w-full object-cover"
/>
</a>
<Button
variant="secondary"
size="icon-xs"
aria-label="Foto löschen"
className="absolute top-1 right-1 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
onClick={() => remove(item.id)}
disabled={pending}
>
<Trash2 className="text-destructive" />
</Button>
</li>
))}
</ul>
) : (
<p className="text-sm text-muted-foreground">
Noch keine eigenen Fotos – zum Beispiel vom fertig gebauten Modell.
</p>
)}
<Button variant="outline" onClick={() => setAddOpen(true)}>
<Camera />
Foto hinzufügen
</Button>
<Dialog open={addOpen} onOpenChange={setAddOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Foto hinzufügen</DialogTitle>
</DialogHeader>
<form id="photo-form" onSubmit={submit} className="space-y-4">
<Field label="Bild" htmlFor="photo-file" required>
{/* capture lets the phone open the camera directly. */}
<Input
id="photo-file"
name="file"
type="file"
accept="image/jpeg,image/png,image/webp"
capture="environment"
required
/>
</Field>
<Field label="Bildunterschrift" htmlFor="caption">
<Input id="caption" name="caption" maxLength={160} />
</Field>
</form>
<DialogFooter>
<Button variant="outline" onClick={() => setAddOpen(false)} disabled={pending}>
Abbrechen
</Button>
<Button type="submit" form="photo-form" disabled={pending}>
{pending ? <Loader2 className="animate-spin" /> : null}
Speichern
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+216
View File
@@ -0,0 +1,216 @@
'use client';
import { useRouter } from 'next/navigation';
import { useState, useTransition } from 'react';
import { Gift, Loader2, Plus, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { deleteSet } from '@/app/(app)/katalog/actions';
import { addSetToCollection } from '@/app/(app)/sammlung/actions';
import { addSetsToWishlist, createWishlist } from '@/app/(app)/wunschlisten/actions';
import { PRIORITY_LABELS } from '@/lib/wishlist-logic';
import { Button } from '@/components/ui/button';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Field } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { NativeSelect } from '@/components/ui/native-select';
const NEW_LIST = '__neu__';
export function SetActions({
setId,
setName,
owned,
wishlists,
}: {
setId: string;
setName: string;
owned: boolean;
wishlists: Array<{ id: string; title: string }>;
}) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [wishlistOpen, setWishlistOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [targetId, setTargetId] = useState(wishlists[0]?.id ?? NEW_LIST);
const [newTitle, setNewTitle] = useState('');
const [priority, setPriority] = useState('2');
function addToCollection() {
startTransition(async () => {
const result = await addSetToCollection(setId);
if (!result.ok) {
toast.error(result.error);
return;
}
toast.success('Zur Sammlung hinzugefügt.');
router.refresh();
});
}
function addToWishlist() {
startTransition(async () => {
let wishlistId = targetId;
if (wishlistId === NEW_LIST) {
const created = await createWishlist({ title: newTitle, occasion: 'other' });
if (!created.ok) {
toast.error(created.error);
return;
}
wishlistId = created.data.id;
}
const result = await addSetsToWishlist({ wishlistId, setIds: [setId], priority });
if (!result.ok) {
toast.error(result.error);
return;
}
if (result.data.added === 0) toast.warning(result.data.message);
else toast.success('Auf die Wunschliste gesetzt.');
setWishlistOpen(false);
router.refresh();
});
}
function remove() {
startTransition(async () => {
const result = await deleteSet(setId);
if (!result.ok) {
toast.error(result.error);
return;
}
toast.success('Set gelöscht.');
router.push('/katalog');
router.refresh();
});
}
return (
<div className="flex flex-wrap gap-2">
<Button size="lg" onClick={addToCollection} disabled={pending}>
{pending ? <Loader2 className="animate-spin" /> : <Plus />}
Zur Sammlung hinzufügen
</Button>
<Button
size="lg"
variant="outline"
onClick={() => setWishlistOpen(true)}
disabled={pending || owned}
title={owned ? 'Das Set ist bereits in der Sammlung.' : undefined}
>
<Gift />
Auf Wunschliste
</Button>
<Button size="lg" variant="ghost" onClick={() => setDeleteOpen(true)} disabled={pending}>
<Trash2 className="text-destructive" />
Löschen
</Button>
{owned ? (
<p className="w-full text-xs text-muted-foreground">
Sets aus der Sammlung lassen sich nicht auf eine Wunschliste setzen.
</p>
) : null}
<Dialog open={wishlistOpen} onOpenChange={setWishlistOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Auf Wunschliste setzen</DialogTitle>
<DialogDescription>{setName}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<Field label="Wunschliste" htmlFor="single-target" required>
<NativeSelect
id="single-target"
value={targetId}
onChange={(event) => setTargetId(event.target.value)}
>
{wishlists.map((list) => (
<option key={list.id} value={list.id}>
{list.title}
</option>
))}
<option value={NEW_LIST}>+ Neue Wunschliste anlegen</option>
</NativeSelect>
</Field>
{targetId === NEW_LIST ? (
<Field label="Titel der neuen Liste" htmlFor="single-new-title" required>
<Input
id="single-new-title"
value={newTitle}
onChange={(event) => setNewTitle(event.target.value)}
placeholder="Weihnachten 2026"
maxLength={80}
/>
</Field>
) : null}
<Field label="Priorität" htmlFor="single-priority">
<NativeSelect
id="single-priority"
value={priority}
onChange={(event) => setPriority(event.target.value)}
>
{Object.entries(PRIORITY_LABELS).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</NativeSelect>
</Field>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setWishlistOpen(false)} disabled={pending}>
Abbrechen
</Button>
<Button onClick={addToWishlist} disabled={pending}>
{pending ? <Loader2 className="animate-spin" /> : <Gift />}
Hinzufügen
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Set wirklich löschen?</AlertDialogTitle>
<AlertDialogDescription>
„{setName}“ wird aus dem Katalog entfernt – mit allen Sammlungseinträgen,
Anleitungen, Fotos und Wunschlisten-Einträgen dazu. Das lässt sich nicht rückgängig
machen.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Abbrechen</AlertDialogCancel>
<AlertDialogAction onClick={remove}>Löschen</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
+88
View File
@@ -0,0 +1,88 @@
import Link from 'next/link';
import { Check, Gift, ImageOff } from 'lucide-react';
import { cn } from 'cn';
import type { CatalogRow } from '@/lib/queries/catalog';
import { formatEuro } from '@/lib/money';
import { setImageSrc } from '@/lib/images';
import { Badge } from '@/components/ui/badge';
/**
* One set in the catalog grid.
*
* Rendered as a plain block so it can be used both as a link (browsing) and
* inside a selection wrapper (bulk add to a wishlist).
*/
export function SetCardContent({ set }: { set: CatalogRow }) {
const image = setImageSrc(set);
const owned = set.ownedCopies > 0;
return (
<div className="flex h-full flex-col overflow-hidden rounded-xl border border-border bg-card transition-colors">
<div className="relative aspect-square bg-muted">
{image ? (
// Plain img: images are either local files or a URL the user entered,
// and the optimizer must never fetch arbitrary remote hosts.
// eslint-disable-next-line @next/next/no-img-element
<img
src={image}
alt=""
loading="lazy"
decoding="async"
className="size-full object-contain p-2"
/>
) : (
<div className="flex size-full items-center justify-center text-muted-foreground">
<ImageOff className="size-8" aria-hidden />
</div>
)}
<div className="absolute top-2 left-2 flex flex-col items-start gap-1">
{owned ? (
<Badge className="gap-1 bg-emerald-600 text-white hover:bg-emerald-600">
<Check className="size-3" aria-hidden />
{set.ownedCopies > 1 ? `${set.ownedCopies}× vorhanden` : 'Vorhanden'}
</Badge>
) : null}
{set.wishlistCount > 0 ? (
<Badge variant="secondary" className="gap-1">
<Gift className="size-3" aria-hidden />
Auf Wunschliste
</Badge>
) : null}
</div>
</div>
<div className="flex flex-1 flex-col gap-1 p-3">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Badge variant="outline" className="font-mono">
{set.setNumber}
</Badge>
<span className="truncate">{set.manufacturerName}</span>
</div>
<p className="line-clamp-2 text-sm leading-snug font-medium">{set.name}</p>
<div className="mt-auto flex items-center justify-between gap-2 pt-1 text-xs text-muted-foreground">
<span className="truncate">{[set.themeName, set.year].filter(Boolean).join(' · ')}</span>
{set.retailPriceCents !== null ? (
<span className="shrink-0 font-medium text-foreground">
{formatEuro(set.retailPriceCents)}
</span>
) : null}
</div>
</div>
</div>
);
}
export function SetCard({ set, className }: { set: CatalogRow; className?: string }) {
return (
<Link
href={`/katalog/${set.id}`}
className={cn(
'rounded-xl outline-none focus-visible:ring-3 focus-visible:ring-ring/50 [&>div]:hover:border-primary/40',
className,
)}
>
<SetCardContent set={set} />
</Link>
);
}
+248
View File
@@ -0,0 +1,248 @@
'use client';
import { useRouter } from 'next/navigation';
import { useState, useTransition } from 'react';
import { Loader2, Save } from 'lucide-react';
import { toast } from 'sonner';
import { createSet, updateSet } from '@/app/(app)/katalog/actions';
import { centsToInput } from '@/lib/money';
import { setImageSrc } from '@/lib/images';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
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 SetFormValues = {
id: string;
manufacturerId: string;
themeId: string | null;
setNumber: string;
name: string;
year: number | null;
pieceCount: number | null;
minifigCount: number | null;
ean: string | null;
ageRange: string | null;
imageUrl: string | null;
imagePath: string | null;
retailPriceCents: number | null;
notes: string | null;
};
type Props = {
manufacturers: Array<{ id: string; name: string }>;
themes: Array<{ id: string; name: string; manufacturerId: string }>;
set?: SetFormValues;
/** Prefilled EAN coming from the barcode scanner (phase 2). */
defaultEan?: string;
};
export function SetForm({ manufacturers, themes, set, defaultEan }: Props) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [manufacturerId, setManufacturerId] = useState(
set?.manufacturerId ?? manufacturers[0]?.id ?? '',
);
const existingImage = set ? setImageSrc(set) : null;
const availableThemes = themes.filter((theme) => theme.manufacturerId === manufacturerId);
function onSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const formData = new FormData(event.currentTarget);
startTransition(async () => {
if (set) {
const result = await updateSet(set.id, formData);
if (!result.ok) {
toast.error(result.error);
return;
}
toast.success('Set gespeichert.');
router.push(`/katalog/${set.id}`);
router.refresh();
return;
}
const result = await createSet(formData);
if (!result.ok) {
toast.error(result.error);
return;
}
toast.success('Set angelegt.');
router.push(`/katalog/${result.data.id}`);
router.refresh();
});
}
return (
<form onSubmit={onSubmit} className="space-y-6">
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Hersteller" htmlFor="manufacturerId" required>
<NativeSelect
id="manufacturerId"
name="manufacturerId"
value={manufacturerId}
onChange={(event) => setManufacturerId(event.target.value)}
required
>
{manufacturers.map((manufacturer) => (
<option key={manufacturer.id} value={manufacturer.id}>
{manufacturer.name}
</option>
))}
</NativeSelect>
</Field>
<Field label="Thema" htmlFor="themeId" hint="Optional.">
<NativeSelect id="themeId" name="themeId" defaultValue={set?.themeId ?? 'none'}>
<option value="none">– kein Thema –</option>
{availableThemes.map((theme) => (
<option key={theme.id} value={theme.id}>
{theme.name}
</option>
))}
</NativeSelect>
</Field>
<Field label="Setnummer" htmlFor="setNumber" required hint="Die Nummer des Herstellers.">
<Input
id="setNumber"
name="setNumber"
defaultValue={set?.setNumber ?? ''}
required
maxLength={40}
placeholder="60398"
className="font-mono"
/>
</Field>
<Field label="Name" htmlFor="name" required className="sm:col-span-1">
<Input
id="name"
name="name"
defaultValue={set?.name ?? ''}
required
maxLength={160}
placeholder="Familienhaus und Elektroauto"
/>
</Field>
<Field label="Jahr" htmlFor="year">
<Input
id="year"
name="year"
type="number"
inputMode="numeric"
defaultValue={set?.year ?? ''}
min={1930}
max={new Date().getFullYear() + 5}
/>
</Field>
<Field label="Teile" htmlFor="pieceCount">
<Input
id="pieceCount"
name="pieceCount"
type="number"
inputMode="numeric"
defaultValue={set?.pieceCount ?? ''}
min={0}
/>
</Field>
<Field label="Minifiguren" htmlFor="minifigCount">
<Input
id="minifigCount"
name="minifigCount"
type="number"
inputMode="numeric"
defaultValue={set?.minifigCount ?? ''}
min={0}
/>
</Field>
<Field label="UVP" htmlFor="retailPriceCents" hint="In Euro, z. B. 89,99.">
<Input
id="retailPriceCents"
name="retailPriceCents"
inputMode="decimal"
defaultValue={centsToInput(set?.retailPriceCents)}
placeholder="89,99"
/>
</Field>
<Field label="EAN" htmlFor="ean" hint="Der Strichcode auf der Verpackung.">
<Input
id="ean"
name="ean"
inputMode="numeric"
defaultValue={set?.ean ?? defaultEan ?? ''}
maxLength={20}
className="font-mono"
/>
</Field>
<Field label="Alter" htmlFor="ageRange" hint="Zum Beispiel 6-12.">
<Input id="ageRange" name="ageRange" defaultValue={set?.ageRange ?? ''} maxLength={20} />
</Field>
</div>
<Field
label="Bild-URL"
htmlFor="imageUrl"
hint="Wird nur verwendet, wenn kein Bild hochgeladen ist."
>
<Input
id="imageUrl"
name="imageUrl"
type="url"
inputMode="url"
defaultValue={set?.imageUrl ?? ''}
placeholder="https://…"
/>
</Field>
<div className="space-y-2">
<Label htmlFor="image">Bild hochladen</Label>
{existingImage ? (
<div className="flex items-center gap-3">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={existingImage}
alt=""
className="size-20 rounded-lg border border-border object-contain"
/>
{set?.imagePath ? (
<label className="flex items-center gap-2 text-sm">
<Checkbox name="removeImage" value="on" />
Hochgeladenes Bild entfernen
</label>
) : null}
</div>
) : null}
<Input id="image" name="image" type="file" accept="image/jpeg,image/png,image/webp" />
<p className="text-xs text-muted-foreground">
JPEG, PNG oder WebP. Das Bild wird beim Speichern verkleinert und lokal abgelegt.
</p>
</div>
<Field label="Notizen" htmlFor="notes">
<Textarea id="notes" name="notes" defaultValue={set?.notes ?? ''} rows={4} />
</Field>
<div className="flex gap-3">
<Button type="submit" size="lg" disabled={pending}>
{pending ? <Loader2 className="animate-spin" /> : <Save />}
{set ? 'Speichern' : 'Set anlegen'}
</Button>
<Button type="button" variant="outline" size="lg" onClick={() => router.back()}>
Abbrechen
</Button>
</div>
</form>
);
}
@@ -0,0 +1,302 @@
'use client';
import { useRouter } from 'next/navigation';
import { useState, useTransition } from 'react';
import { Loader2, Pencil, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { deleteCollectionItem, updateCollectionItem } from '@/app/(app)/sammlung/actions';
import type { CollectionStatus, Completeness, Condition } from '@/db/types';
import { formatDate, toDateInputValue } from '@/lib/format';
import {
COMPLETENESS_LABELS,
CONDITION_LABELS,
STATUS_LABELS,
completenessLabel,
conditionLabel,
statusLabel,
} from '@/lib/labels';
import { centsToInput, formatEuro } from '@/lib/money';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Field } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { NativeSelect } from '@/components/ui/native-select';
import { Textarea } from '@/components/ui/textarea';
export type CollectionItemRow = {
id: string;
setId: string;
status: CollectionStatus;
completeness: Completeness;
condition: Condition | null;
locationId: string | null;
locationName?: string | null;
acquiredAt: Date | null;
acquiredPriceCents: number | null;
acquiredFrom: string | null;
giftedBy: string | null;
lentOutTo: string | null;
notes: string | null;
};
export function CollectionItemsSection({
items,
locations,
}: {
items: CollectionItemRow[];
locations: Array<{ id: string; name: string }>;
}) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [editing, setEditing] = useState<CollectionItemRow | null>(null);
const [deleting, setDeleting] = useState<CollectionItemRow | null>(null);
function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const item = editing;
if (!item) return;
const input = Object.fromEntries(new FormData(event.currentTarget).entries());
startTransition(async () => {
const result = await updateCollectionItem(item.id, { ...input, setId: item.setId });
if (!result.ok) {
toast.error(result.error);
return;
}
toast.success('Sammlungseintrag gespeichert.');
setEditing(null);
router.refresh();
});
}
function remove() {
const item = deleting;
if (!item) return;
startTransition(async () => {
const result = await deleteCollectionItem(item.id);
if (!result.ok) {
toast.error(result.error);
return;
}
toast.success('Sammlungseintrag entfernt.');
setDeleting(null);
router.refresh();
});
}
if (items.length === 0) {
return (
<p className="text-sm text-muted-foreground">
Dieses Set ist noch nicht in der Sammlung.
</p>
);
}
return (
<div className="space-y-3">
<ul className="divide-y divide-border rounded-lg border border-border">
{items.map((item) => (
<li key={item.id} className="flex items-start gap-3 p-3">
<div className="min-w-0 flex-1 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<Badge variant={item.status === 'sold' ? 'outline' : 'default'}>
{statusLabel(item.status)}
</Badge>
<Badge variant="secondary">{completenessLabel(item.completeness)}</Badge>
{item.locationName ? (
<span className="text-xs text-muted-foreground">{item.locationName}</span>
) : null}
</div>
<p className="text-xs text-muted-foreground">
{[
item.acquiredAt ? `Seit ${formatDate(item.acquiredAt)}` : null,
item.acquiredPriceCents !== null
? `Gekauft für ${formatEuro(item.acquiredPriceCents)}`
: null,
item.giftedBy ? `Geschenk von ${item.giftedBy}` : null,
item.lentOutTo ? `Verliehen an ${item.lentOutTo}` : null,
item.condition ? conditionLabel(item.condition) : null,
]
.filter(Boolean)
.join(' · ') || 'Keine weiteren Angaben'}
</p>
{item.notes ? <p className="text-xs">{item.notes}</p> : null}
</div>
<Button
variant="ghost"
size="icon-sm"
aria-label="Bearbeiten"
onClick={() => setEditing(item)}
disabled={pending}
>
<Pencil />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label="Entfernen"
onClick={() => setDeleting(item)}
disabled={pending}
>
<Trash2 className="text-destructive" />
</Button>
</li>
))}
</ul>
<Dialog open={editing !== null} onOpenChange={(open) => !open && setEditing(null)}>
<DialogContent className="max-h-[85dvh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Sammlungseintrag bearbeiten</DialogTitle>
</DialogHeader>
<form id="collection-item-form" onSubmit={submit} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Status" htmlFor="status">
<NativeSelect id="status" name="status" defaultValue={editing?.status ?? 'owned'}>
{Object.entries(STATUS_LABELS).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</NativeSelect>
</Field>
<Field label="Vollständigkeit" htmlFor="completeness">
<NativeSelect
id="completeness"
name="completeness"
defaultValue={editing?.completeness ?? 'unknown'}
>
{Object.entries(COMPLETENESS_LABELS).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</NativeSelect>
</Field>
<Field label="Zustand" htmlFor="condition">
<NativeSelect id="condition" name="condition" defaultValue={editing?.condition ?? 'none'}>
<option value="none">– keine Angabe –</option>
{Object.entries(CONDITION_LABELS).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</NativeSelect>
</Field>
<Field label="Standort" htmlFor="locationId">
<NativeSelect
id="locationId"
name="locationId"
defaultValue={editing?.locationId ?? 'none'}
>
<option value="none">– kein Standort –</option>
{locations.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</NativeSelect>
</Field>
<Field label="Im Besitz seit" htmlFor="acquiredAt">
<Input
id="acquiredAt"
name="acquiredAt"
type="date"
defaultValue={toDateInputValue(editing?.acquiredAt)}
/>
</Field>
<Field label="Kaufpreis" htmlFor="acquiredPriceCents" hint="In Euro.">
<Input
id="acquiredPriceCents"
name="acquiredPriceCents"
inputMode="decimal"
defaultValue={centsToInput(editing?.acquiredPriceCents)}
/>
</Field>
<Field label="Gekauft bei" htmlFor="acquiredFrom">
<Input
id="acquiredFrom"
name="acquiredFrom"
defaultValue={editing?.acquiredFrom ?? ''}
maxLength={120}
/>
</Field>
<Field label="Geschenkt von" htmlFor="giftedBy">
<Input
id="giftedBy"
name="giftedBy"
defaultValue={editing?.giftedBy ?? ''}
maxLength={120}
/>
</Field>
<Field label="Verliehen an" htmlFor="lentOutTo">
<Input
id="lentOutTo"
name="lentOutTo"
defaultValue={editing?.lentOutTo ?? ''}
maxLength={120}
/>
</Field>
</div>
<Field label="Notizen" htmlFor="item-notes">
<Textarea id="item-notes" name="notes" defaultValue={editing?.notes ?? ''} rows={3} />
</Field>
</form>
<DialogFooter>
<Button variant="outline" onClick={() => setEditing(null)} disabled={pending}>
Abbrechen
</Button>
<Button type="submit" form="collection-item-form" disabled={pending}>
{pending ? <Loader2 className="animate-spin" /> : null}
Speichern
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog open={deleting !== null} onOpenChange={(open) => !open && setDeleting(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Eintrag entfernen?</AlertDialogTitle>
<AlertDialogDescription>
Das Set bleibt im Katalog, gilt danach aber wieder als „fehlt noch“.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Abbrechen</AlertDialogCancel>
<AlertDialogAction onClick={remove}>Entfernen</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
+16
View File
@@ -0,0 +1,16 @@
/** URL for a file stored under DATA_DIR/files. */
export function fileUrl(relativePath: string): string {
return `/api/files/${relativePath.split('/').map(encodeURIComponent).join('/')}`;
}
/**
* Picture for a set: a locally stored upload wins over an external URL, since
* it keeps working when the shop takes its image offline.
*/
export function setImageSrc(input: {
imagePath?: string | null;
imageUrl?: string | null;
}): string | null {
if (input.imagePath) return fileUrl(input.imagePath);
return input.imageUrl ?? null;
}
+48
View File
@@ -0,0 +1,48 @@
import type { CollectionStatus, Completeness, Condition, Occasion } from '@/db/types';
/** German labels for the enum values. The database keeps English keys. */
export const STATUS_LABELS: Record<CollectionStatus, string> = {
owned: 'Vorhanden',
built: 'Aufgebaut',
disassembled: 'Zerlegt',
lent_out: 'Verliehen',
sold: 'Verkauft',
};
export const COMPLETENESS_LABELS: Record<Completeness, string> = {
complete: 'Vollständig',
missing_parts: 'Teile fehlen',
unknown: 'Unbekannt',
};
export const CONDITION_LABELS: Record<Condition, string> = {
new_sealed: 'Neu und versiegelt',
like_new: 'Wie neu',
good: 'Gut',
used: 'Gebraucht',
damaged: 'Beschädigt',
};
export const OCCASION_LABELS: Record<Occasion, string> = {
christmas: 'Weihnachten',
birthday: 'Geburtstag',
easter: 'Ostern',
other: 'Anderer Anlass',
};
export function statusLabel(status: CollectionStatus): string {
return STATUS_LABELS[status];
}
export function completenessLabel(value: Completeness): string {
return COMPLETENESS_LABELS[value];
}
export function conditionLabel(value: Condition | null): string {
return value ? CONDITION_LABELS[value] : '–';
}
export function occasionLabel(value: Occasion): string {
return OCCASION_LABELS[value];
}
+75
View File
@@ -0,0 +1,75 @@
import { sql, type SQL } from 'drizzle-orm';
import type { CollectionStatus } from '@/db/types';
/**
* The single definition of "he has it".
*
* A catalog set counts as owned as soon as one collection item exists whose
* status is anything but `sold`. A sold copy is history, not ownership - so a
* set that was sold shows up under "fehlt" again and can go back on a wishlist.
*
* Every query that asks about ownership goes through this module. If the rule
* ever changes, it changes here.
*/
export const OWNED_STATUSES = ['owned', 'built', 'disassembled', 'lent_out'] as const;
export const SOLD_STATUS: CollectionStatus = 'sold';
export type OwnedStatus = (typeof OWNED_STATUSES)[number];
/** Does a single collection item count towards ownership? */
export function isOwningStatus(status: CollectionStatus): status is OwnedStatus {
return status !== SOLD_STATUS;
}
/**
* Does a set count as owned, given the statuses of all its collection items?
* An empty list means the set is only in the catalog.
*/
export function isSetOwned(statuses: readonly CollectionStatus[]): boolean {
return statuses.some(isOwningStatus);
}
/** How many copies count as owned. Several rows for one set are allowed. */
export function countOwnedCopies(statuses: readonly CollectionStatus[]): number {
return statuses.filter(isOwningStatus).length;
}
/**
* SQL counterpart of `countOwnedCopies`, as a correlated subquery.
*
* A subquery instead of a join, because a set owned twice must not appear
* twice in the catalog list.
*/
export function ownedCopiesSql(setIdColumn: SQL | unknown): SQL<number> {
return sql<number>`(
select count(*) from collection_item ci
where ci.set_id = ${setIdColumn} and ci.status <> ${SOLD_STATUS}
)`;
}
/** How many wishlists this set is currently on. */
export function wishlistCountSql(setIdColumn: SQL | unknown): SQL<number> {
return sql<number>`(
select count(*) from wishlist_item wi where wi.set_id = ${setIdColumn}
)`;
}
/** The four states the catalog filter offers, as they appear in the URL. */
export const OWNERSHIP_FILTERS = ['alle', 'sammlung', 'fehlt', 'wunschliste'] as const;
export type OwnershipFilter = (typeof OWNERSHIP_FILTERS)[number];
export function parseOwnershipFilter(value: string | undefined | null): OwnershipFilter {
return OWNERSHIP_FILTERS.includes(value as OwnershipFilter)
? (value as OwnershipFilter)
: 'alle';
}
export const OWNERSHIP_FILTER_LABELS: Record<OwnershipFilter, string> = {
alle: 'Alle Sets',
sammlung: 'In der Sammlung',
fehlt: 'Fehlt noch',
wunschliste: 'Auf einer Wunschliste',
};
+209
View File
@@ -0,0 +1,209 @@
import 'server-only';
import { and, asc, desc, eq, like, not, or, sql, type SQL } from 'drizzle-orm';
import { db } from '@/db';
import { collectionItem, instruction, manufacturer, photo, set, theme } from '@/db/schema';
import { ownedCopiesSql, wishlistCountSql, type OwnershipFilter } from '@/lib/ownership';
export const CATALOG_PAGE_SIZE = 48;
export type CatalogFilters = {
q?: string;
manufacturerId?: string;
themeId?: string;
year?: number;
ownership?: OwnershipFilter;
/** Hides sets that are already on this wishlist (for "aus fehlenden Sets hinzufügen"). */
excludeWishlistId?: string;
page?: number;
};
export type CatalogRow = {
id: string;
setNumber: string;
name: string;
year: number | null;
pieceCount: number | null;
imageUrl: string | null;
imagePath: string | null;
retailPriceCents: number | null;
manufacturerName: string;
themeName: string | null;
ownedCopies: number;
wishlistCount: number;
};
function buildWhere(filters: CatalogFilters): SQL | undefined {
const conditions: SQL[] = [];
const q = filters.q?.trim();
if (q) {
// Matching set number and name covers how people actually search: either
// they read the number off the box, or they remember the name.
const pattern = `%${q.toLowerCase()}%`;
const match = or(
like(sql`lower(${set.setNumber})`, pattern),
like(sql`lower(${set.name})`, pattern),
like(sql`lower(coalesce(${set.ean}, ''))`, pattern),
);
if (match) conditions.push(match);
}
if (filters.manufacturerId) conditions.push(eq(set.manufacturerId, filters.manufacturerId));
if (filters.themeId) conditions.push(eq(set.themeId, filters.themeId));
if (filters.year !== undefined) conditions.push(eq(set.year, filters.year));
const owned = ownedCopiesSql(set.id);
switch (filters.ownership) {
case 'sammlung':
conditions.push(sql`${owned} > 0`);
break;
case 'fehlt':
conditions.push(sql`${owned} = 0`);
break;
case 'wunschliste':
conditions.push(sql`${wishlistCountSql(set.id)} > 0`);
break;
default:
break;
}
if (filters.excludeWishlistId) {
conditions.push(
sql`not exists (
select 1 from wishlist_item wi
where wi.set_id = ${set.id} and wi.wishlist_id = ${filters.excludeWishlistId}
)`,
);
}
return conditions.length > 0 ? and(...conditions) : undefined;
}
export function listSets(filters: CatalogFilters): { rows: CatalogRow[]; total: number } {
const where = buildWhere(filters);
const page = Math.max(1, filters.page ?? 1);
const rows = db
.select({
id: set.id,
setNumber: set.setNumber,
name: set.name,
year: set.year,
pieceCount: set.pieceCount,
imageUrl: set.imageUrl,
imagePath: set.imagePath,
retailPriceCents: set.retailPriceCents,
manufacturerName: manufacturer.name,
themeName: theme.name,
ownedCopies: ownedCopiesSql(set.id).mapWith(Number),
wishlistCount: wishlistCountSql(set.id).mapWith(Number),
})
.from(set)
.innerJoin(manufacturer, eq(manufacturer.id, set.manufacturerId))
.leftJoin(theme, eq(theme.id, set.themeId))
.where(where)
.orderBy(asc(manufacturer.name), asc(set.setNumber))
.limit(CATALOG_PAGE_SIZE)
.offset((page - 1) * CATALOG_PAGE_SIZE)
.all();
const [totals] = db
.select({ total: sql<number>`count(*)`.mapWith(Number) })
.from(set)
.where(where)
.all();
return { rows, total: totals?.total ?? 0 };
}
/** All set ids matching the current filters - used by "alle auswählen". */
export function listSetIds(filters: CatalogFilters): string[] {
return db
.select({ id: set.id })
.from(set)
.where(buildWhere(filters))
.all()
.map((row) => row.id);
}
export function getSetById(id: string) {
const row = db
.select({
id: set.id,
manufacturerId: set.manufacturerId,
manufacturerName: manufacturer.name,
manufacturerWebsite: manufacturer.website,
themeId: set.themeId,
themeName: theme.name,
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,
createdAt: set.createdAt,
updatedAt: set.updatedAt,
ownedCopies: ownedCopiesSql(set.id).mapWith(Number),
wishlistCount: wishlistCountSql(set.id).mapWith(Number),
})
.from(set)
.innerJoin(manufacturer, eq(manufacturer.id, set.manufacturerId))
.leftJoin(theme, eq(theme.id, set.themeId))
.where(eq(set.id, id))
.get();
return row ?? null;
}
export function getSetByNumber(manufacturerId: string, setNumber: string) {
return (
db
.select({ id: set.id })
.from(set)
.where(and(eq(set.manufacturerId, manufacturerId), eq(set.setNumber, setNumber)))
.get() ?? null
);
}
export function listInstructionsForSet(setId: string) {
return db
.select()
.from(instruction)
.where(eq(instruction.setId, setId))
.orderBy(asc(instruction.sortOrder), asc(instruction.createdAt))
.all();
}
export function listPhotosForSet(setId: string) {
return db
.select()
.from(photo)
.where(eq(photo.setId, setId))
.orderBy(asc(photo.sortOrder), asc(photo.createdAt))
.all();
}
export function listCollectionItemsForSet(setId: string) {
return db
.select()
.from(collectionItem)
.where(eq(collectionItem.setId, setId))
.orderBy(desc(collectionItem.createdAt))
.all();
}
/** Distinct release years, newest first - fills the year filter. */
export function listSetYears(): number[] {
return db
.selectDistinct({ year: set.year })
.from(set)
.where(not(sql`${set.year} is null`))
.orderBy(desc(set.year))
.all()
.flatMap((row) => (row.year === null ? [] : [row.year]));
}
+161
View File
@@ -0,0 +1,161 @@
import 'server-only';
import { and, asc, count, eq, inArray, isNotNull, isNull, sql } from 'drizzle-orm';
import { db } from '@/db';
import { manufacturer, set, theme, wishlist, wishlistItem } from '@/db/schema';
import { ownedCopiesSql } from '@/lib/ownership';
/** Short list for the "auf welche Wunschliste?" dialogs. */
export function listWishlistTargets() {
return db
.select({
id: wishlist.id,
title: wishlist.title,
itemCount: count(wishlistItem.id),
})
.from(wishlist)
.leftJoin(wishlistItem, eq(wishlistItem.wishlistId, wishlist.id))
.groupBy(wishlist.id)
.orderBy(asc(wishlist.title))
.all();
}
export function listWishlists(ownerUserId: string) {
const openItems = sql<number>`(
select count(*) from wishlist_item wi
where wi.wishlist_id = ${wishlist.id} and wi.reserved_at is null and wi.fulfilled_at is null
)`.mapWith(Number);
return db
.select({
id: wishlist.id,
title: wishlist.title,
occasion: wishlist.occasion,
eventDate: wishlist.eventDate,
isPublic: wishlist.isPublic,
surpriseMode: wishlist.surpriseMode,
shareToken: wishlist.shareToken,
createdAt: wishlist.createdAt,
itemCount: count(wishlistItem.id),
openCount: openItems,
})
.from(wishlist)
.leftJoin(wishlistItem, eq(wishlistItem.wishlistId, wishlist.id))
.where(eq(wishlist.ownerUserId, ownerUserId))
.groupBy(wishlist.id)
.orderBy(asc(wishlist.title))
.all();
}
export function getWishlist(id: string) {
return db.select().from(wishlist).where(eq(wishlist.id, id)).get() ?? null;
}
export function getWishlistByShareToken(shareToken: string) {
return db.select().from(wishlist).where(eq(wishlist.shareToken, shareToken)).get() ?? null;
}
export type WishlistItemRow = {
id: string;
setId: string;
priority: number;
note: string | null;
sortOrder: number;
reservedByName: string | null;
reservedAt: Date | null;
fulfilledAt: Date | null;
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;
ownedCopies: number;
};
export function listWishlistItems(wishlistId: string): WishlistItemRow[] {
return db
.select({
id: wishlistItem.id,
setId: wishlistItem.setId,
priority: wishlistItem.priority,
note: wishlistItem.note,
sortOrder: wishlistItem.sortOrder,
reservedByName: wishlistItem.reservedByName,
reservedAt: wishlistItem.reservedAt,
fulfilledAt: wishlistItem.fulfilledAt,
setNumber: set.setNumber,
setName: set.name,
year: set.year,
pieceCount: set.pieceCount,
imageUrl: set.imageUrl,
imagePath: set.imagePath,
retailPriceCents: set.retailPriceCents,
manufacturerName: manufacturer.name,
manufacturerWebsite: manufacturer.website,
themeName: theme.name,
ownedCopies: ownedCopiesSql(set.id).mapWith(Number),
})
.from(wishlistItem)
.innerJoin(set, eq(set.id, wishlistItem.setId))
.innerJoin(manufacturer, eq(manufacturer.id, set.manufacturerId))
.leftJoin(theme, eq(theme.id, set.themeId))
.where(eq(wishlistItem.wishlistId, wishlistId))
.orderBy(asc(wishlistItem.sortOrder), asc(wishlistItem.priority), asc(set.name))
.all();
}
/** Which of these sets are already on the list - drives the duplicate skip. */
export function findExistingWishlistSetIds(wishlistId: string, setIds: string[]): Set<string> {
if (setIds.length === 0) return new Set();
const rows = db
.select({ setId: wishlistItem.setId })
.from(wishlistItem)
.where(and(eq(wishlistItem.wishlistId, wishlistId), inArray(wishlistItem.setId, setIds)))
.all();
return new Set(rows.map((row) => row.setId));
}
export function nextWishlistSortOrder(wishlistId: string): number {
const rows = db
.select({ sortOrder: wishlistItem.sortOrder })
.from(wishlistItem)
.where(eq(wishlistItem.wishlistId, wishlistId))
.all();
return rows.reduce((max, row) => Math.max(max, row.sortOrder), -1) + 1;
}
/** Wishlists a set appears on - shown on the set detail page. */
export function listWishlistsForSet(setId: string) {
return db
.select({ id: wishlist.id, title: wishlist.title, itemId: wishlistItem.id })
.from(wishlistItem)
.innerJoin(wishlist, eq(wishlist.id, wishlistItem.wishlistId))
.where(eq(wishlistItem.setId, setId))
.orderBy(asc(wishlist.title))
.all();
}
/**
* Marks wishlist entries as fulfilled once the set is in the collection, and
* clears the flag again if the copy was sold. Called after every change to the
* collection, so the lists stay honest without a background job.
*/
export function reconcileFulfilledWishlistItems(): void {
const owned = ownedCopiesSql(wishlistItem.setId);
db.update(wishlistItem)
.set({ fulfilledAt: new Date() })
.where(and(isNull(wishlistItem.fulfilledAt), sql`${owned} > 0`))
.run();
db.update(wishlistItem)
.set({ fulfilledAt: null })
.where(and(isNotNull(wishlistItem.fulfilledAt), sql`${owned} = 0`))
.run();
}
+35
View File
@@ -0,0 +1,35 @@
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
/**
* Tokens for the parts of the app that work without an account: the wishlist
* share link and the reservation cancel link.
*
* 32 random bytes, base64url encoded. That is 256 bits of entropy - guessing
* one is not a threat model, and the encoded form survives being pasted into
* a chat message without escaping.
*/
export const TOKEN_BYTES = 32;
export function createToken(): string {
return randomBytes(TOKEN_BYTES).toString('base64url');
}
/**
* Reservation and invite tokens are stored as a hash, so a database dump does
* not let anyone cancel someone else's reservation. The share token is stored
* in plain text on purpose - the owner has to be able to see it again.
*/
export function hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
/** Constant-time comparison of two hex hashes. */
export function tokensMatch(token: string, expectedHash: string | null | undefined): boolean {
if (!expectedHash) return false;
const actual = Buffer.from(hashToken(token), 'hex');
const expected = Buffer.from(expectedHash, 'hex');
if (actual.length !== expected.length) return false;
return timingSafeEqual(actual, expected);
}
+160
View File
@@ -0,0 +1,160 @@
import 'server-only';
import fs from 'node:fs/promises';
import path from 'node:path';
import sharp from 'sharp';
import { env } from '@/env';
/**
* Everything that writes into DATA_DIR/files.
*
* Rules that hold for every upload:
* - the file name is generated here, never taken from the client;
* - the type is verified by magic bytes, not by the declared MIME type;
* - the resulting path is checked to stay inside FILES_DIR.
*/
export const IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp'] as const;
export const PDF_MIME_TYPE = 'application/pdf';
export type UploadKind = 'set-image' | 'photo' | 'instruction';
export class UploadError extends Error {}
type Signature = { mime: string; test: (bytes: Uint8Array) => boolean };
const SIGNATURES: Signature[] = [
{
mime: 'application/pdf',
test: (b) => b[0] === 0x25 && b[1] === 0x50 && b[2] === 0x44 && b[3] === 0x46,
},
{
mime: 'image/jpeg',
test: (b) => b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff,
},
{
mime: 'image/png',
test: (b) =>
b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47 && b[4] === 0x0d &&
b[5] === 0x0a && b[6] === 0x1a && b[7] === 0x0a,
},
{
mime: 'image/webp',
test: (b) =>
b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x46 &&
b[8] === 0x57 && b[9] === 0x45 && b[10] === 0x42 && b[11] === 0x50,
},
];
/** Reads the real type from the first bytes. Returns null for anything else. */
export function detectMimeType(bytes: Uint8Array): string | null {
return SIGNATURES.find((signature) => signature.test(bytes))?.mime ?? null;
}
function assertSize(file: File) {
if (file.size === 0) throw new UploadError('Die Datei ist leer.');
if (file.size > env.MAX_UPLOAD_BYTES) {
throw new UploadError(
`Die Datei ist größer als ${env.MAX_UPLOAD_MB} MB. Größere Dateien sind nicht erlaubt.`,
);
}
}
/** Absolute path for a path stored in the database, with traversal check. */
export function resolveFilePath(relativePath: string): string {
const absolute = path.resolve(env.FILES_DIR, relativePath);
const root = path.resolve(env.FILES_DIR);
if (absolute !== root && !absolute.startsWith(root + path.sep)) {
throw new UploadError('Ungültiger Dateipfad.');
}
return absolute;
}
async function writeFile(relativePath: string, data: Buffer): Promise<void> {
const absolute = resolveFilePath(relativePath);
await fs.mkdir(path.dirname(absolute), { recursive: true });
await fs.writeFile(absolute, data);
}
export type StoredImage = {
/** Path relative to FILES_DIR, as stored in the database. */
filePath: string;
thumbnailPath: string;
};
/**
* Normalises an uploaded image to WebP plus a thumbnail. Large photos from a
* phone camera shrink from several MB to a few hundred KB, which matters on a
* self-hosted box with a small disk.
*/
export async function storeImage(file: File, kind: UploadKind, ownerId: string): Promise<StoredImage> {
assertSize(file);
const bytes = Buffer.from(await file.arrayBuffer());
const mime = detectMimeType(bytes);
if (!mime || !IMAGE_MIME_TYPES.includes(mime as (typeof IMAGE_MIME_TYPES)[number])) {
throw new UploadError('Erlaubt sind nur JPEG-, PNG- und WebP-Bilder.');
}
const id = crypto.randomUUID();
const folder = kind === 'set-image' ? `images/sets/${ownerId}` : `photos/${ownerId}`;
const filePath = `${folder}/${id}.webp`;
const thumbnailPath = `${folder}/${id}.thumb.webp`;
const [full, thumbnail] = await Promise.all([
sharp(bytes).rotate().resize(1600, 1600, { fit: 'inside', withoutEnlargement: true }).webp({ quality: 82 }).toBuffer(),
sharp(bytes).rotate().resize(400, 400, { fit: 'inside', withoutEnlargement: true }).webp({ quality: 70 }).toBuffer(),
]);
await writeFile(filePath, full);
await writeFile(thumbnailPath, thumbnail);
return { filePath, thumbnailPath };
}
export type StoredPdf = {
filePath: string;
fileSizeBytes: number;
};
export async function storePdf(file: File, setId: string): Promise<StoredPdf> {
assertSize(file);
const bytes = Buffer.from(await file.arrayBuffer());
if (detectMimeType(bytes) !== PDF_MIME_TYPE) {
throw new UploadError('Anleitungen müssen PDF-Dateien sein.');
}
const filePath = `instructions/${setId}/${crypto.randomUUID()}.pdf`;
await writeFile(filePath, bytes);
return { filePath, fileSizeBytes: bytes.byteLength };
}
/** Removes a stored file. Missing files are not an error - the row is going away anyway. */
export async function deleteStoredFile(relativePath: string | null | undefined): Promise<void> {
if (!relativePath) return;
try {
await fs.unlink(resolveFilePath(relativePath));
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
console.error('[uploads] Datei konnte nicht gelöscht werden', relativePath, error);
}
}
}
/** Files below images/ are readable without a session (see CLAUDE.md). */
export function isPublicFilePath(relativePath: string): boolean {
return relativePath.startsWith('images/');
}
const CONTENT_TYPES: Record<string, string> = {
'.webp': 'image/webp',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.pdf': 'application/pdf',
};
export function contentTypeFor(relativePath: string): string {
return CONTENT_TYPES[path.extname(relativePath).toLowerCase()] ?? 'application/octet-stream';
}
+77
View File
@@ -0,0 +1,77 @@
/**
* Pure rules for putting sets on a wishlist. Kept free of database access so
* they can be unit tested directly - they are the part that is easy to get
* subtly wrong.
*/
export type BulkAddPlan = {
/** Sets that will be inserted. */
toAdd: string[];
/** Already on this list - skipped, and named in the result toast. */
duplicates: string[];
/** Already in the collection - a wish for something he has makes no sense. */
owned: string[];
};
/**
* Splits a selection into the sets that actually get added and the two kinds
* of leftovers. Input order is preserved and repeated ids collapse, so a
* double tap in the selection UI cannot insert a set twice.
*/
export function planBulkAdd(
selectedSetIds: readonly string[],
existingSetIds: ReadonlySet<string>,
ownedSetIds: ReadonlySet<string> = new Set(),
): BulkAddPlan {
const seen = new Set<string>();
const plan: BulkAddPlan = { toAdd: [], duplicates: [], owned: [] };
for (const setId of selectedSetIds) {
if (seen.has(setId)) continue;
seen.add(setId);
if (existingSetIds.has(setId)) plan.duplicates.push(setId);
else if (ownedSetIds.has(setId)) plan.owned.push(setId);
else plan.toAdd.push(setId);
}
return plan;
}
/** German summary for the toast after a bulk add. */
export function describeBulkAdd(plan: BulkAddPlan, names: Map<string, string>): string {
const parts: string[] = [];
parts.push(
plan.toAdd.length === 1
? '1 Set hinzugefügt'
: `${plan.toAdd.length} Sets hinzugefügt`,
);
if (plan.duplicates.length > 0) {
parts.push(`${plan.duplicates.length} übersprungen (schon auf der Liste): ${nameList(plan.duplicates, names)}`);
}
if (plan.owned.length > 0) {
parts.push(`${plan.owned.length} übersprungen (schon in der Sammlung): ${nameList(plan.owned, names)}`);
}
return parts.join(' · ');
}
/** Lists up to three names and summarises the rest. */
function nameList(ids: readonly string[], names: Map<string, string>): string {
const labels = ids.map((id) => names.get(id) ?? id);
if (labels.length <= 3) return labels.join(', ');
return `${labels.slice(0, 3).join(', ')} und ${labels.length - 3} weitere`;
}
/** 1 = highest. Used for badges and sorting. */
export const PRIORITY_LABELS: Record<number, string> = {
1: 'Großer Wunsch',
2: 'Gerne',
3: 'Irgendwann',
};
export function priorityLabel(priority: number): string {
return PRIORITY_LABELS[priority] ?? PRIORITY_LABELS[2]!;
}