From 856800b4e48a0c9d27355574cb8c31f384981be5 Mon Sep 17 00:00:00 2001 From: unknown Date: Sun, 6 Sep 2026 22:59:26 +0200 Subject: [PATCH] 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) --- package.json | 1 + pnpm-lock.yaml | 134 ++++++ scripts/seed.ts | 398 ++++++++++++++++++ .../(app)/katalog/[id]/bearbeiten/page.tsx | 58 +++ src/app/(app)/katalog/[id]/page.tsx | 192 +++++++++ src/app/(app)/katalog/actions.ts | 291 +++++++++++++ src/app/(app)/katalog/neu/page.tsx | 51 +++ src/app/(app)/katalog/page.tsx | 83 ++++ src/app/(app)/sammlung/actions.ts | 105 +++++ src/app/(app)/wunschlisten/actions.ts | 254 +++++++++++ src/app/api/files/[...path]/route.ts | 55 +++ src/components/katalog/catalog-filters.tsx | 148 +++++++ src/components/katalog/catalog-grid.tsx | 306 ++++++++++++++ .../katalog/instructions-section.tsx | 215 ++++++++++ src/components/katalog/pagination.tsx | 54 +++ src/components/katalog/pdf-viewer.tsx | 155 +++++++ src/components/katalog/photos-section.tsx | 138 ++++++ src/components/katalog/set-actions.tsx | 216 ++++++++++ src/components/katalog/set-card.tsx | 88 ++++ src/components/katalog/set-form.tsx | 248 +++++++++++ .../sammlung/collection-items-section.tsx | 302 +++++++++++++ src/lib/images.ts | 16 + src/lib/labels.ts | 48 +++ src/lib/ownership.ts | 75 ++++ src/lib/queries/catalog.ts | 209 +++++++++ src/lib/queries/wishlists.ts | 161 +++++++ src/lib/tokens.ts | 35 ++ src/lib/uploads.ts | 160 +++++++ src/lib/wishlist-logic.ts | 77 ++++ 29 files changed, 4273 insertions(+) create mode 100644 scripts/seed.ts create mode 100644 src/app/(app)/katalog/[id]/bearbeiten/page.tsx create mode 100644 src/app/(app)/katalog/[id]/page.tsx create mode 100644 src/app/(app)/katalog/actions.ts create mode 100644 src/app/(app)/katalog/neu/page.tsx create mode 100644 src/app/(app)/katalog/page.tsx create mode 100644 src/app/(app)/sammlung/actions.ts create mode 100644 src/app/(app)/wunschlisten/actions.ts create mode 100644 src/app/api/files/[...path]/route.ts create mode 100644 src/components/katalog/catalog-filters.tsx create mode 100644 src/components/katalog/catalog-grid.tsx create mode 100644 src/components/katalog/instructions-section.tsx create mode 100644 src/components/katalog/pagination.tsx create mode 100644 src/components/katalog/pdf-viewer.tsx create mode 100644 src/components/katalog/photos-section.tsx create mode 100644 src/components/katalog/set-actions.tsx create mode 100644 src/components/katalog/set-card.tsx create mode 100644 src/components/katalog/set-form.tsx create mode 100644 src/components/sammlung/collection-items-section.tsx create mode 100644 src/lib/images.ts create mode 100644 src/lib/labels.ts create mode 100644 src/lib/ownership.ts create mode 100644 src/lib/queries/catalog.ts create mode 100644 src/lib/queries/wishlists.ts create mode 100644 src/lib/tokens.ts create mode 100644 src/lib/uploads.ts create mode 100644 src/lib/wishlist-logic.ts diff --git a/package.json b/package.json index 4cc0ebf..4a9c250 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "lucide-react": "^1.41.0", "next": "15.5.25", "next-themes": "^0.4.6", + "pdfjs-dist": "^6.3.289", "radix-ui": "^1.6.7", "react": "19.1.0", "react-dom": "19.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d200f5d..d2cf62c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + pdfjs-dist: + specifier: ^6.3.289 + version: 6.3.289 radix-ui: specifier: ^1.6.7 version: 1.6.7(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -1086,6 +1089,81 @@ packages: '@cfworker/json-schema': optional: true + '@napi-rs/canvas-android-arm64@1.0.8': + resolution: {integrity: sha512-5+nkh8i3gt6lqS/d2jTZ1xAn6tdgtB4Lf1mW6T0Qm5/rXNwBuV1sAEyLEWan5o9gJPU/GuvHR3rvSeZ+FaGrbw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@napi-rs/canvas-darwin-arm64@1.0.8': + resolution: {integrity: sha512-7jQ47gi+fZ7KJmfc/5rNyy1CYw/cu4kZ0KPIYbo9UUgSdW0bKQJpt+WihEor6s4Lyp7+xc3a+3HeyXmAEbbnPg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/canvas-darwin-x64@1.0.8': + resolution: {integrity: sha512-rRjDMZs9pIRKGxgijwezplKc1RnJsqUokrA9h88bbTkqQ+7ePj0ZN4ZnZDy8Vu0tXs7KRlI2tQLaK4mx9QlxHg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/canvas-linux-arm-gnueabihf@1.0.8': + resolution: {integrity: sha512-jGcCd+8ra6Q61xKqZeiItujTpp9a9eRLcQ0jW6qYNku+WpupqOPFPY0SrsuSnXFviJwkpKYT9p7QrB4lsf3LNQ==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/canvas-linux-arm64-gnu@1.0.8': + resolution: {integrity: sha512-od6I2Y7kU7i1SwZYG2EKW8rWz6JiedtPpko4WEe1DDsiikrfaotVBCRaUTM5/yeZKaZ92EatoAS+5xG+6uJlYA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@napi-rs/canvas-linux-arm64-musl@1.0.8': + resolution: {integrity: sha512-yYkPbJDJiWj6N0gASA3CAvRypZmVpJnxU0DQg3aBhneLDQde9TPLKADsQkobNoJUtTT/lj46aWpzT48PDb3Qcg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@napi-rs/canvas-linux-riscv64-gnu@1.0.8': + resolution: {integrity: sha512-PB00MSKAp4VwK/xwe6duKxRKmH8UH4GIl1pqHSbxng0jnU9Dr7FwaDypDiqwNFZ774N+8G7mJLGuLtg9NTcQsg==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@napi-rs/canvas-linux-x64-gnu@1.0.8': + resolution: {integrity: sha512-TWM2XWJoitLiIPCvgJh7SriC+L/T9qkYCVzC66AidsZy0QP1hkKzBzVwshCdcA3q6fIn3yE0ISbq4lMJSy8jFw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@napi-rs/canvas-linux-x64-musl@1.0.8': + resolution: {integrity: sha512-hb20MxKXXb5IB7AAwN8UHz9WRsa2HmdZfjsDCzjElwJoeV1aotVEwFU4FrFQcYQVzsJQLeaCc/2Qdt/0Q72mMg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@napi-rs/canvas-win32-arm64-msvc@1.0.8': + resolution: {integrity: sha512-WwPN08IXE4SkL+FhJyPz/iFnycMAUkbphFIT4cmKLlvbSU0Zfn1R7BGJ3Hqky1S89QUYc0Q4IOScXb/42Re9wQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@napi-rs/canvas-win32-x64-msvc@1.0.8': + resolution: {integrity: sha512-XkrVqKb+pxyba7kjy2LJvABFVBTE0DNpEl7MrG4OYUmaWarrXH+t54z/Czj2YxCKtizYTV4mg6phNm3x24qjhQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/canvas@1.0.8': + resolution: {integrity: sha512-/SaLcvlqGWdm0HSCWMgHu7cjJiQXfP8/mOY+6dUyV9flQz7sPBBZ+ed2zYtoukojPmxOaL7bm+d/G4GeWWoN7g==} + engines: {node: '>= 10'} + '@napi-rs/wasm-runtime@1.2.3': resolution: {integrity: sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==} engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} @@ -4174,6 +4252,10 @@ packages: path-to-regexp@8.4.2: resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + pdfjs-dist@6.3.289: + resolution: {integrity: sha512-ZHjSVpDa3D6izMq8/04lvkhkATUmL9px6ChPaXc1k6nU2Mrhlg1/7F0bdUqCwUjw3NsPTfPZsMDUU6ZIcRaeQw==} + engines: {node: '>=22.13.0 || >=24'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -5704,6 +5786,54 @@ snapshots: transitivePeerDependencies: - supports-color + '@napi-rs/canvas-android-arm64@1.0.8': + optional: true + + '@napi-rs/canvas-darwin-arm64@1.0.8': + optional: true + + '@napi-rs/canvas-darwin-x64@1.0.8': + optional: true + + '@napi-rs/canvas-linux-arm-gnueabihf@1.0.8': + optional: true + + '@napi-rs/canvas-linux-arm64-gnu@1.0.8': + optional: true + + '@napi-rs/canvas-linux-arm64-musl@1.0.8': + optional: true + + '@napi-rs/canvas-linux-riscv64-gnu@1.0.8': + optional: true + + '@napi-rs/canvas-linux-x64-gnu@1.0.8': + optional: true + + '@napi-rs/canvas-linux-x64-musl@1.0.8': + optional: true + + '@napi-rs/canvas-win32-arm64-msvc@1.0.8': + optional: true + + '@napi-rs/canvas-win32-x64-msvc@1.0.8': + optional: true + + '@napi-rs/canvas@1.0.8': + optionalDependencies: + '@napi-rs/canvas-android-arm64': 1.0.8 + '@napi-rs/canvas-darwin-arm64': 1.0.8 + '@napi-rs/canvas-darwin-x64': 1.0.8 + '@napi-rs/canvas-linux-arm-gnueabihf': 1.0.8 + '@napi-rs/canvas-linux-arm64-gnu': 1.0.8 + '@napi-rs/canvas-linux-arm64-musl': 1.0.8 + '@napi-rs/canvas-linux-riscv64-gnu': 1.0.8 + '@napi-rs/canvas-linux-x64-gnu': 1.0.8 + '@napi-rs/canvas-linux-x64-musl': 1.0.8 + '@napi-rs/canvas-win32-arm64-msvc': 1.0.8 + '@napi-rs/canvas-win32-x64-msvc': 1.0.8 + optional: true + '@napi-rs/wasm-runtime@1.2.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 @@ -8788,6 +8918,10 @@ snapshots: path-to-regexp@8.4.2: {} + pdfjs-dist@6.3.289: + optionalDependencies: + '@napi-rs/canvas': 1.0.8 + picocolors@1.1.1: {} picomatch@2.3.2: {} diff --git a/scripts/seed.ts b/scripts/seed.ts new file mode 100644 index 0000000..fa10c3b --- /dev/null +++ b/scripts/seed.ts @@ -0,0 +1,398 @@ +/** + * Example data so a fresh instance is not an empty page. + * + * Safe to run more than once: everything is matched by its natural key + * (manufacturer slug, set number) and only inserted when missing. + * + * Run with `pnpm db:seed` after `pnpm db:migrate`. + */ +import path from 'node:path'; +import Database from 'better-sqlite3'; +import { config as loadEnv } from 'dotenv'; +import { eq } from 'drizzle-orm'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import * as schema from '../src/db/schema'; + +loadEnv({ path: '.env', quiet: true }); + +const dataDir = path.resolve(process.env.DATA_DIR ?? './data'); +const sqlite = new Database(path.join(dataDir, 'db', 'bricktrack.db')); +sqlite.pragma('foreign_keys = ON'); +const db = drizzle(sqlite, { schema }); + +const now = new Date(); +const id = () => crypto.randomUUID(); + +type SeedSet = { + manufacturer: string; + theme: string; + setNumber: string; + name: string; + year: number; + pieceCount: number; + minifigCount: number | null; + retailPriceCents: number; + ageRange: string; + /** Copies owned - 0 means the set is only in the catalog. */ + owned?: { + status: 'owned' | 'built' | 'disassembled' | 'lent_out' | 'sold'; + completeness: 'complete' | 'missing_parts' | 'unknown'; + location: string; + giftedBy?: string; + }; +}; + +const MANUFACTURERS = [ + { name: 'Lego', slug: 'lego', website: 'https://www.lego.com/de-de' }, + { name: 'Playmobil', slug: 'playmobil', website: 'https://www.playmobil.de' }, + { name: 'Cobi', slug: 'cobi', website: 'https://cobi.de' }, +]; + +const THEMES: Array<{ manufacturer: string; name: string }> = [ + { manufacturer: 'Lego', name: 'City' }, + { manufacturer: 'Lego', name: 'Technic' }, + { manufacturer: 'Lego', name: 'Ninjago' }, + { manufacturer: 'Playmobil', name: 'Novelmore' }, + { manufacturer: 'Playmobil', name: 'City Action' }, + { manufacturer: 'Cobi', name: 'Historical Collection' }, +]; + +const LOCATIONS = ['Regal Kinderzimmer', 'Kiste Keller 3', 'Vitrine Wohnzimmer']; + +const SETS: SeedSet[] = [ + { + manufacturer: 'Lego', + theme: 'City', + setNumber: '60398', + name: 'Familienhaus und Elektroauto', + year: 2023, + pieceCount: 462, + minifigCount: 4, + retailPriceCents: 4999, + ageRange: '6-12', + owned: { status: 'built', completeness: 'complete', location: 'Regal Kinderzimmer' }, + }, + { + manufacturer: 'Lego', + theme: 'City', + setNumber: '60321', + name: 'Feuerwehreinsatz', + year: 2022, + pieceCount: 766, + minifigCount: 6, + retailPriceCents: 7999, + ageRange: '6-12', + owned: { + status: 'owned', + completeness: 'missing_parts', + location: 'Kiste Keller 3', + giftedBy: 'Oma Helga', + }, + }, + { + manufacturer: 'Lego', + theme: 'Technic', + setNumber: '42147', + name: 'Kipplaster', + year: 2022, + pieceCount: 177, + minifigCount: null, + retailPriceCents: 999, + ageRange: '7-14', + owned: { status: 'disassembled', completeness: 'unknown', location: 'Kiste Keller 3' }, + }, + { + manufacturer: 'Lego', + theme: 'Technic', + setNumber: '42172', + name: 'McLaren P1', + year: 2024, + pieceCount: 3893, + minifigCount: null, + retailPriceCents: 44999, + ageRange: '18+', + }, + { + manufacturer: 'Lego', + theme: 'Ninjago', + setNumber: '71427', + name: 'Lloyds Elementardrachen', + year: 2023, + pieceCount: 685, + minifigCount: 4, + retailPriceCents: 5999, + ageRange: '8-14', + }, + { + manufacturer: 'Lego', + theme: 'Ninjago', + setNumber: '71799', + name: 'Ninjago City Märkte', + year: 2023, + pieceCount: 6163, + minifigCount: 21, + retailPriceCents: 39999, + ageRange: '18+', + }, + { + manufacturer: 'Playmobil', + theme: 'Novelmore', + setNumber: '70392', + name: 'Novelmore Festung', + year: 2020, + pieceCount: 293, + minifigCount: 5, + retailPriceCents: 12999, + ageRange: '4-10', + owned: { status: 'built', completeness: 'complete', location: 'Regal Kinderzimmer' }, + }, + { + manufacturer: 'Playmobil', + theme: 'City Action', + setNumber: '70936', + name: 'Feuerwehr-Fahrzeug Ultimate Rescue Truck', + year: 2021, + pieceCount: 178, + minifigCount: 2, + retailPriceCents: 8999, + ageRange: '5-10', + }, + { + manufacturer: 'Cobi', + theme: 'Historical Collection', + setNumber: '2-30500', + name: 'Ford Mustang GT', + year: 2022, + pieceCount: 250, + minifigCount: null, + retailPriceCents: 2999, + ageRange: '8+', + }, + { + manufacturer: 'Cobi', + theme: 'Historical Collection', + setNumber: '2-30501', + name: 'Feuerwehrauto Magirus', + year: 2023, + pieceCount: 400, + minifigCount: 1, + retailPriceCents: 3999, + ageRange: '8+', + }, +]; + +function seedManufacturers() { + const byName = new Map(); + + for (const entry of MANUFACTURERS) { + const existing = db + .select({ id: schema.manufacturer.id }) + .from(schema.manufacturer) + .where(eq(schema.manufacturer.slug, entry.slug)) + .get(); + + if (existing) { + byName.set(entry.name, existing.id); + continue; + } + + const newId = id(); + db.insert(schema.manufacturer) + .values({ id: newId, ...entry, createdAt: now, updatedAt: now }) + .run(); + byName.set(entry.name, newId); + } + + return byName; +} + +function seedThemes(manufacturers: Map) { + const byKey = new Map(); + + for (const entry of THEMES) { + const manufacturerId = manufacturers.get(entry.manufacturer); + if (!manufacturerId) continue; + + const existing = db + .select({ id: schema.theme.id, name: schema.theme.name }) + .from(schema.theme) + .where(eq(schema.theme.manufacturerId, manufacturerId)) + .all() + .find((row) => row.name === entry.name); + + if (existing) { + byKey.set(`${entry.manufacturer}/${entry.name}`, existing.id); + continue; + } + + const newId = id(); + db.insert(schema.theme) + .values({ + id: newId, + manufacturerId, + name: entry.name, + createdAt: now, + updatedAt: now, + }) + .run(); + byKey.set(`${entry.manufacturer}/${entry.name}`, newId); + } + + return byKey; +} + +function seedLocations() { + const byName = new Map(); + + for (const name of LOCATIONS) { + const existing = db + .select({ id: schema.location.id }) + .from(schema.location) + .where(eq(schema.location.name, name)) + .get(); + + if (existing) { + byName.set(name, existing.id); + continue; + } + + const newId = id(); + db.insert(schema.location) + .values({ id: newId, name, createdAt: now, updatedAt: now }) + .run(); + byName.set(name, newId); + } + + return byName; +} + +function seedSets( + manufacturers: Map, + themes: Map, + locations: Map, +) { + const setIds = new Map(); + + for (const entry of SETS) { + const manufacturerId = manufacturers.get(entry.manufacturer); + if (!manufacturerId) continue; + + const existing = db + .select({ id: schema.set.id, setNumber: schema.set.setNumber }) + .from(schema.set) + .where(eq(schema.set.manufacturerId, manufacturerId)) + .all() + .find((row) => row.setNumber === entry.setNumber); + + if (existing) { + setIds.set(entry.setNumber, existing.id); + continue; + } + + const newId = id(); + db.insert(schema.set) + .values({ + id: newId, + manufacturerId, + themeId: themes.get(`${entry.manufacturer}/${entry.theme}`) ?? null, + setNumber: entry.setNumber, + name: entry.name, + year: entry.year, + pieceCount: entry.pieceCount, + minifigCount: entry.minifigCount, + ageRange: entry.ageRange, + retailPriceCents: entry.retailPriceCents, + createdAt: now, + updatedAt: now, + }) + .run(); + setIds.set(entry.setNumber, newId); + + if (entry.owned) { + db.insert(schema.collectionItem) + .values({ + id: id(), + setId: newId, + status: entry.owned.status, + completeness: entry.owned.completeness, + condition: 'good', + locationId: locations.get(entry.owned.location) ?? null, + acquiredAt: new Date(entry.year, 11, 24), + acquiredPriceCents: entry.retailPriceCents, + giftedBy: entry.owned.giftedBy ?? null, + createdAt: now, + updatedAt: now, + }) + .run(); + } + } + + return setIds; +} + +function seedWishlist(setIds: Map) { + const owner = db.select({ id: schema.user.id }).from(schema.user).get(); + if (!owner) { + console.log('[seed] Kein Nutzer vorhanden - die Beispiel-Wunschliste wird uebersprungen.'); + console.log('[seed] Registriere dich zuerst in der App und starte den Seed danach erneut.'); + return; + } + + const existing = db + .select({ id: schema.wishlist.id }) + .from(schema.wishlist) + .all() + .find((row) => row.id.length > 0); + if (existing) { + console.log('[seed] Es gibt bereits eine Wunschliste - unveraendert gelassen.'); + return; + } + + const wishlistId = id(); + db.insert(schema.wishlist) + .values({ + id: wishlistId, + ownerUserId: owner.id, + title: 'Weihnachten 2026', + occasion: 'christmas', + eventDate: new Date(2026, 11, 24), + shareToken: Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url'), + isPublic: true, + surpriseMode: false, + note: 'Bitte keine Doppelungen - was reserviert ist, steht schon fest.', + createdAt: now, + updatedAt: now, + }) + .run(); + + const wanted = ['71427', '42172', '70936']; + wanted.forEach((setNumber, index) => { + const setId = setIds.get(setNumber); + if (!setId) return; + + db.insert(schema.wishlistItem) + .values({ + id: id(), + wishlistId, + setId, + priority: index === 0 ? 1 : 2, + sortOrder: index, + createdAt: now, + }) + .onConflictDoNothing() + .run(); + }); +} + +console.log('[seed] Beispieldaten werden eingespielt …'); + +const manufacturers = seedManufacturers(); +const themes = seedThemes(manufacturers); +const locations = seedLocations(); +const setIds = seedSets(manufacturers, themes, locations); +seedWishlist(setIds); + +console.log( + `[seed] Fertig: ${manufacturers.size} Hersteller, ${themes.size} Themen, ${setIds.size} Sets.`, +); +sqlite.close(); diff --git a/src/app/(app)/katalog/[id]/bearbeiten/page.tsx b/src/app/(app)/katalog/[id]/bearbeiten/page.tsx new file mode 100644 index 0000000..d142bc7 --- /dev/null +++ b/src/app/(app)/katalog/[id]/bearbeiten/page.tsx @@ -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 ( +
+
+

Set bearbeiten

+

+ {set.setNumber} · {set.name} +

+
+ +
+ ); +} diff --git a/src/app/(app)/katalog/[id]/page.tsx b/src/app/(app)/katalog/[id]/page.tsx new file mode 100644 index 0000000..769ffb0 --- /dev/null +++ b/src/app/(app)/katalog/[id]/page.tsx @@ -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 { + 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 ( +
+
+
+
+ + {set.setNumber} + + {set.manufacturerName} + {owned ? ( + + + {set.ownedCopies > 1 ? `${set.ownedCopies}× vorhanden` : 'Vorhanden'} + + ) : ( + Fehlt noch + )} + {set.wishlistCount > 0 ? ( + + + Auf Wunschliste + + ) : null} +
+

{set.name}

+
+ +
+ +
+
+ {image ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( +
+ +
+ )} +
+ +
+ + +
+ {facts.map(([label, value]) => ( +
+
{label}
+
{value}
+
+ ))} +
+ + {set.manufacturerWebsite ? ( + + ) : null} + + {set.notes ? ( +

{set.notes}

+ ) : null} +
+
+ + + + In der Sammlung + + + + + + + + + Anleitungen + + + + + + + + + Fotos + + + + + + + +
+ ); +} + +function WishlistMemberships({ setId }: { setId: string }) { + const lists = listWishlistsForSet(setId); + if (lists.length === 0) return null; + + return ( + + + Auf diesen Wunschlisten + + + {lists.map((list) => ( + + ))} + + + ); +} diff --git a/src/app/(app)/katalog/actions.ts b/src/app/(app)/katalog/actions.ts new file mode 100644 index 0000000..82c180d --- /dev/null +++ b/src/app/(app)/katalog/actions.ts @@ -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> { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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(); +} diff --git a/src/app/(app)/katalog/neu/page.tsx b/src/app/(app)/katalog/neu/page.tsx new file mode 100644 index 0000000..07b544a --- /dev/null +++ b/src/app/(app)/katalog/neu/page.tsx @@ -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>; + +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 ( +
+

Neues Set

+

+ Jedes Set gehört zu einem Hersteller. Leg zuerst einen an – Lego, Playmobil, Cobi oder + was auch immer im Kinderzimmer steht. +

+ +
+ ); + } + + return ( +
+
+

Neues Set

+

+ Ein Set im Katalog heißt noch nicht, dass es da ist – das legst du danach über „Zur + Sammlung hinzufügen“ fest. +

+
+ +
+ ); +} diff --git a/src/app/(app)/katalog/page.tsx b/src/app/(app)/katalog/page.tsx new file mode 100644 index 0000000..1098964 --- /dev/null +++ b/src/app/(app)/katalog/page.tsx @@ -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>; + +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 ( +
+
+
+

+ {ownership === 'fehlt' ? 'Fehlende Sets' : 'Katalog'} +

+

+ {ownership === 'fehlt' + ? 'Sets, die noch nicht in der Sammlung sind.' + : 'Alle bekannten Sets – unabhängig davon, ob sie schon da sind.'} +

+
+ +
+ + + +

+ {pluralize(total, 'Set', 'Sets')} + {ownership !== 'alle' ? ` · ${OWNERSHIP_FILTER_LABELS[ownership]}` : ''} +

+ + + + +
+ ); +} diff --git a/src/app/(app)/sammlung/actions.ts b/src/app/(app)/sammlung/actions.ts new file mode 100644 index 0000000..3c1f5c5 --- /dev/null +++ b/src/app/(app)/sammlung/actions.ts @@ -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> { + 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 { + 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 { + 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(); +} diff --git a/src/app/(app)/wunschlisten/actions.ts b/src/app/(app)/wunschlisten/actions.ts new file mode 100644 index 0000000..f71d08f --- /dev/null +++ b/src/app/(app)/wunschlisten/actions.ts @@ -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> { + 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 { + 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 { + 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> { + 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> { + 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 { + 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 { + 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 { + 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(); +} diff --git a/src/app/api/files/[...path]/route.ts b/src/app/api/files/[...path]/route.ts new file mode 100644 index 0000000..bbe4d51 --- /dev/null +++ b/src/app/api/files/[...path]/route.ts @@ -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 }); + } +} diff --git a/src/components/katalog/catalog-filters.tsx b/src/components/katalog/catalog-filters.tsx new file mode 100644 index 0000000..790dbb4 --- /dev/null +++ b/src/components/katalog/catalog-filters.tsx @@ -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) { + 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 ( +
+
+ + setQuery(event.target.value)} + placeholder="Setnummer, Name oder EAN" + aria-label="Sets durchsuchen" + className="pl-9" + /> + {pending ? ( + + ) : null} +
+ +
+ apply({ besitz: event.target.value })} + > + {OWNERSHIP_FILTERS.map((value) => ( + + ))} + + + apply({ hersteller: event.target.value || null, thema: null })} + > + + {options.manufacturers.map((manufacturer) => ( + + ))} + + + apply({ thema: event.target.value || null })} + > + + {visibleThemes.map((theme) => ( + + ))} + + + apply({ jahr: event.target.value || null })} + > + + {options.years.map((value) => ( + + ))} + +
+ + {hasFilters ? ( + + ) : null} +
+ ); +} diff --git a/src/components/katalog/catalog-grid.tsx b/src/components/katalog/catalog-grid.tsx new file mode 100644 index 0000000..d64e288 --- /dev/null +++ b/src/components/katalog/catalog-grid.tsx @@ -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>(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 | 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 ( +
+ {emptyState ?? 'Keine Sets gefunden. Passe die Filter an oder leg ein neues Set an.'} +
+ ); + } + + return ( +
+
+

+ {selecting + ? 'Tippe die Sets an, die auf die Wunschliste sollen.' + : 'Tipp: Kachel lange drücken, um mehrere Sets auszuwählen.'} +

+ {selecting ? ( + + ) : ( + + )} +
+ +
+ {sets.map((item) => { + const owned = item.ownedCopies > 0; + const isSelected = selected.has(item.id); + + if (!selecting) { + return ( +
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; + } + }} + > + +
+ ); + } + + return ( + + ); + })} +
+ + {selecting ? ( +
+
+ + {selected.size} ausgewählt + +
+ +
+
+ ) : null} + + + + + + {selected.size === 1 + ? '1 Set auf die Wunschliste' + : `${selected.size} Sets auf die Wunschliste`} + + + Sets, die schon auf der Liste oder bereits in der Sammlung sind, werden + übersprungen. + + + +
+ + setTargetId(event.target.value)} + > + {wishlists.map((list) => ( + + ))} + + + + + {targetId === NEW_LIST ? ( + + setNewTitle(event.target.value)} + placeholder="Weihnachten 2026" + maxLength={80} + /> + + ) : null} + + + setPriority(event.target.value)} + > + {Object.entries(PRIORITY_LABELS).map(([value, label]) => ( + + ))} + + +
+ + + + + +
+
+
+ ); +} diff --git a/src/components/katalog/instructions-section.tsx b/src/components/katalog/instructions-section.tsx new file mode 100644 index 0000000..5d21246 --- /dev/null +++ b/src/components/katalog/instructions-section.tsx @@ -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(null); + + function submit(event: React.FormEvent) { + 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 ( +
+ {instructions.length === 0 ? ( +

+ Noch keine Anleitung hinterlegt. Lade das PDF hoch oder verlinke es beim Hersteller. +

+ ) : ( +
    + {instructions.map((item) => ( +
  • + +
    +

    + {item.label ?? (item.kind === 'pdf_upload' ? 'Anleitung' : 'Externe Anleitung')} +

    +

    + {item.kind === 'pdf_upload' ? `PDF ${formatSize(item.fileSizeBytes)}` : 'Link'} +

    +
    + + {item.kind === 'pdf_upload' && item.filePath ? ( + <> + + + + ) : item.url ? ( + + ) : null} + + +
  • + ))} +
+ )} + + + + + + + Anleitung hinzufügen + + Ein Set kann mehrere Anleitungen haben – zum Beispiel „Buch 1 von 3“. + + + +
+ + setKind(event.target.value as typeof kind)} + > + + + + + + + + + + {kind === 'pdf_upload' ? ( + + + + ) : ( + + + + )} +
+ + + + + +
+
+ + !open && setViewing(null)}> + + + {viewing?.label ?? 'Anleitung'} + + {viewing?.filePath ? ( + + ) : null} + + +
+ ); +} diff --git a/src/components/katalog/pagination.tsx b/src/components/katalog/pagination.tsx new file mode 100644 index 0000000..099473c --- /dev/null +++ b/src/components/katalog/pagination.tsx @@ -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 ( + + ); +} diff --git a/src/components/katalog/pdf-viewer.tsx b/src/components/katalog/pdf-viewer.tsx new file mode 100644 index 0000000..e65073b --- /dev/null +++ b/src/components/katalog/pdf-viewer.tsx @@ -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(null); + const containerRef = useRef(null); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const documentRef = useRef(null); + const renderTaskRef = useRef<{ cancel: () => void } | null>(null); + const loadingTaskRef = useRef<{ destroy: () => Promise } | null>(null); + + const [page, setPage] = useState(1); + const [pageCount, setPageCount] = useState(0); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( +
+

{error}

+ +
+ ); + } + + return ( +
+
+ + {loading ? ( +
+ +
+ ) : null} +
+ +
+ + + Seite {page} von {pageCount || '…'} + + +
+
+ ); +} diff --git a/src/components/katalog/photos-section.tsx b/src/components/katalog/photos-section.tsx new file mode 100644 index 0000000..1e5d22f --- /dev/null +++ b/src/components/katalog/photos-section.tsx @@ -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) { + 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 ( +
+ {photos.length > 0 ? ( + + ) : ( +

+ Noch keine eigenen Fotos – zum Beispiel vom fertig gebauten Modell. +

+ )} + + + + + + + Foto hinzufügen + +
+ + {/* capture lets the phone open the camera directly. */} + + + + + +
+ + + + +
+
+
+ ); +} diff --git a/src/components/katalog/set-actions.tsx b/src/components/katalog/set-actions.tsx new file mode 100644 index 0000000..b936745 --- /dev/null +++ b/src/components/katalog/set-actions.tsx @@ -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 ( +
+ + + + + + + {owned ? ( +

+ Sets aus der Sammlung lassen sich nicht auf eine Wunschliste setzen. +

+ ) : null} + + + + + Auf Wunschliste setzen + {setName} + + +
+ + setTargetId(event.target.value)} + > + {wishlists.map((list) => ( + + ))} + + + + + {targetId === NEW_LIST ? ( + + setNewTitle(event.target.value)} + placeholder="Weihnachten 2026" + maxLength={80} + /> + + ) : null} + + + setPriority(event.target.value)} + > + {Object.entries(PRIORITY_LABELS).map(([value, label]) => ( + + ))} + + +
+ + + + + +
+
+ + + + + Set wirklich löschen? + + „{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. + + + + Abbrechen + Löschen + + + +
+ ); +} diff --git a/src/components/katalog/set-card.tsx b/src/components/katalog/set-card.tsx new file mode 100644 index 0000000..7b83260 --- /dev/null +++ b/src/components/katalog/set-card.tsx @@ -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 ( +
+
+ {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 + + ) : ( +
+ +
+ )} + +
+ {owned ? ( + + + {set.ownedCopies > 1 ? `${set.ownedCopies}× vorhanden` : 'Vorhanden'} + + ) : null} + {set.wishlistCount > 0 ? ( + + + Auf Wunschliste + + ) : null} +
+
+ +
+
+ + {set.setNumber} + + {set.manufacturerName} +
+

{set.name}

+
+ {[set.themeName, set.year].filter(Boolean).join(' · ')} + {set.retailPriceCents !== null ? ( + + {formatEuro(set.retailPriceCents)} + + ) : null} +
+
+
+ ); +} + +export function SetCard({ set, className }: { set: CatalogRow; className?: string }) { + return ( + div]:hover:border-primary/40', + className, + )} + > + + + ); +} diff --git a/src/components/katalog/set-form.tsx b/src/components/katalog/set-form.tsx new file mode 100644 index 0000000..2a95a80 --- /dev/null +++ b/src/components/katalog/set-form.tsx @@ -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) { + 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 ( +
+
+ + setManufacturerId(event.target.value)} + required + > + {manufacturers.map((manufacturer) => ( + + ))} + + + + + + + {availableThemes.map((theme) => ( + + ))} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+ + {existingImage ? ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + + {set?.imagePath ? ( + + ) : null} +
+ ) : null} + +

+ JPEG, PNG oder WebP. Das Bild wird beim Speichern verkleinert und lokal abgelegt. +

+
+ + +