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