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