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
+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();
}