From 4c78c5672b4297ee0a85f7c300a4701c00e8b39d Mon Sep 17 00:00:00 2001 From: unknown Date: Sun, 6 Sep 2026 22:40:42 +0200 Subject: [PATCH 1/9] feat(auth): better-auth mit passwort, passkeys und app-shell Registrierung des ersten Nutzers macht ihn zum Administrator, danach ist die Registrierung geschlossen (Einladungen folgen in Phase 2). Login per Passwort oder Passkey inklusive Conditional UI, Verwaltung der Passkeys unter "Konto" mit Umbenennen und Loeschen. Rate-Limiting liegt in der Datenbank und ueberlebt Neustarts; Session-Cookies sind httpOnly, sameSite=lax und im Produktivbetrieb secure. Die Client-IP wird hinter dem Reverse Proxy aus x-forwarded-for gelesen. Dazu die mobile-first Grundstruktur: Bottom-Navigation, Header mit Konto-Menue, Healthcheck unter /api/health und ein Dashboard mit den vier Einstiegspunkten. Bedienelemente sind auf 44px Touch-Groesse angehoben. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 6 +- ...clean_sumo.sql => 0000_rapid_gargoyle.sql} | 74 ++-- drizzle/meta/0000_snapshot.json | 152 +++---- drizzle/meta/_journal.json | 4 +- package.json | 140 +++---- src/app/(app)/konto/actions.ts | 45 +++ src/app/(app)/konto/page.tsx | 62 +++ src/app/(app)/layout.tsx | 16 + src/app/(app)/page.tsx | 58 +++ src/app/(auth)/anmelden/page.tsx | 38 ++ src/app/(auth)/layout.tsx | 17 + src/app/(auth)/registrieren/page.tsx | 47 +++ src/app/api/auth/[...all]/route.ts | 4 + src/app/api/health/route.ts | 19 + src/app/globals.css | 267 ++++++------ src/app/layout.tsx | 50 +-- src/app/page.tsx | 103 ----- src/components/auth/login-form.tsx | 131 ++++++ src/components/auth/passkey-manager.tsx | 207 ++++++++++ src/components/auth/register-form.tsx | 91 +++++ src/components/layout/app-header.tsx | 21 + src/components/layout/bottom-nav.tsx | 73 ++++ src/components/layout/user-menu.tsx | 62 +++ src/components/ui/button.tsx | 132 +++--- src/components/ui/input.tsx | 36 +- src/components/ui/select.tsx | 382 +++++++++--------- src/db/schema/auth.ts | 66 +-- src/db/schema/catalog.ts | 255 ++++++------ src/db/schema/collection.ts | 197 ++++----- src/lib/auth-client.ts | 14 + src/lib/auth.ts | 122 ++++++ src/lib/format.ts | 31 ++ src/lib/money.ts | 62 +++ src/lib/session.ts | 47 +++ src/middleware.ts | 32 ++ 35 files changed, 2089 insertions(+), 974 deletions(-) rename drizzle/{0000_clean_sumo.sql => 0000_rapid_gargoyle.sql} (85%) create mode 100644 src/app/(app)/konto/actions.ts create mode 100644 src/app/(app)/konto/page.tsx create mode 100644 src/app/(app)/layout.tsx create mode 100644 src/app/(app)/page.tsx create mode 100644 src/app/(auth)/anmelden/page.tsx create mode 100644 src/app/(auth)/layout.tsx create mode 100644 src/app/(auth)/registrieren/page.tsx create mode 100644 src/app/api/auth/[...all]/route.ts create mode 100644 src/app/api/health/route.ts delete mode 100644 src/app/page.tsx create mode 100644 src/components/auth/login-form.tsx create mode 100644 src/components/auth/passkey-manager.tsx create mode 100644 src/components/auth/register-form.tsx create mode 100644 src/components/layout/app-header.tsx create mode 100644 src/components/layout/bottom-nav.tsx create mode 100644 src/components/layout/user-menu.tsx create mode 100644 src/lib/auth-client.ts create mode 100644 src/lib/auth.ts create mode 100644 src/lib/format.ts create mode 100644 src/lib/money.ts create mode 100644 src/lib/session.ts create mode 100644 src/middleware.ts diff --git a/CLAUDE.md b/CLAUDE.md index e48bf7d..37b21c4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,9 +33,9 @@ Bild-URLs, die der Nutzer selbst hinterlegt hat. (`retail_price_cents`, `acquired_price_cents`). SQLite hat keinen Decimal-Typ; Float-Summen waeren ungenau. Umrechnung ausschliesslich in `src/lib/money.ts`. -- Datenbankspalten in `snake_case` - **ausser** in den Auth-Tabellen, - die `camelCase` verwenden, weil das exakt der Ausgabe von - `@better-auth/cli generate` entspricht. +- Alle Datenbankspalten in `snake_case`. Auch in den Auth-Tabellen - + der Drizzle-Adapter von better-auth loest Modelle ueber die + TypeScript-Property-Namen auf, nicht ueber die Spaltennamen. - IDs sind Text-UUIDs (`crypto.randomUUID()`), Zeitstempel sind `integer(mode: 'timestamp')`. - Routen sind deutsch (`/katalog`, `/sammlung`, `/wunschlisten`), diff --git a/drizzle/0000_clean_sumo.sql b/drizzle/0000_rapid_gargoyle.sql similarity index 85% rename from drizzle/0000_clean_sumo.sql rename to drizzle/0000_rapid_gargoyle.sql index c1d86bf..5aed9d6 100644 --- a/drizzle/0000_clean_sumo.sql +++ b/drizzle/0000_rapid_gargoyle.sql @@ -1,21 +1,21 @@ CREATE TABLE `account` ( `id` text PRIMARY KEY NOT NULL, - `accountId` text NOT NULL, - `providerId` text NOT NULL, - `userId` text NOT NULL, - `accessToken` text, - `refreshToken` text, - `idToken` text, - `accessTokenExpiresAt` integer, - `refreshTokenExpiresAt` integer, + `account_id` text NOT NULL, + `provider_id` text NOT NULL, + `user_id` text NOT NULL, + `access_token` text, + `refresh_token` text, + `id_token` text, + `access_token_expires_at` integer, + `refresh_token_expires_at` integer, `scope` text, `password` text, - `createdAt` integer NOT NULL, - `updatedAt` integer NOT NULL, - FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade ); --> statement-breakpoint -CREATE INDEX `account_user_idx` ON `account` (`userId`);--> statement-breakpoint +CREATE INDEX `account_user_idx` ON `account` (`user_id`);--> statement-breakpoint CREATE TABLE `invite` ( `id` text PRIMARY KEY NOT NULL, `token_hash` text NOT NULL, @@ -34,50 +34,50 @@ CREATE UNIQUE INDEX `invite_token_hash_unique` ON `invite` (`token_hash`);--> st CREATE TABLE `passkey` ( `id` text PRIMARY KEY NOT NULL, `name` text, - `publicKey` text NOT NULL, - `userId` text NOT NULL, - `credentialID` text NOT NULL, + `public_key` text NOT NULL, + `user_id` text NOT NULL, + `credential_id` text NOT NULL, `counter` integer NOT NULL, - `deviceType` text NOT NULL, - `backedUp` integer NOT NULL, + `device_type` text NOT NULL, + `backed_up` integer NOT NULL, `transports` text, `aaguid` text, - `createdAt` integer, - FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade + `created_at` integer, + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade ); --> statement-breakpoint -CREATE UNIQUE INDEX `passkey_credentialID_unique` ON `passkey` (`credentialID`);--> statement-breakpoint -CREATE INDEX `passkey_user_idx` ON `passkey` (`userId`);--> statement-breakpoint -CREATE TABLE `rateLimit` ( +CREATE UNIQUE INDEX `passkey_credential_id_unique` ON `passkey` (`credential_id`);--> statement-breakpoint +CREATE INDEX `passkey_user_idx` ON `passkey` (`user_id`);--> statement-breakpoint +CREATE TABLE `rate_limit` ( `id` text PRIMARY KEY NOT NULL, `key` text, `count` integer, - `lastRequest` integer + `last_request` integer ); --> statement-breakpoint CREATE TABLE `session` ( `id` text PRIMARY KEY NOT NULL, `token` text NOT NULL, - `userId` text NOT NULL, - `expiresAt` integer NOT NULL, - `ipAddress` text, - `userAgent` text, - `createdAt` integer NOT NULL, - `updatedAt` integer NOT NULL, - FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade + `user_id` text NOT NULL, + `expires_at` integer NOT NULL, + `ip_address` text, + `user_agent` text, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade ); --> statement-breakpoint CREATE UNIQUE INDEX `session_token_unique` ON `session` (`token`);--> statement-breakpoint -CREATE INDEX `session_user_idx` ON `session` (`userId`);--> statement-breakpoint +CREATE INDEX `session_user_idx` ON `session` (`user_id`);--> statement-breakpoint CREATE TABLE `user` ( `id` text PRIMARY KEY NOT NULL, `name` text NOT NULL, `email` text NOT NULL, - `emailVerified` integer DEFAULT false NOT NULL, + `email_verified` integer DEFAULT false NOT NULL, `image` text, `role` text DEFAULT 'member' NOT NULL, - `createdAt` integer NOT NULL, - `updatedAt` integer NOT NULL + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL ); --> statement-breakpoint CREATE UNIQUE INDEX `user_email_unique` ON `user` (`email`);--> statement-breakpoint @@ -85,9 +85,9 @@ CREATE TABLE `verification` ( `id` text PRIMARY KEY NOT NULL, `identifier` text NOT NULL, `value` text NOT NULL, - `expiresAt` integer NOT NULL, - `createdAt` integer NOT NULL, - `updatedAt` integer NOT NULL + `expires_at` integer NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL ); --> statement-breakpoint CREATE INDEX `verification_identifier_idx` ON `verification` (`identifier`);--> statement-breakpoint diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json index 17cdbe0..5951124 100644 --- a/drizzle/meta/0000_snapshot.json +++ b/drizzle/meta/0000_snapshot.json @@ -1,7 +1,7 @@ { "version": "6", "dialect": "sqlite", - "id": "5d6a1e8b-5b30-4c01-8044-1db2d04615e0", + "id": "426c9bf5-ccbd-473a-a819-11f8b9252494", "prevId": "00000000-0000-0000-0000-000000000000", "tables": { "account": { @@ -14,57 +14,57 @@ "notNull": true, "autoincrement": false }, - "accountId": { - "name": "accountId", + "account_id": { + "name": "account_id", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "providerId": { - "name": "providerId", + "provider_id": { + "name": "provider_id", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "userId": { - "name": "userId", + "user_id": { + "name": "user_id", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "accessToken": { - "name": "accessToken", + "access_token": { + "name": "access_token", "type": "text", "primaryKey": false, "notNull": false, "autoincrement": false }, - "refreshToken": { - "name": "refreshToken", + "refresh_token": { + "name": "refresh_token", "type": "text", "primaryKey": false, "notNull": false, "autoincrement": false }, - "idToken": { - "name": "idToken", + "id_token": { + "name": "id_token", "type": "text", "primaryKey": false, "notNull": false, "autoincrement": false }, - "accessTokenExpiresAt": { - "name": "accessTokenExpiresAt", + "access_token_expires_at": { + "name": "access_token_expires_at", "type": "integer", "primaryKey": false, "notNull": false, "autoincrement": false }, - "refreshTokenExpiresAt": { - "name": "refreshTokenExpiresAt", + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", "type": "integer", "primaryKey": false, "notNull": false, @@ -84,15 +84,15 @@ "notNull": false, "autoincrement": false }, - "createdAt": { - "name": "createdAt", + "created_at": { + "name": "created_at", "type": "integer", "primaryKey": false, "notNull": true, "autoincrement": false }, - "updatedAt": { - "name": "updatedAt", + "updated_at": { + "name": "updated_at", "type": "integer", "primaryKey": false, "notNull": true, @@ -103,18 +103,18 @@ "account_user_idx": { "name": "account_user_idx", "columns": [ - "userId" + "user_id" ], "isUnique": false } }, "foreignKeys": { - "account_userId_user_id_fk": { - "name": "account_userId_user_id_fk", + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", "tableFrom": "account", "tableTo": "user", "columnsFrom": [ - "userId" + "user_id" ], "columnsTo": [ "id" @@ -253,22 +253,22 @@ "notNull": false, "autoincrement": false }, - "publicKey": { - "name": "publicKey", + "public_key": { + "name": "public_key", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "userId": { - "name": "userId", + "user_id": { + "name": "user_id", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "credentialID": { - "name": "credentialID", + "credential_id": { + "name": "credential_id", "type": "text", "primaryKey": false, "notNull": true, @@ -281,15 +281,15 @@ "notNull": true, "autoincrement": false }, - "deviceType": { - "name": "deviceType", + "device_type": { + "name": "device_type", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "backedUp": { - "name": "backedUp", + "backed_up": { + "name": "backed_up", "type": "integer", "primaryKey": false, "notNull": true, @@ -309,8 +309,8 @@ "notNull": false, "autoincrement": false }, - "createdAt": { - "name": "createdAt", + "created_at": { + "name": "created_at", "type": "integer", "primaryKey": false, "notNull": false, @@ -318,28 +318,28 @@ } }, "indexes": { - "passkey_credentialID_unique": { - "name": "passkey_credentialID_unique", + "passkey_credential_id_unique": { + "name": "passkey_credential_id_unique", "columns": [ - "credentialID" + "credential_id" ], "isUnique": true }, "passkey_user_idx": { "name": "passkey_user_idx", "columns": [ - "userId" + "user_id" ], "isUnique": false } }, "foreignKeys": { - "passkey_userId_user_id_fk": { - "name": "passkey_userId_user_id_fk", + "passkey_user_id_user_id_fk": { + "name": "passkey_user_id_user_id_fk", "tableFrom": "passkey", "tableTo": "user", "columnsFrom": [ - "userId" + "user_id" ], "columnsTo": [ "id" @@ -352,8 +352,8 @@ "uniqueConstraints": {}, "checkConstraints": {} }, - "rateLimit": { - "name": "rateLimit", + "rate_limit": { + "name": "rate_limit", "columns": { "id": { "name": "id", @@ -376,8 +376,8 @@ "notNull": false, "autoincrement": false }, - "lastRequest": { - "name": "lastRequest", + "last_request": { + "name": "last_request", "type": "integer", "primaryKey": false, "notNull": false, @@ -407,43 +407,43 @@ "notNull": true, "autoincrement": false }, - "userId": { - "name": "userId", + "user_id": { + "name": "user_id", "type": "text", "primaryKey": false, "notNull": true, "autoincrement": false }, - "expiresAt": { - "name": "expiresAt", + "expires_at": { + "name": "expires_at", "type": "integer", "primaryKey": false, "notNull": true, "autoincrement": false }, - "ipAddress": { - "name": "ipAddress", + "ip_address": { + "name": "ip_address", "type": "text", "primaryKey": false, "notNull": false, "autoincrement": false }, - "userAgent": { - "name": "userAgent", + "user_agent": { + "name": "user_agent", "type": "text", "primaryKey": false, "notNull": false, "autoincrement": false }, - "createdAt": { - "name": "createdAt", + "created_at": { + "name": "created_at", "type": "integer", "primaryKey": false, "notNull": true, "autoincrement": false }, - "updatedAt": { - "name": "updatedAt", + "updated_at": { + "name": "updated_at", "type": "integer", "primaryKey": false, "notNull": true, @@ -461,18 +461,18 @@ "session_user_idx": { "name": "session_user_idx", "columns": [ - "userId" + "user_id" ], "isUnique": false } }, "foreignKeys": { - "session_userId_user_id_fk": { - "name": "session_userId_user_id_fk", + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", "tableFrom": "session", "tableTo": "user", "columnsFrom": [ - "userId" + "user_id" ], "columnsTo": [ "id" @@ -509,8 +509,8 @@ "notNull": true, "autoincrement": false }, - "emailVerified": { - "name": "emailVerified", + "email_verified": { + "name": "email_verified", "type": "integer", "primaryKey": false, "notNull": true, @@ -532,15 +532,15 @@ "autoincrement": false, "default": "'member'" }, - "createdAt": { - "name": "createdAt", + "created_at": { + "name": "created_at", "type": "integer", "primaryKey": false, "notNull": true, "autoincrement": false }, - "updatedAt": { - "name": "updatedAt", + "updated_at": { + "name": "updated_at", "type": "integer", "primaryKey": false, "notNull": true, @@ -585,22 +585,22 @@ "notNull": true, "autoincrement": false }, - "expiresAt": { - "name": "expiresAt", + "expires_at": { + "name": "expires_at", "type": "integer", "primaryKey": false, "notNull": true, "autoincrement": false }, - "createdAt": { - "name": "createdAt", + "created_at": { + "name": "created_at", "type": "integer", "primaryKey": false, "notNull": true, "autoincrement": false }, - "updatedAt": { - "name": "updatedAt", + "updated_at": { + "name": "updated_at", "type": "integer", "primaryKey": false, "notNull": true, diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 5b3d3e7..19c4a53 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -5,8 +5,8 @@ { "idx": 0, "version": "6", - "when": 1788726280857, - "tag": "0000_clean_sumo", + "when": 1788726503038, + "tag": "0000_rapid_gargoyle", "breakpoints": true } ] diff --git a/package.json b/package.json index d24db96..a1bcbe5 100644 --- a/package.json +++ b/package.json @@ -1,70 +1,70 @@ -{ - "name": "bricktrack", - "version": "0.1.0", - "private": true, - "description": "Self-hosted Verwaltung fuer Spielzeug-Set-Sammlungen (Lego, Playmobil, Cobi, ...)", - "packageManager": "pnpm@10.34.5", - "engines": { - "node": ">=22" - }, - "scripts": { - "dev": "next dev", - "build": "next build", - "start": "next start", - "lint": "next lint", - "typecheck": "tsc --noEmit", - "db:generate": "drizzle-kit generate", - "db:migrate": "tsx scripts/migrate.ts", - "db:seed": "tsx scripts/seed.ts", - "db:studio": "drizzle-kit studio", - "test": "vitest run", - "test:watch": "vitest", - "test:e2e": "playwright test", - "screenshots": "playwright test --config=playwright.screenshots.config.ts" - }, - "dependencies": { - "@better-auth/passkey": "^1.7.3", - "better-auth": "^1.7.3", - "better-sqlite3": "^13.0.3", - "class-variance-authority": "^0.7.1", - "cn": "^0.2.6", - "date-fns": "^4.4.0", - "drizzle-orm": "^0.45.2", - "lucide-react": "^1.41.0", - "next": "15.5.25", - "next-themes": "^0.4.6", - "radix-ui": "^1.6.7", - "react": "19.1.0", - "react-dom": "19.1.0", - "sharp": "^0.35.4", - "sonner": "^2.0.8", - "tw-animate-css": "^1.4.0", - "zod": "^4.5.4" - }, - "devDependencies": { - "@eslint/eslintrc": "^3", - "@playwright/test": "^1.63.0", - "@tailwindcss/postcss": "^4", - "@types/better-sqlite3": "^9.6.0", - "@types/node": "^20", - "@types/react": "^19", - "@types/react-dom": "^19", - "dotenv": "^17.4.2", - "drizzle-kit": "^0.31.10", - "eslint": "^9", - "eslint-config-next": "15.5.25", - "shadcn": "^4.21.0", - "tailwindcss": "^4", - "tsx": "^4.23.13", - "typescript": "^5", - "vitest": "^5.0.0" - }, - "pnpm": { - "onlyBuiltDependencies": [ - "better-sqlite3", - "esbuild", - "sharp", - "unrs-resolver" - ] - } -} +{ + "name": "bricktrack", + "version": "0.1.0", + "private": true, + "description": "Self-hosted Verwaltung fuer Spielzeug-Set-Sammlungen (Lego, Playmobil, Cobi, ...)", + "packageManager": "pnpm@10.34.5", + "engines": { + "node": ">=22" + }, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "eslint .", + "typecheck": "tsc --noEmit", + "db:generate": "drizzle-kit generate", + "db:migrate": "tsx scripts/migrate.ts", + "db:seed": "tsx scripts/seed.ts", + "db:studio": "drizzle-kit studio", + "test": "vitest run", + "test:watch": "vitest", + "test:e2e": "playwright test", + "screenshots": "playwright test --config=playwright.screenshots.config.ts" + }, + "dependencies": { + "@better-auth/passkey": "^1.7.3", + "better-auth": "^1.7.3", + "better-sqlite3": "^13.0.3", + "class-variance-authority": "^0.7.1", + "cn": "^0.2.6", + "date-fns": "^4.4.0", + "drizzle-orm": "^0.45.2", + "lucide-react": "^1.41.0", + "next": "15.5.25", + "next-themes": "^0.4.6", + "radix-ui": "^1.6.7", + "react": "19.1.0", + "react-dom": "19.1.0", + "sharp": "^0.35.4", + "sonner": "^2.0.8", + "tw-animate-css": "^1.4.0", + "zod": "^4.5.4" + }, + "devDependencies": { + "@eslint/eslintrc": "^3", + "@playwright/test": "^1.63.0", + "@tailwindcss/postcss": "^4", + "@types/better-sqlite3": "^9.6.0", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "dotenv": "^17.4.2", + "drizzle-kit": "^0.31.10", + "eslint": "^9", + "eslint-config-next": "15.5.25", + "shadcn": "^4.21.0", + "tailwindcss": "^4", + "tsx": "^4.23.13", + "typescript": "^5", + "vitest": "^5.0.0" + }, + "pnpm": { + "onlyBuiltDependencies": [ + "better-sqlite3", + "esbuild", + "sharp", + "unrs-resolver" + ] + } +} diff --git a/src/app/(app)/konto/actions.ts b/src/app/(app)/konto/actions.ts new file mode 100644 index 0000000..1cc91d6 --- /dev/null +++ b/src/app/(app)/konto/actions.ts @@ -0,0 +1,45 @@ +'use server'; + +import { revalidatePath } from 'next/cache'; +import { headers } from 'next/headers'; +import { z } from 'zod'; +import { auth } from '@/lib/auth'; + +const idSchema = z.object({ id: z.string().min(1) }); +const renameSchema = idSchema.extend({ name: z.string().trim().min(1).max(60) }); + +export type ActionResult = { ok: true } | { ok: false; error: string }; + +export async function deletePasskeyAction(input: unknown): Promise { + const parsed = idSchema.safeParse(input); + if (!parsed.success) return { ok: false, error: 'Ungültige Anfrage.' }; + + try { + await auth.api.deletePasskey({ + body: { id: parsed.data.id }, + headers: await headers(), + }); + } catch { + return { ok: false, error: 'Der Passkey konnte nicht gelöscht werden.' }; + } + + revalidatePath('/konto'); + return { ok: true }; +} + +export async function renamePasskeyAction(input: unknown): Promise { + const parsed = renameSchema.safeParse(input); + if (!parsed.success) return { ok: false, error: 'Bitte gib einen Namen an (max. 60 Zeichen).' }; + + try { + await auth.api.updatePasskey({ + body: { id: parsed.data.id, name: parsed.data.name }, + headers: await headers(), + }); + } catch { + return { ok: false, error: 'Der Passkey konnte nicht umbenannt werden.' }; + } + + revalidatePath('/konto'); + return { ok: true }; +} diff --git a/src/app/(app)/konto/page.tsx b/src/app/(app)/konto/page.tsx new file mode 100644 index 0000000..a08252b --- /dev/null +++ b/src/app/(app)/konto/page.tsx @@ -0,0 +1,62 @@ +import type { Metadata } from 'next'; +import { headers } from 'next/headers'; +import { PasskeyManager, type PasskeyRow } from '@/components/auth/passkey-manager'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { auth } from '@/lib/auth'; +import { requireUser } from '@/lib/session'; + +export const metadata: Metadata = { title: 'Konto' }; + +export default async function AccountPage() { + const user = await requireUser(); + + const passkeys = await auth.api.listPasskeys({ headers: await headers() }); + const rows: PasskeyRow[] = passkeys.map((passkey) => ({ + id: passkey.id, + name: passkey.name ?? null, + createdAt: passkey.createdAt ? new Date(passkey.createdAt) : null, + })); + + return ( +
+

Konto

+ + + + Profil + Diese Angaben stammen aus deiner Registrierung. + + +
+ Name + {user.name} +
+
+ E-Mail + {user.email} +
+
+ Rolle + + {user.role === 'admin' ? 'Administrator' : 'Mitglied'} + +
+
+
+ + + + Passkeys + + Passkeys sind an diese Domain gebunden und funktionieren nur über HTTPS. Du kannst + beliebig viele Geräte hinterlegen. + + + + + + +
+ ); +} diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx new file mode 100644 index 0000000..3fe9e1b --- /dev/null +++ b/src/app/(app)/layout.tsx @@ -0,0 +1,16 @@ +import { AppHeader } from '@/components/layout/app-header'; +import { BottomNav } from '@/components/layout/bottom-nav'; +import { requireUser } from '@/lib/session'; + +export default async function AppLayout({ children }: { children: React.ReactNode }) { + const user = await requireUser(); + + return ( +
+ + {/* Bottom padding keeps the mobile nav from covering the last row. */} +
{children}
+ +
+ ); +} diff --git a/src/app/(app)/page.tsx b/src/app/(app)/page.tsx new file mode 100644 index 0000000..dad922a --- /dev/null +++ b/src/app/(app)/page.tsx @@ -0,0 +1,58 @@ +import Link from 'next/link'; +import { Boxes, Gift, LayoutGrid, PackageSearch } from 'lucide-react'; +import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { requireUser } from '@/lib/session'; + +const shortcuts = [ + { + href: '/katalog', + icon: LayoutGrid, + title: 'Katalog', + description: 'Alle bekannten Sets durchsuchen und neue anlegen.', + }, + { + href: '/katalog?besitz=fehlt', + icon: PackageSearch, + title: 'Fehlende Sets', + description: 'Was noch nicht in der Sammlung ist – direkt auf die Wunschliste.', + }, + { + href: '/sammlung', + icon: Boxes, + title: 'Sammlung', + description: 'Alles, was tatsächlich da ist – mit Standort und Zustand.', + }, + { + href: '/wunschlisten', + icon: Gift, + title: 'Wunschlisten', + description: 'Listen anlegen und per Link mit Familie und Freunden teilen.', + }, +] as const; + +export default async function DashboardPage() { + const user = await requireUser(); + + return ( +
+
+

Hallo {user.name}

+

Was möchtest du tun?

+
+ +
+ {shortcuts.map((shortcut) => ( + + + + + {shortcut.title} + {shortcut.description} + + + + ))} +
+
+ ); +} diff --git a/src/app/(auth)/anmelden/page.tsx b/src/app/(auth)/anmelden/page.tsx new file mode 100644 index 0000000..088094b --- /dev/null +++ b/src/app/(auth)/anmelden/page.tsx @@ -0,0 +1,38 @@ +import type { Metadata } from 'next'; +import Link from 'next/link'; +import { redirect } from 'next/navigation'; +import { Suspense } from 'react'; +import { LoginForm } from '@/components/auth/login-form'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Skeleton } from '@/components/ui/skeleton'; +import { isFirstRun } from '@/lib/auth'; +import { getSession } from '@/lib/session'; + +export const metadata: Metadata = { title: 'Anmelden' }; + +export default async function LoginPage() { + if (await getSession()) redirect('/'); + + // Nobody registered yet: send the very first visitor straight to sign-up. + if (isFirstRun()) redirect('/registrieren'); + + return ( + + + Anmelden + Melde dich an, um die Sammlung zu verwalten. + + + }> + + +

+ Noch kein Konto?{' '} + + Registrierung + +

+
+
+ ); +} diff --git a/src/app/(auth)/layout.tsx b/src/app/(auth)/layout.tsx new file mode 100644 index 0000000..09aa574 --- /dev/null +++ b/src/app/(auth)/layout.tsx @@ -0,0 +1,17 @@ +import Link from 'next/link'; +import { Blocks } from 'lucide-react'; + +export default function AuthLayout({ children }: { children: React.ReactNode }) { + return ( +
+ + + Bricktrack + +
{children}
+

+ Die Sammlung und die Wunschlisten deiner Familie – auf deinem eigenen Server. +

+
+ ); +} diff --git a/src/app/(auth)/registrieren/page.tsx b/src/app/(auth)/registrieren/page.tsx new file mode 100644 index 0000000..d9792ae --- /dev/null +++ b/src/app/(auth)/registrieren/page.tsx @@ -0,0 +1,47 @@ +import type { Metadata } from 'next'; +import Link from 'next/link'; +import { redirect } from 'next/navigation'; +import { RegisterForm } from '@/components/auth/register-form'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { isFirstRun } from '@/lib/auth'; +import { getSession } from '@/lib/session'; + +export const metadata: Metadata = { title: 'Registrieren' }; + +export default async function RegisterPage() { + if (await getSession()) redirect('/'); + + // After the first user, accounts only come from an invitation link. + if (!isFirstRun()) { + return ( + + + Registrierung geschlossen + + Diese Bricktrack-Instanz hat bereits einen Administrator. Weitere Konten entstehen nur + über einen Einladungslink. + + + + + Zur Anmeldung + + + + ); + } + + return ( + + + Erstes Konto anlegen + + Du bist der erste Nutzer und wirst damit automatisch Administrator. + + + + + + + ); +} diff --git a/src/app/api/auth/[...all]/route.ts b/src/app/api/auth/[...all]/route.ts new file mode 100644 index 0000000..9122667 --- /dev/null +++ b/src/app/api/auth/[...all]/route.ts @@ -0,0 +1,4 @@ +import { toNextJsHandler } from 'better-auth/next-js'; +import { auth } from '@/lib/auth'; + +export const { GET, POST } = toNextJsHandler(auth.handler); diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts new file mode 100644 index 0000000..c774a02 --- /dev/null +++ b/src/app/api/health/route.ts @@ -0,0 +1,19 @@ +import { sql } from 'drizzle-orm'; +import { NextResponse } from 'next/server'; +import { db } from '@/db'; + +export const dynamic = 'force-dynamic'; + +/** + * Container healthcheck. Verifies that the process is up *and* that the SQLite + * file is readable - a healthy Node process on a broken volume is not healthy. + */ +export function GET() { + try { + db.get(sql`select 1`); + return NextResponse.json({ status: 'ok', time: new Date().toISOString() }); + } catch (error) { + console.error('[health] Datenbank nicht erreichbar', error); + return NextResponse.json({ status: 'error', error: 'database unavailable' }, { status: 503 }); + } +} diff --git a/src/app/globals.css b/src/app/globals.css index c56032b..6b1710d 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,130 +1,139 @@ -@import "tailwindcss"; -@import "tw-animate-css"; -@import "shadcn/tailwind.css"; - -@custom-variant dark (&:is(.dark *)); - -@theme inline { - --color-background: var(--background); - --color-foreground: var(--foreground); - --font-sans: var(--font-sans); - --font-mono: var(--font-geist-mono); - --font-heading: var(--font-sans); - --color-sidebar-ring: var(--sidebar-ring); - --color-sidebar-border: var(--sidebar-border); - --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); - --color-sidebar-accent: var(--sidebar-accent); - --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); - --color-sidebar-primary: var(--sidebar-primary); - --color-sidebar-foreground: var(--sidebar-foreground); - --color-sidebar: var(--sidebar); - --color-chart-5: var(--chart-5); - --color-chart-4: var(--chart-4); - --color-chart-3: var(--chart-3); - --color-chart-2: var(--chart-2); - --color-chart-1: var(--chart-1); - --color-ring: var(--ring); - --color-input: var(--input); - --color-border: var(--border); - --color-destructive: var(--destructive); - --color-accent-foreground: var(--accent-foreground); - --color-accent: var(--accent); - --color-muted-foreground: var(--muted-foreground); - --color-muted: var(--muted); - --color-secondary-foreground: var(--secondary-foreground); - --color-secondary: var(--secondary); - --color-primary-foreground: var(--primary-foreground); - --color-primary: var(--primary); - --color-popover-foreground: var(--popover-foreground); - --color-popover: var(--popover); - --color-card-foreground: var(--card-foreground); - --color-card: var(--card); - --radius-sm: calc(var(--radius) * 0.6); - --radius-md: calc(var(--radius) * 0.8); - --radius-lg: var(--radius); - --radius-xl: calc(var(--radius) * 1.4); - --radius-2xl: calc(var(--radius) * 1.8); - --radius-3xl: calc(var(--radius) * 2.2); - --radius-4xl: calc(var(--radius) * 2.6); -} - -:root { - --background: oklch(1 0 0); - --foreground: oklch(0.145 0 0); - --card: oklch(1 0 0); - --card-foreground: oklch(0.145 0 0); - --popover: oklch(1 0 0); - --popover-foreground: oklch(0.145 0 0); - --primary: oklch(0.205 0 0); - --primary-foreground: oklch(0.985 0 0); - --secondary: oklch(0.97 0 0); - --secondary-foreground: oklch(0.205 0 0); - --muted: oklch(0.97 0 0); - --muted-foreground: oklch(0.556 0 0); - --accent: oklch(0.97 0 0); - --accent-foreground: oklch(0.205 0 0); - --destructive: oklch(0.577 0.245 27.325); - --border: oklch(0.922 0 0); - --input: oklch(0.922 0 0); - --ring: oklch(0.708 0 0); - --chart-1: oklch(0.87 0 0); - --chart-2: oklch(0.556 0 0); - --chart-3: oklch(0.439 0 0); - --chart-4: oklch(0.371 0 0); - --chart-5: oklch(0.269 0 0); - --radius: 0.625rem; - --sidebar: oklch(0.985 0 0); - --sidebar-foreground: oklch(0.145 0 0); - --sidebar-primary: oklch(0.205 0 0); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.97 0 0); - --sidebar-accent-foreground: oklch(0.205 0 0); - --sidebar-border: oklch(0.922 0 0); - --sidebar-ring: oklch(0.708 0 0); -} - -.dark { - --background: oklch(0.145 0 0); - --foreground: oklch(0.985 0 0); - --card: oklch(0.205 0 0); - --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.205 0 0); - --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.922 0 0); - --primary-foreground: oklch(0.205 0 0); - --secondary: oklch(0.269 0 0); - --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.269 0 0); - --muted-foreground: oklch(0.708 0 0); - --accent: oklch(0.269 0 0); - --accent-foreground: oklch(0.985 0 0); - --destructive: oklch(0.704 0.191 22.216); - --border: oklch(1 0 0 / 10%); - --input: oklch(1 0 0 / 15%); - --ring: oklch(0.556 0 0); - --chart-1: oklch(0.87 0 0); - --chart-2: oklch(0.556 0 0); - --chart-3: oklch(0.439 0 0); - --chart-4: oklch(0.371 0 0); - --chart-5: oklch(0.269 0 0); - --sidebar: oklch(0.205 0 0); - --sidebar-foreground: oklch(0.985 0 0); - --sidebar-primary: oklch(0.488 0.243 264.376); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.269 0 0); - --sidebar-accent-foreground: oklch(0.985 0 0); - --sidebar-border: oklch(1 0 0 / 10%); - --sidebar-ring: oklch(0.556 0 0); -} - -@layer base { - * { - @apply border-border outline-ring/50; - } - body { - @apply bg-background text-foreground; - } - html { - @apply font-sans; - } +@import "tailwindcss"; +@import "tw-animate-css"; +@import "shadcn/tailwind.css"; + +@custom-variant dark (&:is(.dark *)); + +:root { + --font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, + "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", + "Segoe UI Emoji"; + --font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, + "Liberation Mono", monospace; +} + + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --font-sans: var(--font-sans); + --font-mono: var(--font-mono); + --font-heading: var(--font-sans); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --radius-sm: calc(var(--radius) * 0.6); + --radius-md: calc(var(--radius) * 0.8); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) * 1.4); + --radius-2xl: calc(var(--radius) * 1.8); + --radius-3xl: calc(var(--radius) * 2.2); + --radius-4xl: calc(var(--radius) * 2.6); +} + +:root { + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.87 0 0); + --chart-2: oklch(0.556 0 0); + --chart-3: oklch(0.439 0 0); + --chart-4: oklch(0.371 0 0); + --chart-5: oklch(0.269 0 0); + --radius: 0.625rem; + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.87 0 0); + --chart-2: oklch(0.556 0 0); + --chart-3: oklch(0.439 0 0); + --chart-4: oklch(0.371 0 0); + --chart-5: oklch(0.269 0 0); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } + html { + @apply font-sans; + } } \ No newline at end of file diff --git a/src/app/layout.tsx b/src/app/layout.tsx index f7fa87e..2e04487 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,33 +1,35 @@ -import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; -import "./globals.css"; - -const geistSans = Geist({ - variable: "--font-geist-sans", - subsets: ["latin"], -}); - -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", - subsets: ["latin"], -}); +import type { Metadata, Viewport } from 'next'; +import { Toaster } from '@/components/ui/sonner'; +import './globals.css'; export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: { + default: 'Bricktrack', + template: '%s · Bricktrack', + }, + description: 'Sammlung und Wunschlisten für Spielzeug-Sets', + applicationName: 'Bricktrack', + // A private, self-hosted instance never belongs in a search index - and the + // public wishlist links are meant to be shared, not found. + robots: { index: false, follow: false }, }; -export default function RootLayout({ - children, -}: Readonly<{ - children: React.ReactNode; -}>) { +export const viewport: Viewport = { + width: 'device-width', + initialScale: 1, + viewportFit: 'cover', + themeColor: [ + { media: '(prefers-color-scheme: light)', color: '#ffffff' }, + { media: '(prefers-color-scheme: dark)', color: '#0a0a0a' }, + ], +}; + +export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { return ( - - + + {children} + ); diff --git a/src/app/page.tsx b/src/app/page.tsx deleted file mode 100644 index a932894..0000000 --- a/src/app/page.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import Image from "next/image"; - -export default function Home() { - return ( -
-
- Next.js logo -
    -
  1. - Get started by editing{" "} - - src/app/page.tsx - - . -
  2. -
  3. - Save and see your changes instantly. -
  4. -
- - -
- -
- ); -} diff --git a/src/components/auth/login-form.tsx b/src/components/auth/login-form.tsx new file mode 100644 index 0000000..864e224 --- /dev/null +++ b/src/components/auth/login-form.tsx @@ -0,0 +1,131 @@ +'use client'; + +import { useRouter, useSearchParams } from 'next/navigation'; +import { useEffect, useState } from 'react'; +import { Fingerprint, Loader2 } from 'lucide-react'; +import { toast } from 'sonner'; +import { authClient } from '@/lib/auth-client'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; + +export function LoginForm() { + const router = useRouter(); + const searchParams = useSearchParams(); + const target = searchParams.get('weiter') ?? '/'; + + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [pending, setPending] = useState<'password' | 'passkey' | null>(null); + + useEffect(() => { + // Conditional UI: offers matching passkeys straight from the browser's + // autofill, where supported. Silently ignored everywhere else. + if (!window.PublicKeyCredential?.isConditionalMediationAvailable) return; + + let cancelled = false; + void window.PublicKeyCredential.isConditionalMediationAvailable().then((available) => { + if (!available || cancelled) return; + void authClient.signIn + .passkey({ autoFill: true }) + .then((result) => { + if (cancelled || result?.error) return; + router.push(target); + router.refresh(); + }) + .catch(() => { + /* user dismissed the autofill prompt */ + }); + }); + + return () => { + cancelled = true; + }; + }, [router, target]); + + async function signInWithPassword(event: React.FormEvent) { + event.preventDefault(); + setPending('password'); + const { error } = await authClient.signIn.email({ email, password }); + setPending(null); + + if (error) { + toast.error( + error.status === 429 + ? 'Zu viele Versuche. Bitte warte einen Moment.' + : 'E-Mail-Adresse oder Passwort stimmt nicht.', + ); + return; + } + router.push(target); + router.refresh(); + } + + async function signInWithPasskey() { + setPending('passkey'); + const result = await authClient.signIn.passkey(); + setPending(null); + + if (!result || result.error) { + toast.error('Anmeldung mit Passkey hat nicht geklappt.'); + return; + } + router.push(target); + router.refresh(); + } + + return ( +
+
+
+ + setEmail(event.target.value)} + placeholder="du@example.de" + /> +
+
+ + setPassword(event.target.value)} + /> +
+ +
+ +
+ + oder + +
+ + +
+ ); +} diff --git a/src/components/auth/passkey-manager.tsx b/src/components/auth/passkey-manager.tsx new file mode 100644 index 0000000..cedea85 --- /dev/null +++ b/src/components/auth/passkey-manager.tsx @@ -0,0 +1,207 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { useState, useTransition } from 'react'; +import { Fingerprint, KeyRound, Loader2, Pencil, Trash2 } from 'lucide-react'; +import { toast } from 'sonner'; +import { deletePasskeyAction, renamePasskeyAction } from '@/app/(app)/konto/actions'; +import { authClient } from '@/lib/auth-client'; +import { formatDate } from '@/lib/format'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; + +export type PasskeyRow = { + id: string; + name: string | null; + createdAt: Date | null; +}; + +export function PasskeyManager({ passkeys }: { passkeys: PasskeyRow[] }) { + const router = useRouter(); + const [adding, setAdding] = useState(false); + const [name, setName] = useState(''); + const [dialogOpen, setDialogOpen] = useState(false); + const [renaming, setRenaming] = useState(null); + const [pending, startTransition] = useTransition(); + + async function addPasskey() { + setAdding(true); + const result = await authClient.passkey.addPasskey({ + name: name.trim() || defaultPasskeyName(), + }); + setAdding(false); + + if (result?.error) { + toast.error(result.error.message ?? 'Der Passkey konnte nicht angelegt werden.'); + return; + } + + toast.success('Passkey gespeichert.'); + setName(''); + setDialogOpen(false); + router.refresh(); + } + + function remove(passkey: PasskeyRow) { + startTransition(async () => { + const result = await deletePasskeyAction({ id: passkey.id }); + if (!result.ok) { + toast.error(result.error); + return; + } + toast.success('Passkey gelöscht.'); + router.refresh(); + }); + } + + function rename(passkey: PasskeyRow, newName: string) { + startTransition(async () => { + const result = await renamePasskeyAction({ id: passkey.id, name: newName }); + if (!result.ok) { + toast.error(result.error); + return; + } + toast.success('Passkey umbenannt.'); + setRenaming(null); + router.refresh(); + }); + } + + return ( +
+ {passkeys.length === 0 ? ( +

+ Noch kein Passkey hinterlegt. Mit einem Passkey meldest du dich per Fingerabdruck, + Gesichtserkennung oder Geräte-PIN an – ohne Passwort. +

+ ) : ( +
    + {passkeys.map((passkey) => ( +
  • + +
    +

    {passkey.name ?? 'Unbenannt'}

    +

    + Angelegt am {formatDate(passkey.createdAt)} +

    +
    + + +
  • + ))} +
+ )} + + + + + + + Passkey hinzufügen + + Gib dem Passkey einen Namen, damit du ihn später wiedererkennst – zum Beispiel + „iPhone von Papa“. + + +
+ + setName(event.target.value)} + placeholder={defaultPasskeyName()} + maxLength={60} + /> +
+ + + + + + +
+
+ + !open && setRenaming(null)}> + + + Passkey umbenennen + + Der Name hilft dir nur beim Wiedererkennen und ist sonst ohne Bedeutung. + + +
{ + event.preventDefault(); + const value = new FormData(event.currentTarget).get('name'); + if (renaming && typeof value === 'string') rename(renaming, value); + }} + className="space-y-2" + > + + +
+ + + + + + +
+
+
+ ); +} + +/** A sensible default so the list does not fill up with "Unbenannt". */ +function defaultPasskeyName(): string { + if (typeof navigator === 'undefined') return 'Mein Gerät'; + const ua = navigator.userAgent; + if (/iPhone/.test(ua)) return 'iPhone'; + if (/iPad/.test(ua)) return 'iPad'; + if (/Android/.test(ua)) return 'Android-Gerät'; + if (/Macintosh/.test(ua)) return 'Mac'; + if (/Windows/.test(ua)) return 'Windows-PC'; + return 'Mein Gerät'; +} diff --git a/src/components/auth/register-form.tsx b/src/components/auth/register-form.tsx new file mode 100644 index 0000000..9baa45b --- /dev/null +++ b/src/components/auth/register-form.tsx @@ -0,0 +1,91 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { useState } from 'react'; +import { Loader2 } from 'lucide-react'; +import { toast } from 'sonner'; +import { authClient } from '@/lib/auth-client'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; + +const MIN_PASSWORD_LENGTH = 10; + +export function RegisterForm() { + const router = useRouter(); + const [name, setName] = useState(''); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [pending, setPending] = useState(false); + + async function onSubmit(event: React.FormEvent) { + event.preventDefault(); + + if (password.length < MIN_PASSWORD_LENGTH) { + toast.error(`Das Passwort braucht mindestens ${MIN_PASSWORD_LENGTH} Zeichen.`); + return; + } + + setPending(true); + const { error } = await authClient.signUp.email({ name, email, password }); + setPending(false); + + if (error) { + toast.error(error.message ?? 'Registrierung fehlgeschlagen.'); + return; + } + + toast.success('Konto angelegt. Willkommen bei Bricktrack!'); + router.push('/'); + router.refresh(); + } + + return ( +
+
+ + setName(event.target.value)} + placeholder="Vorname" + /> +
+
+ + setEmail(event.target.value)} + placeholder="du@example.de" + /> +
+
+ + setPassword(event.target.value)} + /> +

+ Mindestens {MIN_PASSWORD_LENGTH} Zeichen. Einen Passkey kannst du direkt nach der + Anmeldung unter „Konto“ hinterlegen. +

+
+ +
+ ); +} diff --git a/src/components/layout/app-header.tsx b/src/components/layout/app-header.tsx new file mode 100644 index 0000000..bdb8d67 --- /dev/null +++ b/src/components/layout/app-header.tsx @@ -0,0 +1,21 @@ +import Link from 'next/link'; +import { Blocks } from 'lucide-react'; +import { DesktopNav } from './bottom-nav'; +import { UserMenu } from './user-menu'; +import type { SessionUser } from '@/lib/session'; + +export function AppHeader({ user }: { user: SessionUser }) { + return ( +
+
+ + + Bricktrack + +
+ + +
+
+ ); +} diff --git a/src/components/layout/bottom-nav.tsx b/src/components/layout/bottom-nav.tsx new file mode 100644 index 0000000..80685cb --- /dev/null +++ b/src/components/layout/bottom-nav.tsx @@ -0,0 +1,73 @@ +'use client'; + +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import { Boxes, Gift, LayoutGrid, User } from 'lucide-react'; +import { cn } from 'cn'; + +const items = [ + { href: '/katalog', label: 'Katalog', icon: LayoutGrid }, + { href: '/sammlung', label: 'Sammlung', icon: Boxes }, + { href: '/wunschlisten', label: 'Wünsche', icon: Gift }, + { href: '/konto', label: 'Konto', icon: User }, +] as const; + +export function BottomNav() { + const pathname = usePathname(); + + return ( + + ); +} + +export function DesktopNav() { + const pathname = usePathname(); + + return ( + + ); +} diff --git a/src/components/layout/user-menu.tsx b/src/components/layout/user-menu.tsx new file mode 100644 index 0000000..8aa10df --- /dev/null +++ b/src/components/layout/user-menu.tsx @@ -0,0 +1,62 @@ +'use client'; + +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { LogOut, Settings, UserRound } from 'lucide-react'; +import { authClient } from '@/lib/auth-client'; +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import type { SessionUser } from '@/lib/session'; + +export function UserMenu({ user }: { user: SessionUser }) { + const router = useRouter(); + + async function signOut() { + await authClient.signOut(); + router.push('/anmelden'); + router.refresh(); + } + + return ( + + + + + + + {user.name} + {user.email} + + + + + + Konto und Passkeys + + + {user.role === 'admin' ? ( + + + + Verwaltung + + + ) : null} + + void signOut()}> + + Abmelden + + + + ); +} diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 18407c9..7e1eb83 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -1,66 +1,66 @@ -import * as React from "react" -import { cva, type VariantProps } from "class-variance-authority" -import { cn } from "cn" -import { Slot } from "radix-ui" - -const buttonVariants = cva( - "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", - { - variants: { - variant: { - default: "bg-primary text-primary-foreground hover:bg-primary/80", - outline: - "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", - secondary: - "bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", - ghost: - "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50", - destructive: - "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40", - link: "text-primary underline-offset-4 hover:underline", - }, - size: { - default: - "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", - xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", - sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", - lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", - icon: "size-8", - "icon-xs": - "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3", - "icon-sm": - "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg", - "icon-lg": "size-9", - }, - }, - defaultVariants: { - variant: "default", - size: "default", - }, - } -) - -function Button({ - className, - variant = "default", - size = "default", - asChild = false, - ...props -}: React.ComponentProps<"button"> & - VariantProps & { - asChild?: boolean - }) { - const Comp = asChild ? Slot.Root : "button" - - return ( - - ) -} - -export { Button, buttonVariants } +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { cn } from "cn" +import { Slot } from "radix-ui" + +const buttonVariants = cva( + "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/80", + outline: + "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", + secondary: + "bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", + ghost: + "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50", + destructive: + "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: + "h-10 gap-1.5 px-3.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", + xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", + sm: "h-9 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", + lg: "h-11 gap-2 px-5 text-base has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", + icon: "size-10", + "icon-xs": + "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3", + "icon-sm": + "size-9 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg", + "icon-lg": "size-11", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +function Button({ + className, + variant = "default", + size = "default", + asChild = false, + ...props +}: React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean + }) { + const Comp = asChild ? Slot.Root : "button" + + return ( + + ) +} + +export { Button, buttonVariants } diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx index 7aa671b..607e963 100644 --- a/src/components/ui/input.tsx +++ b/src/components/ui/input.tsx @@ -1,18 +1,18 @@ -import * as React from "react" -import { cn } from "cn" - -function Input({ className, type, ...props }: React.ComponentProps<"input">) { - return ( - - ) -} - -export { Input } +import * as React from "react" +import { cn } from "cn" + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + + ) +} + +export { Input } diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx index cdd962b..e2d9617 100644 --- a/src/components/ui/select.tsx +++ b/src/components/ui/select.tsx @@ -1,191 +1,191 @@ -"use client" - -import * as React from "react" -import { cn } from "cn" -import { Select as SelectPrimitive } from "radix-ui" -import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react" - -function Select({ - ...props -}: React.ComponentProps) { - return -} - -function SelectGroup({ - className, - ...props -}: React.ComponentProps) { - return ( - - ) -} - -function SelectValue({ - ...props -}: React.ComponentProps) { - return -} - -function SelectTrigger({ - className, - size = "default", - children, - ...props -}: React.ComponentProps & { - size?: "sm" | "default" -}) { - return ( - - {children} - - - - - ) -} - -function SelectContent({ - className, - children, - position = "item-aligned", - align = "center", - ...props -}: React.ComponentProps) { - return ( - - - - - {children} - - - - - ) -} - -function SelectLabel({ - className, - ...props -}: React.ComponentProps) { - return ( - - ) -} - -function SelectItem({ - className, - children, - ...props -}: React.ComponentProps) { - return ( - - - - - - - {children} - - ) -} - -function SelectSeparator({ - className, - ...props -}: React.ComponentProps) { - return ( - - ) -} - -function SelectScrollUpButton({ - className, - ...props -}: React.ComponentProps) { - return ( - - - - ) -} - -function SelectScrollDownButton({ - className, - ...props -}: React.ComponentProps) { - return ( - - - - ) -} - -export { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectLabel, - SelectScrollDownButton, - SelectScrollUpButton, - SelectSeparator, - SelectTrigger, - SelectValue, -} +"use client" + +import * as React from "react" +import { cn } from "cn" +import { Select as SelectPrimitive } from "radix-ui" +import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react" + +function Select({ + ...props +}: React.ComponentProps) { + return +} + +function SelectGroup({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SelectValue({ + ...props +}: React.ComponentProps) { + return +} + +function SelectTrigger({ + className, + size = "default", + children, + ...props +}: React.ComponentProps & { + size?: "sm" | "default" +}) { + return ( + + {children} + + + + + ) +} + +function SelectContent({ + className, + children, + position = "item-aligned", + align = "center", + ...props +}: React.ComponentProps) { + return ( + + + + + {children} + + + + + ) +} + +function SelectLabel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SelectItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function SelectSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SelectScrollUpButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function SelectScrollDownButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectScrollDownButton, + SelectScrollUpButton, + SelectSeparator, + SelectTrigger, + SelectValue, +} diff --git a/src/db/schema/auth.ts b/src/db/schema/auth.ts index b0443c6..e2090ef 100644 --- a/src/db/schema/auth.ts +++ b/src/db/schema/auth.ts @@ -3,23 +3,23 @@ import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'; /** * Tables owned by better-auth. * - * Column names are camelCase here (unlike the rest of the schema) because that - * is exactly what `@better-auth/cli generate` produces. Renaming them is a - * reliable source of breakage whenever a plugin is added or updated. + * The drizzle adapter resolves models through the TypeScript property names + * (`userId`, `expiresAt`, ...), so the column names below are free to follow + * the snake_case convention used by the rest of the schema. */ export const user = sqliteTable('user', { id: text('id').primaryKey(), name: text('name').notNull(), email: text('email').notNull().unique(), - emailVerified: integer('emailVerified', { mode: 'boolean' }).notNull().default(false), + emailVerified: integer('email_verified', { mode: 'boolean' }).notNull().default(false), image: text('image'), /** App specific: the first registered user becomes admin, everyone else member. */ role: text('role', { enum: ['admin', 'member'] }) .notNull() .default('member'), - createdAt: integer('createdAt', { mode: 'timestamp' }).notNull(), - updatedAt: integer('updatedAt', { mode: 'timestamp' }).notNull(), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), + updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), }); export const session = sqliteTable( @@ -27,14 +27,14 @@ export const session = sqliteTable( { id: text('id').primaryKey(), token: text('token').notNull().unique(), - userId: text('userId') + userId: text('user_id') .notNull() .references(() => user.id, { onDelete: 'cascade' }), - expiresAt: integer('expiresAt', { mode: 'timestamp' }).notNull(), - ipAddress: text('ipAddress'), - userAgent: text('userAgent'), - createdAt: integer('createdAt', { mode: 'timestamp' }).notNull(), - updatedAt: integer('updatedAt', { mode: 'timestamp' }).notNull(), + expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull(), + ipAddress: text('ip_address'), + userAgent: text('user_agent'), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), + updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), }, (t) => [index('session_user_idx').on(t.userId)], ); @@ -44,20 +44,20 @@ export const account = sqliteTable( 'account', { id: text('id').primaryKey(), - accountId: text('accountId').notNull(), - providerId: text('providerId').notNull(), - userId: text('userId') + accountId: text('account_id').notNull(), + providerId: text('provider_id').notNull(), + userId: text('user_id') .notNull() .references(() => user.id, { onDelete: 'cascade' }), - accessToken: text('accessToken'), - refreshToken: text('refreshToken'), - idToken: text('idToken'), - accessTokenExpiresAt: integer('accessTokenExpiresAt', { mode: 'timestamp' }), - refreshTokenExpiresAt: integer('refreshTokenExpiresAt', { mode: 'timestamp' }), + accessToken: text('access_token'), + refreshToken: text('refresh_token'), + idToken: text('id_token'), + accessTokenExpiresAt: integer('access_token_expires_at', { mode: 'timestamp' }), + refreshTokenExpiresAt: integer('refresh_token_expires_at', { mode: 'timestamp' }), scope: text('scope'), password: text('password'), - createdAt: integer('createdAt', { mode: 'timestamp' }).notNull(), - updatedAt: integer('updatedAt', { mode: 'timestamp' }).notNull(), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), + updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), }, (t) => [index('account_user_idx').on(t.userId)], ); @@ -68,9 +68,9 @@ export const verification = sqliteTable( id: text('id').primaryKey(), identifier: text('identifier').notNull(), value: text('value').notNull(), - expiresAt: integer('expiresAt', { mode: 'timestamp' }).notNull(), - createdAt: integer('createdAt', { mode: 'timestamp' }).notNull(), - updatedAt: integer('updatedAt', { mode: 'timestamp' }).notNull(), + expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull(), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), + updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), }, (t) => [index('verification_identifier_idx').on(t.identifier)], ); @@ -81,27 +81,27 @@ export const passkey = sqliteTable( { id: text('id').primaryKey(), name: text('name'), - publicKey: text('publicKey').notNull(), - userId: text('userId') + publicKey: text('public_key').notNull(), + userId: text('user_id') .notNull() .references(() => user.id, { onDelete: 'cascade' }), - credentialID: text('credentialID').notNull().unique(), + credentialID: text('credential_id').notNull().unique(), counter: integer('counter').notNull(), - deviceType: text('deviceType').notNull(), - backedUp: integer('backedUp', { mode: 'boolean' }).notNull(), + deviceType: text('device_type').notNull(), + backedUp: integer('backed_up', { mode: 'boolean' }).notNull(), transports: text('transports'), aaguid: text('aaguid'), - createdAt: integer('createdAt', { mode: 'timestamp' }), + createdAt: integer('created_at', { mode: 'timestamp' }), }, (t) => [index('passkey_user_idx').on(t.userId)], ); /** Persistent rate limiting so restarts do not reset login throttling. */ -export const rateLimit = sqliteTable('rateLimit', { +export const rateLimit = sqliteTable('rate_limit', { id: text('id').primaryKey(), key: text('key'), count: integer('count'), - lastRequest: integer('lastRequest'), + lastRequest: integer('last_request'), }); /** diff --git a/src/db/schema/catalog.ts b/src/db/schema/catalog.ts index 6194846..46567d5 100644 --- a/src/db/schema/catalog.ts +++ b/src/db/schema/catalog.ts @@ -1,127 +1,128 @@ -import { sql, type AnyColumn } from 'drizzle-orm'; -import { - check, - index, - integer, - primaryKey, - sqliteTable, - text, - uniqueIndex, -} from 'drizzle-orm/sqlite-core'; - -export const manufacturer = sqliteTable('manufacturer', { - id: text('id').primaryKey(), - name: text('name').notNull(), - slug: text('slug').notNull().unique(), - website: text('website'), - createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), - updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), -}); - -export const theme = sqliteTable( - 'theme', - { - id: text('id').primaryKey(), - manufacturerId: text('manufacturer_id') - .notNull() - .references(() => manufacturer.id, { onDelete: 'cascade' }), - parentThemeId: text('parent_theme_id').references((): AnyColumn => theme.id, { - onDelete: 'set null', - }), - name: text('name').notNull(), - createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), - updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), - }, - (t) => [ - index('theme_manufacturer_idx').on(t.manufacturerId), - uniqueIndex('theme_unique_name').on(t.manufacturerId, t.name), - ], -); - -/** - * A catalog entry. The set exists in the world, independent of whether we own - * it - ownership is expressed by `collection_item`. - */ -export const set = sqliteTable( - 'set', - { - id: text('id').primaryKey(), - manufacturerId: text('manufacturer_id') - .notNull() - .references(() => manufacturer.id, { onDelete: 'restrict' }), - themeId: text('theme_id').references(() => theme.id, { onDelete: 'set null' }), - /** Manufacturer's own number, e.g. "60398", "71427", "2-30500". */ - setNumber: text('set_number').notNull(), - name: text('name').notNull(), - year: integer('year'), - pieceCount: integer('piece_count'), - minifigCount: integer('minifig_count'), - ean: text('ean'), - ageRange: text('age_range'), - /** Either an external URL the user entered ... */ - imageUrl: text('image_url'), - /** ... or a locally stored file below FILES_DIR. */ - imagePath: text('image_path'), - /** EUR in cents - SQLite has no decimal type and sums must stay exact. */ - retailPriceCents: integer('retail_price_cents'), - externalIds: text('external_ids', { mode: 'json' }).$type>(), - notes: text('notes'), - createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), - updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), - }, - (t) => [ - uniqueIndex('set_manufacturer_number_unique').on(t.manufacturerId, t.setNumber), - index('set_name_idx').on(t.name), - index('set_ean_idx').on(t.ean), - index('set_theme_idx').on(t.themeId), - index('set_year_idx').on(t.year), - ], -); - -export const tag = sqliteTable('tag', { - id: text('id').primaryKey(), - name: text('name').notNull(), - slug: text('slug').notNull().unique(), -}); - -export const setTag = sqliteTable( - 'set_tag', - { - setId: text('set_id') - .notNull() - .references(() => set.id, { onDelete: 'cascade' }), - tagId: text('tag_id') - .notNull() - .references(() => tag.id, { onDelete: 'cascade' }), - }, - (t) => [primaryKey({ columns: [t.setId, t.tagId] })], -); - -export const instruction = sqliteTable( - 'instruction', - { - id: text('id').primaryKey(), - setId: text('set_id') - .notNull() - .references(() => set.id, { onDelete: 'cascade' }), - kind: text('kind', { enum: ['pdf_upload', 'external_url'] }).notNull(), - /** Set when kind = pdf_upload. */ - filePath: text('file_path'), - /** Set when kind = external_url. */ - url: text('url'), - /** Free text label, e.g. "Buch 1 von 3". */ - label: text('label'), - pageCount: integer('page_count'), - fileSizeBytes: integer('file_size_bytes'), - sortOrder: integer('sort_order').notNull().default(0), - createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), - }, - (t) => [ - index('instruction_set_idx').on(t.setId), - check( - 'instruction_source', - sql`(${t.kind} = 'pdf_upload' AND ${t.filePath} IS NOT NULL AND ${t.url} IS NULL) - OR (${t.kind} = 'external_url' AND ${t.url} IS NOT NULL AND ${t.filePath} IS NULL)`, - ), - ], -); +import { sql } from 'drizzle-orm'; +import { + type AnySQLiteColumn, + check, + index, + integer, + primaryKey, + sqliteTable, + text, + uniqueIndex, +} from 'drizzle-orm/sqlite-core'; + +export const manufacturer = sqliteTable('manufacturer', { + id: text('id').primaryKey(), + name: text('name').notNull(), + slug: text('slug').notNull().unique(), + website: text('website'), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), + updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), +}); + +export const theme = sqliteTable( + 'theme', + { + id: text('id').primaryKey(), + manufacturerId: text('manufacturer_id') + .notNull() + .references(() => manufacturer.id, { onDelete: 'cascade' }), + parentThemeId: text('parent_theme_id').references((): AnySQLiteColumn => theme.id, { + onDelete: 'set null', + }), + name: text('name').notNull(), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), + updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), + }, + (t) => [ + index('theme_manufacturer_idx').on(t.manufacturerId), + uniqueIndex('theme_unique_name').on(t.manufacturerId, t.name), + ], +); + +/** + * A catalog entry. The set exists in the world, independent of whether we own + * it - ownership is expressed by `collection_item`. + */ +export const set = sqliteTable( + 'set', + { + id: text('id').primaryKey(), + manufacturerId: text('manufacturer_id') + .notNull() + .references(() => manufacturer.id, { onDelete: 'restrict' }), + themeId: text('theme_id').references(() => theme.id, { onDelete: 'set null' }), + /** Manufacturer's own number, e.g. "60398", "71427", "2-30500". */ + setNumber: text('set_number').notNull(), + name: text('name').notNull(), + year: integer('year'), + pieceCount: integer('piece_count'), + minifigCount: integer('minifig_count'), + ean: text('ean'), + ageRange: text('age_range'), + /** Either an external URL the user entered ... */ + imageUrl: text('image_url'), + /** ... or a locally stored file below FILES_DIR. */ + imagePath: text('image_path'), + /** EUR in cents - SQLite has no decimal type and sums must stay exact. */ + retailPriceCents: integer('retail_price_cents'), + externalIds: text('external_ids', { mode: 'json' }).$type>(), + notes: text('notes'), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), + updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), + }, + (t) => [ + uniqueIndex('set_manufacturer_number_unique').on(t.manufacturerId, t.setNumber), + index('set_name_idx').on(t.name), + index('set_ean_idx').on(t.ean), + index('set_theme_idx').on(t.themeId), + index('set_year_idx').on(t.year), + ], +); + +export const tag = sqliteTable('tag', { + id: text('id').primaryKey(), + name: text('name').notNull(), + slug: text('slug').notNull().unique(), +}); + +export const setTag = sqliteTable( + 'set_tag', + { + setId: text('set_id') + .notNull() + .references(() => set.id, { onDelete: 'cascade' }), + tagId: text('tag_id') + .notNull() + .references(() => tag.id, { onDelete: 'cascade' }), + }, + (t) => [primaryKey({ columns: [t.setId, t.tagId] })], +); + +export const instruction = sqliteTable( + 'instruction', + { + id: text('id').primaryKey(), + setId: text('set_id') + .notNull() + .references(() => set.id, { onDelete: 'cascade' }), + kind: text('kind', { enum: ['pdf_upload', 'external_url'] }).notNull(), + /** Set when kind = pdf_upload. */ + filePath: text('file_path'), + /** Set when kind = external_url. */ + url: text('url'), + /** Free text label, e.g. "Buch 1 von 3". */ + label: text('label'), + pageCount: integer('page_count'), + fileSizeBytes: integer('file_size_bytes'), + sortOrder: integer('sort_order').notNull().default(0), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), + }, + (t) => [ + index('instruction_set_idx').on(t.setId), + check( + 'instruction_source', + sql`(${t.kind} = 'pdf_upload' AND ${t.filePath} IS NOT NULL AND ${t.url} IS NULL) + OR (${t.kind} = 'external_url' AND ${t.url} IS NOT NULL AND ${t.filePath} IS NULL)`, + ), + ], +); diff --git a/src/db/schema/collection.ts b/src/db/schema/collection.ts index 503f69f..4144eb7 100644 --- a/src/db/schema/collection.ts +++ b/src/db/schema/collection.ts @@ -1,95 +1,102 @@ -import { sql, type AnyColumn } from 'drizzle-orm'; -import { check, index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'; -import { set } from './catalog'; - -export const location = sqliteTable('location', { - id: text('id').primaryKey(), - /** e.g. "Regal Kinderzimmer", "Kiste Keller 3". */ - name: text('name').notNull(), - parentLocationId: text('parent_location_id').references((): AnyColumn => location.id, { - onDelete: 'set null', - }), - createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), - updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), -}); - -/** - * Ownership: links a catalog set to a copy we actually have. Owning the same - * set twice means two rows. - */ -export const collectionItem = sqliteTable( - 'collection_item', - { - id: text('id').primaryKey(), - setId: text('set_id') - .notNull() - .references(() => set.id, { onDelete: 'cascade' }), - status: text('status', { - enum: ['owned', 'built', 'disassembled', 'lent_out', 'sold'], - }) - .notNull() - .default('owned'), - completeness: text('completeness', { enum: ['complete', 'missing_parts', 'unknown'] }) - .notNull() - .default('unknown'), - condition: text('condition', { - enum: ['new_sealed', 'like_new', 'good', 'used', 'damaged'], - }), - locationId: text('location_id').references(() => location.id, { onDelete: 'set null' }), - acquiredAt: integer('acquired_at', { mode: 'timestamp' }), - acquiredPriceCents: integer('acquired_price_cents'), - acquiredFrom: text('acquired_from'), - giftedBy: text('gifted_by'), - lentOutTo: text('lent_out_to'), - notes: text('notes'), - createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), - updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), - }, - (t) => [ - index('collection_item_set_idx').on(t.setId), - index('collection_item_status_idx').on(t.status), - index('collection_item_location_idx').on(t.locationId), - ], -); - -export const missingPart = sqliteTable( - 'missing_part', - { - id: text('id').primaryKey(), - collectionItemId: text('collection_item_id') - .notNull() - .references(() => collectionItem.id, { onDelete: 'cascade' }), - partNumber: text('part_number'), - colorName: text('color_name'), - quantity: integer('quantity').notNull().default(1), - resolved: integer('resolved', { mode: 'boolean' }).notNull().default(false), - notes: text('notes'), - createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), - }, - (t) => [index('missing_part_item_idx').on(t.collectionItemId)], -); - -/** Belongs to either a catalog set or one owned copy - never to both. */ -export const photo = sqliteTable( - 'photo', - { - id: text('id').primaryKey(), - setId: text('set_id').references(() => set.id, { onDelete: 'cascade' }), - collectionItemId: text('collection_item_id').references(() => collectionItem.id, { - onDelete: 'cascade', - }), - filePath: text('file_path').notNull(), - thumbnailPath: text('thumbnail_path'), - caption: text('caption'), - sortOrder: integer('sort_order').notNull().default(0), - createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), - }, - (t) => [ - index('photo_set_idx').on(t.setId), - index('photo_item_idx').on(t.collectionItemId), - check( - 'photo_single_owner', - sql`(${t.setId} IS NOT NULL) <> (${t.collectionItemId} IS NOT NULL)`, - ), - ], -); +import { sql } from 'drizzle-orm'; +import { + type AnySQLiteColumn, + check, + index, + integer, + sqliteTable, + text, +} from 'drizzle-orm/sqlite-core'; +import { set } from './catalog'; + +export const location = sqliteTable('location', { + id: text('id').primaryKey(), + /** e.g. "Regal Kinderzimmer", "Kiste Keller 3". */ + name: text('name').notNull(), + parentLocationId: text('parent_location_id').references((): AnySQLiteColumn => location.id, { + onDelete: 'set null', + }), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), + updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), +}); + +/** + * Ownership: links a catalog set to a copy we actually have. Owning the same + * set twice means two rows. + */ +export const collectionItem = sqliteTable( + 'collection_item', + { + id: text('id').primaryKey(), + setId: text('set_id') + .notNull() + .references(() => set.id, { onDelete: 'cascade' }), + status: text('status', { + enum: ['owned', 'built', 'disassembled', 'lent_out', 'sold'], + }) + .notNull() + .default('owned'), + completeness: text('completeness', { enum: ['complete', 'missing_parts', 'unknown'] }) + .notNull() + .default('unknown'), + condition: text('condition', { + enum: ['new_sealed', 'like_new', 'good', 'used', 'damaged'], + }), + locationId: text('location_id').references(() => location.id, { onDelete: 'set null' }), + acquiredAt: integer('acquired_at', { mode: 'timestamp' }), + acquiredPriceCents: integer('acquired_price_cents'), + acquiredFrom: text('acquired_from'), + giftedBy: text('gifted_by'), + lentOutTo: text('lent_out_to'), + notes: text('notes'), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), + updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), + }, + (t) => [ + index('collection_item_set_idx').on(t.setId), + index('collection_item_status_idx').on(t.status), + index('collection_item_location_idx').on(t.locationId), + ], +); + +export const missingPart = sqliteTable( + 'missing_part', + { + id: text('id').primaryKey(), + collectionItemId: text('collection_item_id') + .notNull() + .references(() => collectionItem.id, { onDelete: 'cascade' }), + partNumber: text('part_number'), + colorName: text('color_name'), + quantity: integer('quantity').notNull().default(1), + resolved: integer('resolved', { mode: 'boolean' }).notNull().default(false), + notes: text('notes'), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), + }, + (t) => [index('missing_part_item_idx').on(t.collectionItemId)], +); + +/** Belongs to either a catalog set or one owned copy - never to both. */ +export const photo = sqliteTable( + 'photo', + { + id: text('id').primaryKey(), + setId: text('set_id').references(() => set.id, { onDelete: 'cascade' }), + collectionItemId: text('collection_item_id').references(() => collectionItem.id, { + onDelete: 'cascade', + }), + filePath: text('file_path').notNull(), + thumbnailPath: text('thumbnail_path'), + caption: text('caption'), + sortOrder: integer('sort_order').notNull().default(0), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), + }, + (t) => [ + index('photo_set_idx').on(t.setId), + index('photo_item_idx').on(t.collectionItemId), + check( + 'photo_single_owner', + sql`(${t.setId} IS NOT NULL) <> (${t.collectionItemId} IS NOT NULL)`, + ), + ], +); diff --git a/src/lib/auth-client.ts b/src/lib/auth-client.ts new file mode 100644 index 0000000..b8c790e --- /dev/null +++ b/src/lib/auth-client.ts @@ -0,0 +1,14 @@ +'use client'; + +import { passkeyClient } from '@better-auth/passkey/client'; +import { createAuthClient } from 'better-auth/react'; + +/** + * Browser-side auth client. The base URL stays relative so the app works + * behind any reverse proxy without rebuilding. + */ +export const authClient = createAuthClient({ + plugins: [passkeyClient()], +}); + +export const { signIn, signUp, signOut, useSession } = authClient; diff --git a/src/lib/auth.ts b/src/lib/auth.ts new file mode 100644 index 0000000..5fc90d9 --- /dev/null +++ b/src/lib/auth.ts @@ -0,0 +1,122 @@ +import { passkey } from '@better-auth/passkey'; +import { betterAuth } from 'better-auth'; +import { drizzleAdapter } from 'better-auth/adapters/drizzle'; +import { APIError } from 'better-auth/api'; +import { nextCookies } from 'better-auth/next-js'; +import { count } from 'drizzle-orm'; +import { db, schema } from '@/db'; +import { env } from '@/env'; + +/** + * Returns true while the instance has no user at all. + * + * The very first registration creates the admin; afterwards sign-up is closed + * and only works through an invitation (phase 2). + */ +export function isFirstRun(): boolean { + const [row] = db.select({ value: count() }).from(schema.user).all(); + return (row?.value ?? 0) === 0; +} + +export const auth = betterAuth({ + appName: 'Bricktrack', + baseURL: env.BASE_URL, + secret: env.BETTER_AUTH_SECRET, + database: drizzleAdapter(db, { + provider: 'sqlite', + schema: { + user: schema.user, + session: schema.session, + account: schema.account, + verification: schema.verification, + passkey: schema.passkey, + rateLimit: schema.rateLimit, + }, + }), + emailAndPassword: { + enabled: true, + // No mail server in this deployment, so verification links would go nowhere. + requireEmailVerification: false, + minPasswordLength: 10, + autoSignIn: true, + }, + user: { + additionalFields: { + role: { + type: 'string', + required: false, + defaultValue: 'member', + // Never settable through the sign-up payload - only by our own hook. + input: false, + }, + }, + }, + session: { + expiresIn: 60 * 60 * 24 * 30, + updateAge: 60 * 60 * 24, + cookieCache: { enabled: true, maxAge: 5 * 60 }, + }, + trustedOrigins: [env.BASE_URL], + advanced: { + cookiePrefix: 'bricktrack', + useSecureCookies: env.isProduction, + defaultCookieAttributes: { + httpOnly: true, + sameSite: 'lax', + secure: env.isProduction, + }, + // TLS is terminated by the reverse proxy, so the client IP arrives in a header. + ipAddress: { + ipAddressHeaders: ['x-forwarded-for', 'x-real-ip'], + }, + database: { + generateId: () => crypto.randomUUID(), + }, + }, + rateLimit: { + enabled: true, + // Survives container restarts, unlike the default in-memory storage. + storage: 'database', + window: 60, + max: 100, + customRules: { + '/sign-in/email': { window: 60, max: 5 }, + '/sign-up/email': { window: 300, max: 3 }, + '/passkey/generate-authenticate-options': { window: 60, max: 10 }, + '/passkey/verify-authentication': { window: 60, max: 10 }, + }, + }, + databaseHooks: { + user: { + create: { + before: async (user) => { + const firstRun = isFirstRun(); + if (!firstRun) { + // Phase 2 opens this up again for invited users. + throw new APIError('FORBIDDEN', { + message: + 'Die Registrierung ist geschlossen. Bitte lass dir vom Administrator einen Einladungslink schicken.', + }); + } + return { data: { ...user, role: 'admin' as const } }; + }, + }, + }, + }, + plugins: [ + passkey({ + rpID: env.RP_ID, + rpName: env.RP_NAME, + origin: env.BASE_URL, + authenticatorSelection: { + // Discoverable credentials enable the passwordless autofill flow. + residentKey: 'preferred', + userVerification: 'preferred', + }, + }), + // Must stay last: lets server actions write the session cookie. + nextCookies(), + ], +}); + +export type Auth = typeof auth; diff --git a/src/lib/format.ts b/src/lib/format.ts new file mode 100644 index 0000000..ff60812 --- /dev/null +++ b/src/lib/format.ts @@ -0,0 +1,31 @@ +import { format } from 'date-fns'; +import { de } from 'date-fns/locale'; + +/** dd.MM.yyyy - the only date format used in the UI. */ +export function formatDate(value: Date | number | null | undefined): string { + if (value === null || value === undefined) return '–'; + return format(value, 'dd.MM.yyyy'); +} + +export function formatDateTime(value: Date | number | null | undefined): string { + if (value === null || value === undefined) return '–'; + return format(value, 'dd.MM.yyyy, HH:mm', { locale: de }); +} + +/** Value for an , which always expects ISO. */ +export function toDateInputValue(value: Date | null | undefined): string { + if (!value) return ''; + return format(value, 'yyyy-MM-dd'); +} + +const numberFormatter = new Intl.NumberFormat('de-DE'); + +export function formatNumber(value: number | null | undefined): string { + if (value === null || value === undefined) return '–'; + return numberFormatter.format(value); +} + +/** "1 Set" / "12 Sets" - German plural for the few nouns we count. */ +export function pluralize(count: number, singular: string, plural: string): string { + return `${formatNumber(count)} ${count === 1 ? singular : plural}`; +} diff --git a/src/lib/money.ts b/src/lib/money.ts new file mode 100644 index 0000000..9cbce8e --- /dev/null +++ b/src/lib/money.ts @@ -0,0 +1,62 @@ +/** + * Money handling. Amounts live in the database as integer cents; euros only + * exist for display and for what the user types. Every conversion goes through + * this module. + */ + +const currencyFormatter = new Intl.NumberFormat('de-DE', { + style: 'currency', + currency: 'EUR', +}); + +const decimalFormatter = new Intl.NumberFormat('de-DE', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, +}); + +/** "12,99 €" - the canonical display form. */ +export function formatEuro(cents: number | null | undefined): string { + if (cents === null || cents === undefined) return '–'; + return currencyFormatter.format(cents / 100); +} + +/** "12,99" - for prefilling a number input without the currency symbol. */ +export function centsToInput(cents: number | null | undefined): string { + if (cents === null || cents === undefined) return ''; + return decimalFormatter.format(cents / 100); +} + +/** + * Parses user input into cents. Accepts both German and English decimal + * separators as well as thousands separators, because phone keyboards differ. + * Returns null for empty input and NaN-ish garbage. + */ +export function parseEuroInput(value: string | null | undefined): number | null { + if (value === null || value === undefined) return null; + + const trimmed = value.trim().replace(/[€\s]/g, ''); + if (trimmed === '') return null; + + // "1.234,56" -> "1234.56" ; "1,234.56" -> "1234.56" ; "12,99" -> "12.99" + const lastComma = trimmed.lastIndexOf(','); + const lastDot = trimmed.lastIndexOf('.'); + let normalized: string; + + if (lastComma > lastDot) { + normalized = trimmed.replace(/\./g, '').replace(',', '.'); + } else if (lastDot > lastComma) { + normalized = trimmed.replace(/,/g, ''); + } else { + normalized = trimmed; + } + + const parsed = Number.parseFloat(normalized); + if (!Number.isFinite(parsed) || parsed < 0) return null; + + return Math.round(parsed * 100); +} + +/** Sum of a list of optional cent amounts, ignoring the unknown ones. */ +export function sumCents(values: Array): number { + return values.reduce((total, value) => total + (value ?? 0), 0); +} diff --git a/src/lib/session.ts b/src/lib/session.ts new file mode 100644 index 0000000..bf478d0 --- /dev/null +++ b/src/lib/session.ts @@ -0,0 +1,47 @@ +import { headers } from 'next/headers'; +import { redirect } from 'next/navigation'; +import { cache } from 'react'; +import type { UserRole } from '@/db/types'; +import { auth } from './auth'; + +/** + * Reads the current session. Cached per request so several server components + * on the same page share one lookup. + */ +export const getSession = cache(async () => { + return auth.api.getSession({ headers: await headers() }); +}); + +export type SessionUser = { + id: string; + name: string; + email: string; + role: UserRole; +}; + +function toSessionUser(user: { id: string; name: string; email: string; role?: unknown }): SessionUser { + return { + id: user.id, + name: user.name, + email: user.email, + role: user.role === 'admin' ? 'admin' : 'member', + }; +} + +export async function getCurrentUser(): Promise { + const session = await getSession(); + return session ? toSessionUser(session.user) : null; +} + +/** Guard for every page and server action behind the login. */ +export async function requireUser(): Promise { + const user = await getCurrentUser(); + if (!user) redirect('/anmelden'); + return user; +} + +export async function requireAdmin(): Promise { + const user = await requireUser(); + if (user.role !== 'admin') redirect('/'); + return user; +} diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..457809b --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,32 @@ +import { getSessionCookie } from 'better-auth/cookies'; +import { NextResponse, type NextRequest } from 'next/server'; + +/** + * Optimistic route guard: it only checks whether a session cookie is present, + * which keeps the middleware free of database access. The real check happens + * in every page and server action via `requireUser()`. + */ +export function middleware(request: NextRequest) { + const hasSession = getSessionCookie(request, { cookiePrefix: 'bricktrack' }); + if (hasSession) return NextResponse.next(); + + const target = new URL('/anmelden', request.url); + const { pathname, search } = request.nextUrl; + if (pathname !== '/') { + target.searchParams.set('weiter', `${pathname}${search}`); + } + return NextResponse.redirect(target); +} + +export const config = { + matcher: [ + /* + * Everything except the public surface: + * - /anmelden, /registrieren, /einladung (auth pages) + * - /w/... (public wishlists) + * - /api/auth, /api/health, /api/files (handled by their own guards) + * - Next internals and static assets + */ + '/((?!anmelden|registrieren|einladung|w/|api/auth|api/health|api/files|_next/static|_next/image|favicon.ico|icons/|manifest.webmanifest).*)', + ], +}; From af0ae039cbb205202cbb6230de988561706543fd Mon Sep 17 00:00:00 2001 From: unknown Date: Sun, 6 Sep 2026 22:46:45 +0200 Subject: [PATCH 2/9] feat(stammdaten): crud fuer hersteller, themen und standorte Gemeinsame CrudSection fuer die drei kleinen Stammdatentabellen; sie unterscheiden sich nur in den Formularfeldern. Loeschen ist bewusst blockiert, solange Sets am Hersteller oder Thema haengen - Standorte duerfen weg, die Sammlungseintraege verlieren dann nur den Ort. Dazu die gemeinsamen Bausteine: zod-Schemata fuer alle Formulare, einheitliches ActionResult, Slug-Erzeugung mit deutschen Umlauten, Geld als Cent und Datumsformatierung dd.MM.yyyy. Co-Authored-By: Claude Opus 5 (1M context) --- package.json | 141 +++++------ pnpm-lock.yaml | 8 + src/app/(app)/verwaltung/actions.ts | 236 ++++++++++++++++++ src/app/(app)/verwaltung/hersteller/page.tsx | 20 ++ src/app/(app)/verwaltung/layout.tsx | 22 ++ src/app/(app)/verwaltung/standorte/page.tsx | 16 ++ src/app/(app)/verwaltung/themen/page.tsx | 29 +++ src/components/ui/field.tsx | 30 +++ src/components/ui/native-select.tsx | 23 ++ src/components/verwaltung/crud-section.tsx | 211 ++++++++++++++++ src/components/verwaltung/location-list.tsx | 62 +++++ .../verwaltung/manufacturer-list.tsx | 54 ++++ src/components/verwaltung/theme-list.tsx | 76 ++++++ src/components/verwaltung/verwaltung-nav.tsx | 38 +++ src/lib/action-result.ts | 64 +++++ src/lib/queries/master-data.ts | 71 ++++++ src/lib/slug.ts | 32 +++ src/lib/validation.ts | 191 ++++++++++++++ 18 files changed, 1254 insertions(+), 70 deletions(-) create mode 100644 src/app/(app)/verwaltung/actions.ts create mode 100644 src/app/(app)/verwaltung/hersteller/page.tsx create mode 100644 src/app/(app)/verwaltung/layout.tsx create mode 100644 src/app/(app)/verwaltung/standorte/page.tsx create mode 100644 src/app/(app)/verwaltung/themen/page.tsx create mode 100644 src/components/ui/field.tsx create mode 100644 src/components/ui/native-select.tsx create mode 100644 src/components/verwaltung/crud-section.tsx create mode 100644 src/components/verwaltung/location-list.tsx create mode 100644 src/components/verwaltung/manufacturer-list.tsx create mode 100644 src/components/verwaltung/theme-list.tsx create mode 100644 src/components/verwaltung/verwaltung-nav.tsx create mode 100644 src/lib/action-result.ts create mode 100644 src/lib/queries/master-data.ts create mode 100644 src/lib/slug.ts create mode 100644 src/lib/validation.ts diff --git a/package.json b/package.json index a1bcbe5..4cc0ebf 100644 --- a/package.json +++ b/package.json @@ -1,70 +1,71 @@ -{ - "name": "bricktrack", - "version": "0.1.0", - "private": true, - "description": "Self-hosted Verwaltung fuer Spielzeug-Set-Sammlungen (Lego, Playmobil, Cobi, ...)", - "packageManager": "pnpm@10.34.5", - "engines": { - "node": ">=22" - }, - "scripts": { - "dev": "next dev", - "build": "next build", - "start": "next start", - "lint": "eslint .", - "typecheck": "tsc --noEmit", - "db:generate": "drizzle-kit generate", - "db:migrate": "tsx scripts/migrate.ts", - "db:seed": "tsx scripts/seed.ts", - "db:studio": "drizzle-kit studio", - "test": "vitest run", - "test:watch": "vitest", - "test:e2e": "playwright test", - "screenshots": "playwright test --config=playwright.screenshots.config.ts" - }, - "dependencies": { - "@better-auth/passkey": "^1.7.3", - "better-auth": "^1.7.3", - "better-sqlite3": "^13.0.3", - "class-variance-authority": "^0.7.1", - "cn": "^0.2.6", - "date-fns": "^4.4.0", - "drizzle-orm": "^0.45.2", - "lucide-react": "^1.41.0", - "next": "15.5.25", - "next-themes": "^0.4.6", - "radix-ui": "^1.6.7", - "react": "19.1.0", - "react-dom": "19.1.0", - "sharp": "^0.35.4", - "sonner": "^2.0.8", - "tw-animate-css": "^1.4.0", - "zod": "^4.5.4" - }, - "devDependencies": { - "@eslint/eslintrc": "^3", - "@playwright/test": "^1.63.0", - "@tailwindcss/postcss": "^4", - "@types/better-sqlite3": "^9.6.0", - "@types/node": "^20", - "@types/react": "^19", - "@types/react-dom": "^19", - "dotenv": "^17.4.2", - "drizzle-kit": "^0.31.10", - "eslint": "^9", - "eslint-config-next": "15.5.25", - "shadcn": "^4.21.0", - "tailwindcss": "^4", - "tsx": "^4.23.13", - "typescript": "^5", - "vitest": "^5.0.0" - }, - "pnpm": { - "onlyBuiltDependencies": [ - "better-sqlite3", - "esbuild", - "sharp", - "unrs-resolver" - ] - } -} +{ + "name": "bricktrack", + "version": "0.1.0", + "private": true, + "description": "Self-hosted Verwaltung fuer Spielzeug-Set-Sammlungen (Lego, Playmobil, Cobi, ...)", + "packageManager": "pnpm@10.34.5", + "engines": { + "node": ">=22" + }, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "eslint .", + "typecheck": "tsc --noEmit", + "db:generate": "drizzle-kit generate", + "db:migrate": "tsx scripts/migrate.ts", + "db:seed": "tsx scripts/seed.ts", + "db:studio": "drizzle-kit studio", + "test": "vitest run", + "test:watch": "vitest", + "test:e2e": "playwright test", + "screenshots": "playwright test --config=playwright.screenshots.config.ts" + }, + "dependencies": { + "@better-auth/passkey": "^1.7.3", + "better-auth": "^1.7.3", + "better-sqlite3": "^13.0.3", + "class-variance-authority": "^0.7.1", + "cn": "^0.2.6", + "date-fns": "^4.4.0", + "drizzle-orm": "^0.45.2", + "lucide-react": "^1.41.0", + "next": "15.5.25", + "next-themes": "^0.4.6", + "radix-ui": "^1.6.7", + "react": "19.1.0", + "react-dom": "19.1.0", + "server-only": "^0.0.1", + "sharp": "^0.35.4", + "sonner": "^2.0.8", + "tw-animate-css": "^1.4.0", + "zod": "^4.5.4" + }, + "devDependencies": { + "@eslint/eslintrc": "^3", + "@playwright/test": "^1.63.0", + "@tailwindcss/postcss": "^4", + "@types/better-sqlite3": "^9.6.0", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "dotenv": "^17.4.2", + "drizzle-kit": "^0.31.10", + "eslint": "^9", + "eslint-config-next": "15.5.25", + "shadcn": "^4.21.0", + "tailwindcss": "^4", + "tsx": "^4.23.13", + "typescript": "^5", + "vitest": "^5.0.0" + }, + "pnpm": { + "onlyBuiltDependencies": [ + "better-sqlite3", + "esbuild", + "sharp", + "unrs-resolver" + ] + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d3989d8..d200f5d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,6 +47,9 @@ importers: react-dom: specifier: 19.1.0 version: 19.1.0(react@19.1.0) + server-only: + specifier: ^0.0.1 + version: 0.0.1 sharp: specifier: ^0.35.4 version: 0.35.4(@types/node@20.19.43) @@ -4417,6 +4420,9 @@ packages: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} + server-only@0.0.1: + resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} + set-cookie-parser@3.1.2: resolution: {integrity: sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==} @@ -9111,6 +9117,8 @@ snapshots: transitivePeerDependencies: - supports-color + server-only@0.0.1: {} + set-cookie-parser@3.1.2: {} set-function-length@1.2.2: diff --git a/src/app/(app)/verwaltung/actions.ts b/src/app/(app)/verwaltung/actions.ts new file mode 100644 index 0000000..8751fe9 --- /dev/null +++ b/src/app/(app)/verwaltung/actions.ts @@ -0,0 +1,236 @@ +'use server'; + +import { revalidatePath } from 'next/cache'; +import { and, eq, ne } from 'drizzle-orm'; +import { db } from '@/db'; +import { location, manufacturer, set, theme } from '@/db/schema'; +import { actionError, actionOk, parseForm, type ActionResult } from '@/lib/action-result'; +import { requireUser } from '@/lib/session'; +import { uniqueSlug } from '@/lib/slug'; +import { locationSchema, manufacturerSchema, themeSchema } from '@/lib/validation'; + +function now() { + return new Date(); +} + +function revalidateMasterData() { + revalidatePath('/verwaltung/hersteller'); + revalidatePath('/verwaltung/themen'); + revalidatePath('/verwaltung/standorte'); + revalidatePath('/katalog'); +} + +// --------------------------------------------------------------------------- +// Manufacturers +// --------------------------------------------------------------------------- + +export async function createManufacturer(input: unknown): Promise { + await requireUser(); + const parsed = parseForm(manufacturerSchema, input); + if (!parsed.ok) return parsed; + + const taken = db.select({ slug: manufacturer.slug }).from(manufacturer).all(); + const exists = db + .select({ id: manufacturer.id }) + .from(manufacturer) + .where(eq(manufacturer.name, parsed.data.name)) + .get(); + if (exists) return actionError('Diesen Hersteller gibt es schon.'); + + db.insert(manufacturer) + .values({ + id: crypto.randomUUID(), + name: parsed.data.name, + slug: uniqueSlug( + parsed.data.name, + taken.map((row) => row.slug), + ), + website: parsed.data.website, + createdAt: now(), + updatedAt: now(), + }) + .run(); + + revalidateMasterData(); + return actionOk(); +} + +export async function updateManufacturer(id: string, input: unknown): Promise { + await requireUser(); + const parsed = parseForm(manufacturerSchema, input); + if (!parsed.ok) return parsed; + + const clash = db + .select({ id: manufacturer.id }) + .from(manufacturer) + .where(and(eq(manufacturer.name, parsed.data.name), ne(manufacturer.id, id))) + .get(); + if (clash) return actionError('Ein anderer Hersteller heißt bereits so.'); + + db.update(manufacturer) + .set({ name: parsed.data.name, website: parsed.data.website, updatedAt: now() }) + .where(eq(manufacturer.id, id)) + .run(); + + revalidateMasterData(); + return actionOk(); +} + +export async function deleteManufacturer(id: string): Promise { + await requireUser(); + + const used = db.select({ id: set.id }).from(set).where(eq(set.manufacturerId, id)).get(); + if (used) { + return actionError( + 'Der Hersteller wird noch von mindestens einem Set verwendet und kann deshalb nicht gelöscht werden.', + ); + } + + db.delete(manufacturer).where(eq(manufacturer.id, id)).run(); + revalidateMasterData(); + return actionOk(); +} + +// --------------------------------------------------------------------------- +// Themes +// --------------------------------------------------------------------------- + +export async function createTheme(input: unknown): Promise { + await requireUser(); + const parsed = parseForm(themeSchema, input); + if (!parsed.ok) return parsed; + + const exists = db + .select({ id: theme.id }) + .from(theme) + .where( + and(eq(theme.manufacturerId, parsed.data.manufacturerId), eq(theme.name, parsed.data.name)), + ) + .get(); + if (exists) return actionError('Dieses Thema gibt es beim Hersteller schon.'); + + db.insert(theme) + .values({ + id: crypto.randomUUID(), + manufacturerId: parsed.data.manufacturerId, + name: parsed.data.name, + parentThemeId: parsed.data.parentThemeId, + createdAt: now(), + updatedAt: now(), + }) + .run(); + + revalidateMasterData(); + return actionOk(); +} + +export async function updateTheme(id: string, input: unknown): Promise { + await requireUser(); + const parsed = parseForm(themeSchema, input); + if (!parsed.ok) return parsed; + + if (parsed.data.parentThemeId === id) { + return actionError('Ein Thema kann nicht sein eigenes Oberthema sein.'); + } + + const clash = db + .select({ id: theme.id }) + .from(theme) + .where( + and( + eq(theme.manufacturerId, parsed.data.manufacturerId), + eq(theme.name, parsed.data.name), + ne(theme.id, id), + ), + ) + .get(); + if (clash) return actionError('Ein anderes Thema dieses Herstellers heißt bereits so.'); + + db.update(theme) + .set({ + manufacturerId: parsed.data.manufacturerId, + name: parsed.data.name, + parentThemeId: parsed.data.parentThemeId, + updatedAt: now(), + }) + .where(eq(theme.id, id)) + .run(); + + revalidateMasterData(); + return actionOk(); +} + +export async function deleteTheme(id: string): Promise { + await requireUser(); + + // Sets keep existing without a theme (ON DELETE SET NULL), but silently + // unthemeing a dozen sets would be a surprise - so we ask for a decision. + const used = db.select({ id: set.id }).from(set).where(eq(set.themeId, id)).get(); + if (used) { + return actionError( + 'Dem Thema sind noch Sets zugeordnet. Ordne sie zuerst um, dann lässt sich das Thema löschen.', + ); + } + + db.delete(theme).where(eq(theme.id, id)).run(); + revalidateMasterData(); + return actionOk(); +} + +// --------------------------------------------------------------------------- +// Locations +// --------------------------------------------------------------------------- + +export async function createLocation(input: unknown): Promise { + await requireUser(); + const parsed = parseForm(locationSchema, input); + if (!parsed.ok) return parsed; + + db.insert(location) + .values({ + id: crypto.randomUUID(), + name: parsed.data.name, + parentLocationId: parsed.data.parentLocationId, + createdAt: now(), + updatedAt: now(), + }) + .run(); + + revalidatePath('/verwaltung/standorte'); + revalidatePath('/sammlung'); + return actionOk(); +} + +export async function updateLocation(id: string, input: unknown): Promise { + await requireUser(); + const parsed = parseForm(locationSchema, input); + if (!parsed.ok) return parsed; + + if (parsed.data.parentLocationId === id) { + return actionError('Ein Standort kann nicht in sich selbst liegen.'); + } + + db.update(location) + .set({ + name: parsed.data.name, + parentLocationId: parsed.data.parentLocationId, + updatedAt: now(), + }) + .where(eq(location.id, id)) + .run(); + + revalidatePath('/verwaltung/standorte'); + revalidatePath('/sammlung'); + return actionOk(); +} + +export async function deleteLocation(id: string): Promise { + await requireUser(); + + // Collection items only lose their location (ON DELETE SET NULL). + db.delete(location).where(eq(location.id, id)).run(); + + revalidatePath('/verwaltung/standorte'); + revalidatePath('/sammlung'); + return actionOk(); +} diff --git a/src/app/(app)/verwaltung/hersteller/page.tsx b/src/app/(app)/verwaltung/hersteller/page.tsx new file mode 100644 index 0000000..e6f8057 --- /dev/null +++ b/src/app/(app)/verwaltung/hersteller/page.tsx @@ -0,0 +1,20 @@ +import type { Metadata } from 'next'; +import { ManufacturerList, type ManufacturerRow } from '@/components/verwaltung/manufacturer-list'; +import { pluralize } from '@/lib/format'; +import { listManufacturers } from '@/lib/queries/master-data'; + +export const metadata: Metadata = { title: 'Hersteller' }; + +export default function HerstellerPage() { + const rows: ManufacturerRow[] = listManufacturers().map((row) => ({ + id: row.id, + title: row.name, + subtitle: [pluralize(row.setCount, 'Set', 'Sets'), row.website] + .filter(Boolean) + .join(' · '), + website: row.website, + setCount: row.setCount, + })); + + return ; +} diff --git a/src/app/(app)/verwaltung/layout.tsx b/src/app/(app)/verwaltung/layout.tsx new file mode 100644 index 0000000..7a57af8 --- /dev/null +++ b/src/app/(app)/verwaltung/layout.tsx @@ -0,0 +1,22 @@ +import type { Metadata } from 'next'; +import { VerwaltungNav } from '@/components/verwaltung/verwaltung-nav'; +import { requireUser } from '@/lib/session'; + +export const metadata: Metadata = { title: 'Verwaltung' }; + +export default async function VerwaltungLayout({ children }: { children: React.ReactNode }) { + await requireUser(); + + return ( +
+
+

Verwaltung

+

+ Stammdaten, die im Katalog und in der Sammlung ausgewählt werden. +

+
+ + {children} +
+ ); +} diff --git a/src/app/(app)/verwaltung/standorte/page.tsx b/src/app/(app)/verwaltung/standorte/page.tsx new file mode 100644 index 0000000..36c97e8 --- /dev/null +++ b/src/app/(app)/verwaltung/standorte/page.tsx @@ -0,0 +1,16 @@ +import type { Metadata } from 'next'; +import { LocationList, type LocationRow } from '@/components/verwaltung/location-list'; +import { listLocations } from '@/lib/queries/master-data'; + +export const metadata: Metadata = { title: 'Standorte' }; + +export default function StandortePage() { + const rows: LocationRow[] = listLocations().map((row) => ({ + id: row.id, + title: row.name, + subtitle: row.parentName ? `in ${row.parentName}` : null, + parentLocationId: row.parentLocationId, + })); + + return ; +} diff --git a/src/app/(app)/verwaltung/themen/page.tsx b/src/app/(app)/verwaltung/themen/page.tsx new file mode 100644 index 0000000..9bccbb0 --- /dev/null +++ b/src/app/(app)/verwaltung/themen/page.tsx @@ -0,0 +1,29 @@ +import type { Metadata } from 'next'; +import { ThemeList, type ThemeRow } from '@/components/verwaltung/theme-list'; +import { listManufacturers, listThemes } from '@/lib/queries/master-data'; + +export const metadata: Metadata = { title: 'Themen' }; + +export default function ThemenPage() { + const manufacturers = listManufacturers().map((row) => ({ id: row.id, name: row.name })); + + const rows: ThemeRow[] = listThemes().map((row) => ({ + id: row.id, + title: row.name, + subtitle: [row.manufacturerName, row.parentName ? `in ${row.parentName}` : null] + .filter(Boolean) + .join(' · '), + manufacturerId: row.manufacturerId, + parentThemeId: row.parentThemeId, + })); + + if (manufacturers.length === 0) { + return ( +

+ Themen gehören immer zu einem Hersteller. Leg zuerst einen Hersteller an. +

+ ); + } + + return ; +} diff --git a/src/components/ui/field.tsx b/src/components/ui/field.tsx new file mode 100644 index 0000000..84b9414 --- /dev/null +++ b/src/components/ui/field.tsx @@ -0,0 +1,30 @@ +import { cn } from 'cn'; +import { Label } from './label'; + +/** Label + control + optional hint, the layout every form in the app uses. */ +export function Field({ + label, + htmlFor, + hint, + required, + className, + children, +}: { + label: string; + htmlFor: string; + hint?: string; + required?: boolean; + className?: string; + children: React.ReactNode; +}) { + return ( +
+ + {children} + {hint ?

{hint}

: null} +
+ ); +} diff --git a/src/components/ui/native-select.tsx b/src/components/ui/native-select.tsx new file mode 100644 index 0000000..1e9d4cd --- /dev/null +++ b/src/components/ui/native-select.tsx @@ -0,0 +1,23 @@ +import { cn } from 'cn'; + +/** + * A plain + ); +} diff --git a/src/components/verwaltung/crud-section.tsx b/src/components/verwaltung/crud-section.tsx new file mode 100644 index 0000000..2307805 --- /dev/null +++ b/src/components/verwaltung/crud-section.tsx @@ -0,0 +1,211 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { useState, useTransition } from 'react'; +import { Loader2, Pencil, Plus, Trash2 } from 'lucide-react'; +import { toast } from 'sonner'; +import type { ActionResult } from '@/lib/action-result'; +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'; + +export type CrudItem = { + id: string; + title: string; + subtitle?: string | null; +}; + +type Props = { + items: T[]; + emptyText: string; + createLabel: string; + dialogTitle: (item: T | null) => string; + dialogDescription?: string; + /** Form fields for the create/edit dialog. `item` is null when creating. */ + renderFields: (item: T | null) => React.ReactNode; + onCreate: (input: Record) => Promise; + onUpdate: (id: string, input: Record) => Promise; + onDelete: (id: string) => Promise; + deleteWarning: (item: T) => string; +}; + +/** + * Shared list + create/edit/delete UI for the small master-data tables + * (manufacturers, themes, locations). They differ only in their form fields. + */ +export function CrudSection({ + items, + emptyText, + createLabel, + dialogTitle, + dialogDescription, + renderFields, + onCreate, + onUpdate, + onDelete, + deleteWarning, +}: Props) { + const router = useRouter(); + const [editing, setEditing] = useState(null); + const [dialogOpen, setDialogOpen] = useState(false); + const [deleting, setDeleting] = useState(null); + const [pending, startTransition] = useTransition(); + + function openCreate() { + setEditing(null); + setDialogOpen(true); + } + + function openEdit(item: T) { + setEditing(item); + setDialogOpen(true); + } + + function submit(event: React.FormEvent) { + event.preventDefault(); + const formData = new FormData(event.currentTarget); + const input = Object.fromEntries(formData.entries()); + const current = editing; + + startTransition(async () => { + const result = current ? await onUpdate(current.id, input) : await onCreate(input); + if (!result.ok) { + toast.error(result.error); + return; + } + toast.success(current ? 'Gespeichert.' : 'Angelegt.'); + setDialogOpen(false); + setEditing(null); + router.refresh(); + }); + } + + function confirmDelete() { + const item = deleting; + if (!item) return; + + startTransition(async () => { + const result = await onDelete(item.id); + if (!result.ok) { + toast.error(result.error); + setDeleting(null); + return; + } + toast.success('Gelöscht.'); + setDeleting(null); + router.refresh(); + }); + } + + return ( +
+ + + {items.length === 0 ? ( +

+ {emptyText} +

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

    {item.title}

    + {item.subtitle ? ( +

    {item.subtitle}

    + ) : null} +
    + + +
  • + ))} +
+ )} + + { + setDialogOpen(open); + if (!open) setEditing(null); + }} + > + + + {dialogTitle(editing)} + {dialogDescription ? ( + {dialogDescription} + ) : null} + + {/* Remounting on change resets defaultValue-based fields. */} +
+ {renderFields(editing)} +
+ + + + +
+
+ + !open && setDeleting(null)}> + + + Wirklich löschen? + + {deleting ? deleteWarning(deleting) : ''} + + + + Abbrechen + Löschen + + + +
+ ); +} diff --git a/src/components/verwaltung/location-list.tsx b/src/components/verwaltung/location-list.tsx new file mode 100644 index 0000000..c0cc7bb --- /dev/null +++ b/src/components/verwaltung/location-list.tsx @@ -0,0 +1,62 @@ +'use client'; + +import { createLocation, deleteLocation, updateLocation } from '@/app/(app)/verwaltung/actions'; +import { CrudSection, type CrudItem } from './crud-section'; +import { Field } from '@/components/ui/field'; +import { Input } from '@/components/ui/input'; +import { NativeSelect } from '@/components/ui/native-select'; + +export type LocationRow = CrudItem & { + parentLocationId: string | null; +}; + +export function LocationList({ locations }: { locations: LocationRow[] }) { + return ( + + items={locations} + emptyText="Noch kein Standort angelegt – zum Beispiel „Regal Kinderzimmer“ oder „Kiste Keller 3“." + createLabel="Standort anlegen" + dialogTitle={(item) => (item ? 'Standort bearbeiten' : 'Neuer Standort')} + onCreate={createLocation} + onUpdate={updateLocation} + onDelete={deleteLocation} + deleteWarning={(item) => + `„${item.title}“ wird gelöscht. Sets an diesem Standort bleiben erhalten und haben danach keinen Standort mehr.` + } + renderFields={(item) => ( + <> + + + + + + + {locations + .filter((candidate) => candidate.id !== item?.id) + .map((candidate) => ( + + ))} + + + + )} + /> + ); +} diff --git a/src/components/verwaltung/manufacturer-list.tsx b/src/components/verwaltung/manufacturer-list.tsx new file mode 100644 index 0000000..1eacb7d --- /dev/null +++ b/src/components/verwaltung/manufacturer-list.tsx @@ -0,0 +1,54 @@ +'use client'; + +import { + createManufacturer, + deleteManufacturer, + updateManufacturer, +} from '@/app/(app)/verwaltung/actions'; +import { CrudSection, type CrudItem } from './crud-section'; +import { Field } from '@/components/ui/field'; +import { Input } from '@/components/ui/input'; + +export type ManufacturerRow = CrudItem & { + website: string | null; + setCount: number; +}; + +export function ManufacturerList({ manufacturers }: { manufacturers: ManufacturerRow[] }) { + return ( + + items={manufacturers} + emptyText="Noch kein Hersteller angelegt. Lego, Playmobil, Cobi – leg einfach los." + createLabel="Hersteller anlegen" + dialogTitle={(item) => (item ? 'Hersteller bearbeiten' : 'Neuer Hersteller')} + dialogDescription="Der Name erscheint als Badge auf jeder Set-Kachel." + onCreate={createManufacturer} + onUpdate={updateManufacturer} + onDelete={deleteManufacturer} + deleteWarning={(item) => + `„${item.title}“ wird gelöscht. Das geht nur, solange kein Set diesen Hersteller nutzt.` + } + renderFields={(item) => ( + <> + + + + + + + + )} + /> + ); +} diff --git a/src/components/verwaltung/theme-list.tsx b/src/components/verwaltung/theme-list.tsx new file mode 100644 index 0000000..1c15b02 --- /dev/null +++ b/src/components/verwaltung/theme-list.tsx @@ -0,0 +1,76 @@ +'use client'; + +import { createTheme, deleteTheme, updateTheme } from '@/app/(app)/verwaltung/actions'; +import { CrudSection, type CrudItem } from './crud-section'; +import { Field } from '@/components/ui/field'; +import { Input } from '@/components/ui/input'; +import { NativeSelect } from '@/components/ui/native-select'; + +export type ThemeRow = CrudItem & { + manufacturerId: string; + parentThemeId: string | null; +}; + +export function ThemeList({ + themes, + manufacturers, +}: { + themes: ThemeRow[]; + manufacturers: Array<{ id: string; name: string }>; +}) { + return ( + + items={themes} + emptyText="Noch kein Thema angelegt. Themen sind Serien wie „City“, „Technic“ oder „Novelmore“." + createLabel="Thema anlegen" + dialogTitle={(item) => (item ? 'Thema bearbeiten' : 'Neues Thema')} + onCreate={createTheme} + onUpdate={updateTheme} + onDelete={deleteTheme} + deleteWarning={(item) => + `„${item.title}“ wird gelöscht. Das geht nur, solange dem Thema kein Set zugeordnet ist.` + } + renderFields={(item) => ( + <> + + + {manufacturers.map((manufacturer) => ( + + ))} + + + + + + + + + {themes + .filter((candidate) => candidate.id !== item?.id) + .map((candidate) => ( + + ))} + + + + )} + /> + ); +} diff --git a/src/components/verwaltung/verwaltung-nav.tsx b/src/components/verwaltung/verwaltung-nav.tsx new file mode 100644 index 0000000..fd83275 --- /dev/null +++ b/src/components/verwaltung/verwaltung-nav.tsx @@ -0,0 +1,38 @@ +'use client'; + +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import { cn } from 'cn'; + +const tabs = [ + { href: '/verwaltung/hersteller', label: 'Hersteller' }, + { href: '/verwaltung/themen', label: 'Themen' }, + { href: '/verwaltung/standorte', label: 'Standorte' }, +] as const; + +export function VerwaltungNav() { + const pathname = usePathname(); + + return ( + + ); +} diff --git a/src/lib/action-result.ts b/src/lib/action-result.ts new file mode 100644 index 0000000..32d51cb --- /dev/null +++ b/src/lib/action-result.ts @@ -0,0 +1,64 @@ +import type { z } from 'zod'; + +/** + * Uniform return shape for every server action, so the client always knows + * where to find the error message and never has to catch exceptions. + */ +export type ActionResult = + | ({ ok: true } & (T extends undefined ? { data?: undefined } : { data: T })) + | { ok: false; error: string; fieldErrors?: Record }; + +export function actionError(error: string, fieldErrors?: Record) { + return { ok: false as const, error, fieldErrors }; +} + +export function actionOk(): { ok: true } { + return { ok: true }; +} + +export function actionData(data: T): { ok: true; data: T } { + return { ok: true, data }; +} + +/** Turns FormData into a plain object; repeated keys become arrays. */ +export function formToObject(formData: FormData): Record { + const result: Record = {}; + + for (const [key, value] of formData.entries()) { + if (value instanceof File) continue; + const existing = result[key]; + if (existing === undefined) { + result[key] = value; + } else if (Array.isArray(existing)) { + existing.push(value); + } else { + result[key] = [existing, value]; + } + } + + return result; +} + +/** + * Runs a zod schema against form input and maps the first message per field + * into a shape the forms can render. + */ +export function parseForm( + schema: Schema, + input: unknown, +): { ok: true; data: z.output } | { ok: false; error: string; fieldErrors: Record } { + const parsed = schema.safeParse(input); + if (parsed.success) return { ok: true, data: parsed.data }; + + const fieldErrors: Record = {}; + for (const issue of parsed.error.issues) { + const key = issue.path.join('.') || '_'; + fieldErrors[key] ??= issue.message; + } + + return { + ok: false, + error: Object.values(fieldErrors)[0] ?? 'Die Eingaben sind unvollständig.', + fieldErrors, + }; +} diff --git a/src/lib/queries/master-data.ts b/src/lib/queries/master-data.ts new file mode 100644 index 0000000..d4d902c --- /dev/null +++ b/src/lib/queries/master-data.ts @@ -0,0 +1,71 @@ +import 'server-only'; +import { asc, count, eq, sql } from 'drizzle-orm'; +import { db } from '@/db'; +import { location, manufacturer, set, theme } from '@/db/schema'; + +export function listManufacturers() { + return db + .select({ + id: manufacturer.id, + name: manufacturer.name, + slug: manufacturer.slug, + website: manufacturer.website, + setCount: count(set.id), + }) + .from(manufacturer) + .leftJoin(set, eq(set.manufacturerId, manufacturer.id)) + .groupBy(manufacturer.id) + .orderBy(asc(manufacturer.name)) + .all(); +} + +export function listThemes() { + const parent = sql`( + select parent.name from theme as parent where parent.id = ${theme.parentThemeId} + )`.as('parent_name'); + + return db + .select({ + id: theme.id, + name: theme.name, + manufacturerId: theme.manufacturerId, + manufacturerName: manufacturer.name, + parentThemeId: theme.parentThemeId, + parentName: parent, + }) + .from(theme) + .innerJoin(manufacturer, eq(manufacturer.id, theme.manufacturerId)) + .orderBy(asc(manufacturer.name), asc(theme.name)) + .all(); +} + +/** Themes of one manufacturer - used by the set form and the catalog filter. */ +export function listThemesByManufacturer(manufacturerId: string) { + return db + .select({ id: theme.id, name: theme.name }) + .from(theme) + .where(eq(theme.manufacturerId, manufacturerId)) + .orderBy(asc(theme.name)) + .all(); +} + +export function listLocations() { + const parent = sql`( + select parent.name from location as parent where parent.id = ${location.parentLocationId} + )`.as('parent_name'); + + return db + .select({ + id: location.id, + name: location.name, + parentLocationId: location.parentLocationId, + parentName: parent, + }) + .from(location) + .orderBy(asc(location.name)) + .all(); +} + +export type ManufacturerListRow = ReturnType[number]; +export type ThemeListRow = ReturnType[number]; +export type LocationListRow = ReturnType[number]; diff --git a/src/lib/slug.ts b/src/lib/slug.ts new file mode 100644 index 0000000..1a05d2f --- /dev/null +++ b/src/lib/slug.ts @@ -0,0 +1,32 @@ +/** + * URL-safe slug from arbitrary user input. German umlauts are spelled out + * before the accents are stripped, so "Grün" becomes "gruen" and not "grun". + */ +export function slugify(value: string): string { + return value + .toLowerCase() + .replace(/ä/g, 'ae') + .replace(/ö/g, 'oe') + .replace(/ü/g, 'ue') + .replace(/ß/g, 'ss') + .normalize('NFD') + // Combining diacritical marks left over from the decomposition. + .replace(/[̀-ͯ]/g, '') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 60); +} + +/** + * Appends a counter until the slug is free. `taken` holds the slugs that + * already exist in the same table. + */ +export function uniqueSlug(base: string, taken: Iterable): string { + const existing = new Set(taken); + const seed = slugify(base) || 'eintrag'; + if (!existing.has(seed)) return seed; + + let counter = 2; + while (existing.has(`${seed}-${counter}`)) counter += 1; + return `${seed}-${counter}`; +} diff --git a/src/lib/validation.ts b/src/lib/validation.ts new file mode 100644 index 0000000..82306c7 --- /dev/null +++ b/src/lib/validation.ts @@ -0,0 +1,191 @@ +import { z } from 'zod'; +import { parseEuroInput } from './money'; + +/** + * Server-side validation for every form in the app. + * + * All input arrives as FormData, i.e. as strings, so the schemas do the + * coercion as well: empty fields become null, prices become cents, dates + * become Date objects. + */ + +/** Empty text fields must not be stored as empty strings. */ +export const optionalText = (max = 500) => + z + .string() + .trim() + .max(max, `Höchstens ${max} Zeichen.`) + .nullish() + .transform((value) => (value && value.length > 0 ? value : null)); + +export const optionalUrl = z + .string() + .trim() + .nullish() + .transform((value) => (value && value.length > 0 ? value : null)) + .refine( + (value) => value === null || /^https?:\/\/\S+$/i.test(value), + 'Bitte eine vollständige Adresse mit http:// oder https:// angeben.', + ); + +/** A select that may be left on "keine Auswahl". */ +export const optionalId = z + .string() + .trim() + .nullish() + .transform((value) => (value && value.length > 0 && value !== 'none' ? value : null)); + +export const optionalInt = (min: number, max: number) => + z + .string() + .trim() + .nullish() + .transform((value) => (value && value.length > 0 ? Number(value) : null)) + .refine( + (value) => value === null || (Number.isInteger(value) && value >= min && value <= max), + `Bitte eine ganze Zahl zwischen ${min} und ${max} angeben.`, + ); + +/** Money arrives as "12,99" or "12.99" and is stored as cents. */ +export const optionalPriceCents = z + .string() + .nullish() + .transform((value) => parseEuroInput(value ?? null)) + .refine((value) => value === null || value <= 100_000_00, 'Der Betrag ist unrealistisch hoch.'); + +export const optionalDate = z + .string() + .trim() + .nullish() + .transform((value) => (value && value.length > 0 ? new Date(`${value}T12:00:00`) : null)) + .refine( + (value) => value === null || !Number.isNaN(value.getTime()), + 'Bitte ein gültiges Datum angeben.', + ); + +export const checkbox = z + .union([z.literal('on'), z.literal('true'), z.literal('false'), z.literal('')]) + .nullish() + .transform((value) => value === 'on' || value === 'true'); + +// --------------------------------------------------------------------------- +// Master data +// --------------------------------------------------------------------------- + +export const manufacturerSchema = z.object({ + name: z.string().trim().min(1, 'Bitte einen Namen angeben.').max(80), + website: optionalUrl, +}); + +export const themeSchema = z.object({ + manufacturerId: z.string().trim().min(1, 'Bitte einen Hersteller wählen.'), + name: z.string().trim().min(1, 'Bitte einen Namen angeben.').max(80), + parentThemeId: optionalId, +}); + +export const locationSchema = z.object({ + name: z.string().trim().min(1, 'Bitte einen Namen angeben.').max(80), + parentLocationId: optionalId, +}); + +// --------------------------------------------------------------------------- +// Catalog +// --------------------------------------------------------------------------- + +const currentYear = new Date().getFullYear(); + +export const setSchema = z.object({ + manufacturerId: z.string().trim().min(1, 'Bitte einen Hersteller wählen.'), + themeId: optionalId, + setNumber: z.string().trim().min(1, 'Bitte die Setnummer angeben.').max(40), + name: z.string().trim().min(1, 'Bitte einen Namen angeben.').max(160), + year: optionalInt(1930, currentYear + 5), + pieceCount: optionalInt(0, 100_000), + minifigCount: optionalInt(0, 500), + ean: optionalText(20), + ageRange: optionalText(20), + imageUrl: optionalUrl, + retailPriceCents: optionalPriceCents, + notes: optionalText(2000), +}); + +// --------------------------------------------------------------------------- +// Collection +// --------------------------------------------------------------------------- + +export const collectionStatusEnum = z.enum([ + 'owned', + 'built', + 'disassembled', + 'lent_out', + 'sold', +]); + +export const completenessEnum = z.enum(['complete', 'missing_parts', 'unknown']); + +export const conditionEnum = z.enum(['new_sealed', 'like_new', 'good', 'used', 'damaged']); + +export const collectionItemSchema = z.object({ + setId: z.string().trim().min(1), + status: collectionStatusEnum.default('owned'), + completeness: completenessEnum.default('unknown'), + condition: z + .string() + .trim() + .nullish() + .transform((value) => (value && value.length > 0 && value !== 'none' ? value : null)) + .refine( + (value) => value === null || conditionEnum.safeParse(value).success, + 'Unbekannter Zustand.', + ), + locationId: optionalId, + acquiredAt: optionalDate, + acquiredPriceCents: optionalPriceCents, + acquiredFrom: optionalText(120), + giftedBy: optionalText(120), + lentOutTo: optionalText(120), + notes: optionalText(2000), +}); + +// --------------------------------------------------------------------------- +// Wishlists +// --------------------------------------------------------------------------- + +export const occasionEnum = z.enum(['christmas', 'birthday', 'easter', 'other']); + +export const wishlistSchema = z.object({ + title: z.string().trim().min(1, 'Bitte einen Titel angeben.').max(80), + occasion: occasionEnum.default('other'), + eventDate: optionalDate, + note: optionalText(1000), + isPublic: checkbox, + surpriseMode: checkbox, +}); + +export const wishlistItemSchema = z.object({ + priority: z.coerce.number().int().min(1).max(3).default(2), + note: optionalText(500), +}); + +/** Bulk add from the "fehlende Sets" selection. */ +export const addSetsToWishlistSchema = z.object({ + wishlistId: z.string().trim().min(1, 'Bitte eine Wunschliste wählen.'), + setIds: z.array(z.string().trim().min(1)).min(1, 'Bitte mindestens ein Set auswählen.'), + priority: z.coerce.number().int().min(1).max(3).default(2), +}); + +export const reservationSchema = z.object({ + itemId: z.string().trim().min(1), + name: z + .string() + .trim() + .min(2, 'Bitte trag deinen Namen ein, damit klar ist, wer das Geschenk übernimmt.') + .max(60), +}); + +export type ManufacturerInput = z.infer; +export type ThemeInput = z.infer; +export type LocationInput = z.infer; +export type SetInput = z.infer; +export type CollectionItemInput = z.infer; +export type WishlistInput = z.infer; From 856800b4e48a0c9d27355574cb8c31f384981be5 Mon Sep 17 00:00:00 2001 From: unknown Date: Sun, 6 Sep 2026 22:59:26 +0200 Subject: [PATCH 3/9] feat(katalog): set-katalog mit filtern, detailseite und uploads Kachelliste mit Suche ueber Setnummer, Name und EAN sowie Filtern nach Hersteller, Thema, Jahr und Besitzstatus. Besitz kommt aus genau einer Stelle: src/lib/ownership.ts definiert "hat er nicht" als Katalogeintrag ohne collection_item mit Status ungleich sold, als korrelierte Subquery, damit mehrere Exemplare die Liste nicht verdoppeln. Detailseite mit Metadaten, Anleitungen (PDF-Upload plus pdf.js-Viewer ohne CDN, oder externer Link), Fotos, Sammlungseintraegen und den Aktionen "Zur Sammlung" und "Auf Wunschliste". Upload-Pipeline prueft Magic Bytes statt des gemeldeten MIME-Typs, erzeugt Dateinamen serverseitig, wandelt Bilder in WebP inklusive Thumbnail und liefert Dateien ueber /api/files aus - Set-Bilder oeffentlich, Fotos und PDFs nur mit Session. Dazu das Seed-Skript mit 3 Herstellern, 6 Themen, 10 Sets und 4 Sammlungseintraegen. Co-Authored-By: Claude Opus 5 (1M context) --- package.json | 1 + pnpm-lock.yaml | 134 ++++++ scripts/seed.ts | 398 ++++++++++++++++++ .../(app)/katalog/[id]/bearbeiten/page.tsx | 58 +++ src/app/(app)/katalog/[id]/page.tsx | 192 +++++++++ src/app/(app)/katalog/actions.ts | 291 +++++++++++++ src/app/(app)/katalog/neu/page.tsx | 51 +++ src/app/(app)/katalog/page.tsx | 83 ++++ src/app/(app)/sammlung/actions.ts | 105 +++++ src/app/(app)/wunschlisten/actions.ts | 254 +++++++++++ src/app/api/files/[...path]/route.ts | 55 +++ src/components/katalog/catalog-filters.tsx | 148 +++++++ src/components/katalog/catalog-grid.tsx | 306 ++++++++++++++ .../katalog/instructions-section.tsx | 215 ++++++++++ src/components/katalog/pagination.tsx | 54 +++ src/components/katalog/pdf-viewer.tsx | 155 +++++++ src/components/katalog/photos-section.tsx | 138 ++++++ src/components/katalog/set-actions.tsx | 216 ++++++++++ src/components/katalog/set-card.tsx | 88 ++++ src/components/katalog/set-form.tsx | 248 +++++++++++ .../sammlung/collection-items-section.tsx | 302 +++++++++++++ src/lib/images.ts | 16 + src/lib/labels.ts | 48 +++ src/lib/ownership.ts | 75 ++++ src/lib/queries/catalog.ts | 209 +++++++++ src/lib/queries/wishlists.ts | 161 +++++++ src/lib/tokens.ts | 35 ++ src/lib/uploads.ts | 160 +++++++ src/lib/wishlist-logic.ts | 77 ++++ 29 files changed, 4273 insertions(+) create mode 100644 scripts/seed.ts create mode 100644 src/app/(app)/katalog/[id]/bearbeiten/page.tsx create mode 100644 src/app/(app)/katalog/[id]/page.tsx create mode 100644 src/app/(app)/katalog/actions.ts create mode 100644 src/app/(app)/katalog/neu/page.tsx create mode 100644 src/app/(app)/katalog/page.tsx create mode 100644 src/app/(app)/sammlung/actions.ts create mode 100644 src/app/(app)/wunschlisten/actions.ts create mode 100644 src/app/api/files/[...path]/route.ts create mode 100644 src/components/katalog/catalog-filters.tsx create mode 100644 src/components/katalog/catalog-grid.tsx create mode 100644 src/components/katalog/instructions-section.tsx create mode 100644 src/components/katalog/pagination.tsx create mode 100644 src/components/katalog/pdf-viewer.tsx create mode 100644 src/components/katalog/photos-section.tsx create mode 100644 src/components/katalog/set-actions.tsx create mode 100644 src/components/katalog/set-card.tsx create mode 100644 src/components/katalog/set-form.tsx create mode 100644 src/components/sammlung/collection-items-section.tsx create mode 100644 src/lib/images.ts create mode 100644 src/lib/labels.ts create mode 100644 src/lib/ownership.ts create mode 100644 src/lib/queries/catalog.ts create mode 100644 src/lib/queries/wishlists.ts create mode 100644 src/lib/tokens.ts create mode 100644 src/lib/uploads.ts create mode 100644 src/lib/wishlist-logic.ts diff --git a/package.json b/package.json index 4cc0ebf..4a9c250 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "lucide-react": "^1.41.0", "next": "15.5.25", "next-themes": "^0.4.6", + "pdfjs-dist": "^6.3.289", "radix-ui": "^1.6.7", "react": "19.1.0", "react-dom": "19.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d200f5d..d2cf62c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + pdfjs-dist: + specifier: ^6.3.289 + version: 6.3.289 radix-ui: specifier: ^1.6.7 version: 1.6.7(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -1086,6 +1089,81 @@ packages: '@cfworker/json-schema': optional: true + '@napi-rs/canvas-android-arm64@1.0.8': + resolution: {integrity: sha512-5+nkh8i3gt6lqS/d2jTZ1xAn6tdgtB4Lf1mW6T0Qm5/rXNwBuV1sAEyLEWan5o9gJPU/GuvHR3rvSeZ+FaGrbw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@napi-rs/canvas-darwin-arm64@1.0.8': + resolution: {integrity: sha512-7jQ47gi+fZ7KJmfc/5rNyy1CYw/cu4kZ0KPIYbo9UUgSdW0bKQJpt+WihEor6s4Lyp7+xc3a+3HeyXmAEbbnPg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/canvas-darwin-x64@1.0.8': + resolution: {integrity: sha512-rRjDMZs9pIRKGxgijwezplKc1RnJsqUokrA9h88bbTkqQ+7ePj0ZN4ZnZDy8Vu0tXs7KRlI2tQLaK4mx9QlxHg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/canvas-linux-arm-gnueabihf@1.0.8': + resolution: {integrity: sha512-jGcCd+8ra6Q61xKqZeiItujTpp9a9eRLcQ0jW6qYNku+WpupqOPFPY0SrsuSnXFviJwkpKYT9p7QrB4lsf3LNQ==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/canvas-linux-arm64-gnu@1.0.8': + resolution: {integrity: sha512-od6I2Y7kU7i1SwZYG2EKW8rWz6JiedtPpko4WEe1DDsiikrfaotVBCRaUTM5/yeZKaZ92EatoAS+5xG+6uJlYA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@napi-rs/canvas-linux-arm64-musl@1.0.8': + resolution: {integrity: sha512-yYkPbJDJiWj6N0gASA3CAvRypZmVpJnxU0DQg3aBhneLDQde9TPLKADsQkobNoJUtTT/lj46aWpzT48PDb3Qcg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@napi-rs/canvas-linux-riscv64-gnu@1.0.8': + resolution: {integrity: sha512-PB00MSKAp4VwK/xwe6duKxRKmH8UH4GIl1pqHSbxng0jnU9Dr7FwaDypDiqwNFZ774N+8G7mJLGuLtg9NTcQsg==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@napi-rs/canvas-linux-x64-gnu@1.0.8': + resolution: {integrity: sha512-TWM2XWJoitLiIPCvgJh7SriC+L/T9qkYCVzC66AidsZy0QP1hkKzBzVwshCdcA3q6fIn3yE0ISbq4lMJSy8jFw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@napi-rs/canvas-linux-x64-musl@1.0.8': + resolution: {integrity: sha512-hb20MxKXXb5IB7AAwN8UHz9WRsa2HmdZfjsDCzjElwJoeV1aotVEwFU4FrFQcYQVzsJQLeaCc/2Qdt/0Q72mMg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@napi-rs/canvas-win32-arm64-msvc@1.0.8': + resolution: {integrity: sha512-WwPN08IXE4SkL+FhJyPz/iFnycMAUkbphFIT4cmKLlvbSU0Zfn1R7BGJ3Hqky1S89QUYc0Q4IOScXb/42Re9wQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@napi-rs/canvas-win32-x64-msvc@1.0.8': + resolution: {integrity: sha512-XkrVqKb+pxyba7kjy2LJvABFVBTE0DNpEl7MrG4OYUmaWarrXH+t54z/Czj2YxCKtizYTV4mg6phNm3x24qjhQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/canvas@1.0.8': + resolution: {integrity: sha512-/SaLcvlqGWdm0HSCWMgHu7cjJiQXfP8/mOY+6dUyV9flQz7sPBBZ+ed2zYtoukojPmxOaL7bm+d/G4GeWWoN7g==} + engines: {node: '>= 10'} + '@napi-rs/wasm-runtime@1.2.3': resolution: {integrity: sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==} engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} @@ -4174,6 +4252,10 @@ packages: path-to-regexp@8.4.2: resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + pdfjs-dist@6.3.289: + resolution: {integrity: sha512-ZHjSVpDa3D6izMq8/04lvkhkATUmL9px6ChPaXc1k6nU2Mrhlg1/7F0bdUqCwUjw3NsPTfPZsMDUU6ZIcRaeQw==} + engines: {node: '>=22.13.0 || >=24'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -5704,6 +5786,54 @@ snapshots: transitivePeerDependencies: - supports-color + '@napi-rs/canvas-android-arm64@1.0.8': + optional: true + + '@napi-rs/canvas-darwin-arm64@1.0.8': + optional: true + + '@napi-rs/canvas-darwin-x64@1.0.8': + optional: true + + '@napi-rs/canvas-linux-arm-gnueabihf@1.0.8': + optional: true + + '@napi-rs/canvas-linux-arm64-gnu@1.0.8': + optional: true + + '@napi-rs/canvas-linux-arm64-musl@1.0.8': + optional: true + + '@napi-rs/canvas-linux-riscv64-gnu@1.0.8': + optional: true + + '@napi-rs/canvas-linux-x64-gnu@1.0.8': + optional: true + + '@napi-rs/canvas-linux-x64-musl@1.0.8': + optional: true + + '@napi-rs/canvas-win32-arm64-msvc@1.0.8': + optional: true + + '@napi-rs/canvas-win32-x64-msvc@1.0.8': + optional: true + + '@napi-rs/canvas@1.0.8': + optionalDependencies: + '@napi-rs/canvas-android-arm64': 1.0.8 + '@napi-rs/canvas-darwin-arm64': 1.0.8 + '@napi-rs/canvas-darwin-x64': 1.0.8 + '@napi-rs/canvas-linux-arm-gnueabihf': 1.0.8 + '@napi-rs/canvas-linux-arm64-gnu': 1.0.8 + '@napi-rs/canvas-linux-arm64-musl': 1.0.8 + '@napi-rs/canvas-linux-riscv64-gnu': 1.0.8 + '@napi-rs/canvas-linux-x64-gnu': 1.0.8 + '@napi-rs/canvas-linux-x64-musl': 1.0.8 + '@napi-rs/canvas-win32-arm64-msvc': 1.0.8 + '@napi-rs/canvas-win32-x64-msvc': 1.0.8 + optional: true + '@napi-rs/wasm-runtime@1.2.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 @@ -8788,6 +8918,10 @@ snapshots: path-to-regexp@8.4.2: {} + pdfjs-dist@6.3.289: + optionalDependencies: + '@napi-rs/canvas': 1.0.8 + picocolors@1.1.1: {} picomatch@2.3.2: {} diff --git a/scripts/seed.ts b/scripts/seed.ts new file mode 100644 index 0000000..fa10c3b --- /dev/null +++ b/scripts/seed.ts @@ -0,0 +1,398 @@ +/** + * Example data so a fresh instance is not an empty page. + * + * Safe to run more than once: everything is matched by its natural key + * (manufacturer slug, set number) and only inserted when missing. + * + * Run with `pnpm db:seed` after `pnpm db:migrate`. + */ +import path from 'node:path'; +import Database from 'better-sqlite3'; +import { config as loadEnv } from 'dotenv'; +import { eq } from 'drizzle-orm'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import * as schema from '../src/db/schema'; + +loadEnv({ path: '.env', quiet: true }); + +const dataDir = path.resolve(process.env.DATA_DIR ?? './data'); +const sqlite = new Database(path.join(dataDir, 'db', 'bricktrack.db')); +sqlite.pragma('foreign_keys = ON'); +const db = drizzle(sqlite, { schema }); + +const now = new Date(); +const id = () => crypto.randomUUID(); + +type SeedSet = { + manufacturer: string; + theme: string; + setNumber: string; + name: string; + year: number; + pieceCount: number; + minifigCount: number | null; + retailPriceCents: number; + ageRange: string; + /** Copies owned - 0 means the set is only in the catalog. */ + owned?: { + status: 'owned' | 'built' | 'disassembled' | 'lent_out' | 'sold'; + completeness: 'complete' | 'missing_parts' | 'unknown'; + location: string; + giftedBy?: string; + }; +}; + +const MANUFACTURERS = [ + { name: 'Lego', slug: 'lego', website: 'https://www.lego.com/de-de' }, + { name: 'Playmobil', slug: 'playmobil', website: 'https://www.playmobil.de' }, + { name: 'Cobi', slug: 'cobi', website: 'https://cobi.de' }, +]; + +const THEMES: Array<{ manufacturer: string; name: string }> = [ + { manufacturer: 'Lego', name: 'City' }, + { manufacturer: 'Lego', name: 'Technic' }, + { manufacturer: 'Lego', name: 'Ninjago' }, + { manufacturer: 'Playmobil', name: 'Novelmore' }, + { manufacturer: 'Playmobil', name: 'City Action' }, + { manufacturer: 'Cobi', name: 'Historical Collection' }, +]; + +const LOCATIONS = ['Regal Kinderzimmer', 'Kiste Keller 3', 'Vitrine Wohnzimmer']; + +const SETS: SeedSet[] = [ + { + manufacturer: 'Lego', + theme: 'City', + setNumber: '60398', + name: 'Familienhaus und Elektroauto', + year: 2023, + pieceCount: 462, + minifigCount: 4, + retailPriceCents: 4999, + ageRange: '6-12', + owned: { status: 'built', completeness: 'complete', location: 'Regal Kinderzimmer' }, + }, + { + manufacturer: 'Lego', + theme: 'City', + setNumber: '60321', + name: 'Feuerwehreinsatz', + year: 2022, + pieceCount: 766, + minifigCount: 6, + retailPriceCents: 7999, + ageRange: '6-12', + owned: { + status: 'owned', + completeness: 'missing_parts', + location: 'Kiste Keller 3', + giftedBy: 'Oma Helga', + }, + }, + { + manufacturer: 'Lego', + theme: 'Technic', + setNumber: '42147', + name: 'Kipplaster', + year: 2022, + pieceCount: 177, + minifigCount: null, + retailPriceCents: 999, + ageRange: '7-14', + owned: { status: 'disassembled', completeness: 'unknown', location: 'Kiste Keller 3' }, + }, + { + manufacturer: 'Lego', + theme: 'Technic', + setNumber: '42172', + name: 'McLaren P1', + year: 2024, + pieceCount: 3893, + minifigCount: null, + retailPriceCents: 44999, + ageRange: '18+', + }, + { + manufacturer: 'Lego', + theme: 'Ninjago', + setNumber: '71427', + name: 'Lloyds Elementardrachen', + year: 2023, + pieceCount: 685, + minifigCount: 4, + retailPriceCents: 5999, + ageRange: '8-14', + }, + { + manufacturer: 'Lego', + theme: 'Ninjago', + setNumber: '71799', + name: 'Ninjago City Märkte', + year: 2023, + pieceCount: 6163, + minifigCount: 21, + retailPriceCents: 39999, + ageRange: '18+', + }, + { + manufacturer: 'Playmobil', + theme: 'Novelmore', + setNumber: '70392', + name: 'Novelmore Festung', + year: 2020, + pieceCount: 293, + minifigCount: 5, + retailPriceCents: 12999, + ageRange: '4-10', + owned: { status: 'built', completeness: 'complete', location: 'Regal Kinderzimmer' }, + }, + { + manufacturer: 'Playmobil', + theme: 'City Action', + setNumber: '70936', + name: 'Feuerwehr-Fahrzeug Ultimate Rescue Truck', + year: 2021, + pieceCount: 178, + minifigCount: 2, + retailPriceCents: 8999, + ageRange: '5-10', + }, + { + manufacturer: 'Cobi', + theme: 'Historical Collection', + setNumber: '2-30500', + name: 'Ford Mustang GT', + year: 2022, + pieceCount: 250, + minifigCount: null, + retailPriceCents: 2999, + ageRange: '8+', + }, + { + manufacturer: 'Cobi', + theme: 'Historical Collection', + setNumber: '2-30501', + name: 'Feuerwehrauto Magirus', + year: 2023, + pieceCount: 400, + minifigCount: 1, + retailPriceCents: 3999, + ageRange: '8+', + }, +]; + +function seedManufacturers() { + const byName = new Map(); + + for (const entry of MANUFACTURERS) { + const existing = db + .select({ id: schema.manufacturer.id }) + .from(schema.manufacturer) + .where(eq(schema.manufacturer.slug, entry.slug)) + .get(); + + if (existing) { + byName.set(entry.name, existing.id); + continue; + } + + const newId = id(); + db.insert(schema.manufacturer) + .values({ id: newId, ...entry, createdAt: now, updatedAt: now }) + .run(); + byName.set(entry.name, newId); + } + + return byName; +} + +function seedThemes(manufacturers: Map) { + const byKey = new Map(); + + for (const entry of THEMES) { + const manufacturerId = manufacturers.get(entry.manufacturer); + if (!manufacturerId) continue; + + const existing = db + .select({ id: schema.theme.id, name: schema.theme.name }) + .from(schema.theme) + .where(eq(schema.theme.manufacturerId, manufacturerId)) + .all() + .find((row) => row.name === entry.name); + + if (existing) { + byKey.set(`${entry.manufacturer}/${entry.name}`, existing.id); + continue; + } + + const newId = id(); + db.insert(schema.theme) + .values({ + id: newId, + manufacturerId, + name: entry.name, + createdAt: now, + updatedAt: now, + }) + .run(); + byKey.set(`${entry.manufacturer}/${entry.name}`, newId); + } + + return byKey; +} + +function seedLocations() { + const byName = new Map(); + + for (const name of LOCATIONS) { + const existing = db + .select({ id: schema.location.id }) + .from(schema.location) + .where(eq(schema.location.name, name)) + .get(); + + if (existing) { + byName.set(name, existing.id); + continue; + } + + const newId = id(); + db.insert(schema.location) + .values({ id: newId, name, createdAt: now, updatedAt: now }) + .run(); + byName.set(name, newId); + } + + return byName; +} + +function seedSets( + manufacturers: Map, + themes: Map, + locations: Map, +) { + const setIds = new Map(); + + for (const entry of SETS) { + const manufacturerId = manufacturers.get(entry.manufacturer); + if (!manufacturerId) continue; + + const existing = db + .select({ id: schema.set.id, setNumber: schema.set.setNumber }) + .from(schema.set) + .where(eq(schema.set.manufacturerId, manufacturerId)) + .all() + .find((row) => row.setNumber === entry.setNumber); + + if (existing) { + setIds.set(entry.setNumber, existing.id); + continue; + } + + const newId = id(); + db.insert(schema.set) + .values({ + id: newId, + manufacturerId, + themeId: themes.get(`${entry.manufacturer}/${entry.theme}`) ?? null, + setNumber: entry.setNumber, + name: entry.name, + year: entry.year, + pieceCount: entry.pieceCount, + minifigCount: entry.minifigCount, + ageRange: entry.ageRange, + retailPriceCents: entry.retailPriceCents, + createdAt: now, + updatedAt: now, + }) + .run(); + setIds.set(entry.setNumber, newId); + + if (entry.owned) { + db.insert(schema.collectionItem) + .values({ + id: id(), + setId: newId, + status: entry.owned.status, + completeness: entry.owned.completeness, + condition: 'good', + locationId: locations.get(entry.owned.location) ?? null, + acquiredAt: new Date(entry.year, 11, 24), + acquiredPriceCents: entry.retailPriceCents, + giftedBy: entry.owned.giftedBy ?? null, + createdAt: now, + updatedAt: now, + }) + .run(); + } + } + + return setIds; +} + +function seedWishlist(setIds: Map) { + const owner = db.select({ id: schema.user.id }).from(schema.user).get(); + if (!owner) { + console.log('[seed] Kein Nutzer vorhanden - die Beispiel-Wunschliste wird uebersprungen.'); + console.log('[seed] Registriere dich zuerst in der App und starte den Seed danach erneut.'); + return; + } + + const existing = db + .select({ id: schema.wishlist.id }) + .from(schema.wishlist) + .all() + .find((row) => row.id.length > 0); + if (existing) { + console.log('[seed] Es gibt bereits eine Wunschliste - unveraendert gelassen.'); + return; + } + + const wishlistId = id(); + db.insert(schema.wishlist) + .values({ + id: wishlistId, + ownerUserId: owner.id, + title: 'Weihnachten 2026', + occasion: 'christmas', + eventDate: new Date(2026, 11, 24), + shareToken: Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url'), + isPublic: true, + surpriseMode: false, + note: 'Bitte keine Doppelungen - was reserviert ist, steht schon fest.', + createdAt: now, + updatedAt: now, + }) + .run(); + + const wanted = ['71427', '42172', '70936']; + wanted.forEach((setNumber, index) => { + const setId = setIds.get(setNumber); + if (!setId) return; + + db.insert(schema.wishlistItem) + .values({ + id: id(), + wishlistId, + setId, + priority: index === 0 ? 1 : 2, + sortOrder: index, + createdAt: now, + }) + .onConflictDoNothing() + .run(); + }); +} + +console.log('[seed] Beispieldaten werden eingespielt …'); + +const manufacturers = seedManufacturers(); +const themes = seedThemes(manufacturers); +const locations = seedLocations(); +const setIds = seedSets(manufacturers, themes, locations); +seedWishlist(setIds); + +console.log( + `[seed] Fertig: ${manufacturers.size} Hersteller, ${themes.size} Themen, ${setIds.size} Sets.`, +); +sqlite.close(); diff --git a/src/app/(app)/katalog/[id]/bearbeiten/page.tsx b/src/app/(app)/katalog/[id]/bearbeiten/page.tsx new file mode 100644 index 0000000..d142bc7 --- /dev/null +++ b/src/app/(app)/katalog/[id]/bearbeiten/page.tsx @@ -0,0 +1,58 @@ +import type { Metadata } from 'next'; +import { notFound } from 'next/navigation'; +import { SetForm } from '@/components/katalog/set-form'; +import { getSetById } from '@/lib/queries/catalog'; +import { listManufacturers, listThemes } from '@/lib/queries/master-data'; +import { requireUser } from '@/lib/session'; + +export const metadata: Metadata = { title: 'Set bearbeiten' }; + +export default async function SetBearbeitenPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + await requireUser(); + const { id } = await params; + + const set = getSetById(id); + if (!set) notFound(); + + const manufacturers = listManufacturers().map((row) => ({ id: row.id, name: row.name })); + const themes = listThemes().map((row) => ({ + id: row.id, + name: row.name, + manufacturerId: row.manufacturerId, + })); + + return ( +
+
+

Set bearbeiten

+

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

+
+ +
+ ); +} diff --git a/src/app/(app)/katalog/[id]/page.tsx b/src/app/(app)/katalog/[id]/page.tsx new file mode 100644 index 0000000..769ffb0 --- /dev/null +++ b/src/app/(app)/katalog/[id]/page.tsx @@ -0,0 +1,192 @@ +import type { Metadata } from 'next'; +import Link from 'next/link'; +import { notFound } from 'next/navigation'; +import { Check, ExternalLink, Gift, ImageOff, Pencil } from 'lucide-react'; +import { InstructionsSection } from '@/components/katalog/instructions-section'; +import { PhotosSection } from '@/components/katalog/photos-section'; +import { SetActions } from '@/components/katalog/set-actions'; +import { CollectionItemsSection } from '@/components/sammlung/collection-items-section'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { formatNumber } from '@/lib/format'; +import { setImageSrc } from '@/lib/images'; +import { formatEuro } from '@/lib/money'; +import { + getSetById, + listCollectionItemsForSet, + listInstructionsForSet, + listPhotosForSet, +} from '@/lib/queries/catalog'; +import { listLocations } from '@/lib/queries/master-data'; +import { listWishlistsForSet, listWishlistTargets } from '@/lib/queries/wishlists'; +import { requireUser } from '@/lib/session'; + +type Props = { params: Promise<{ id: string }> }; + +export async function generateMetadata({ params }: Props): Promise { + const { id } = await params; + const set = getSetById(id); + return { title: set ? `${set.setNumber} · ${set.name}` : 'Set' }; +} + +export default async function SetDetailPage({ params }: Props) { + await requireUser(); + const { id } = await params; + + const set = getSetById(id); + if (!set) notFound(); + + const image = setImageSrc(set); + const owned = set.ownedCopies > 0; + const locations = listLocations().map((row) => ({ id: row.id, name: row.name })); + const locationNames = new Map(locations.map((row) => [row.id, row.name])); + + const collectionItems = listCollectionItemsForSet(set.id).map((item) => ({ + ...item, + locationName: item.locationId ? (locationNames.get(item.locationId) ?? null) : null, + })); + + const facts: Array<[string, string]> = [ + ['Hersteller', set.manufacturerName], + ['Thema', set.themeName ?? '–'], + ['Setnummer', set.setNumber], + ['Jahr', set.year ? String(set.year) : '–'], + ['Teile', formatNumber(set.pieceCount)], + ['Minifiguren', formatNumber(set.minifigCount)], + ['Alter', set.ageRange ?? '–'], + ['EAN', set.ean ?? '–'], + ['UVP', formatEuro(set.retailPriceCents)], + ]; + + return ( +
+
+
+
+ + {set.setNumber} + + {set.manufacturerName} + {owned ? ( + + + {set.ownedCopies > 1 ? `${set.ownedCopies}× vorhanden` : 'Vorhanden'} + + ) : ( + Fehlt noch + )} + {set.wishlistCount > 0 ? ( + + + Auf Wunschliste + + ) : null} +
+

{set.name}

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

{set.notes}

+ ) : null} +
+
+ + + + In der Sammlung + + + + + + + + + Anleitungen + + + + + + + + + Fotos + + + + + + + +
+ ); +} + +function WishlistMemberships({ setId }: { setId: string }) { + const lists = listWishlistsForSet(setId); + if (lists.length === 0) return null; + + return ( + + + Auf diesen Wunschlisten + + + {lists.map((list) => ( + + ))} + + + ); +} diff --git a/src/app/(app)/katalog/actions.ts b/src/app/(app)/katalog/actions.ts new file mode 100644 index 0000000..82c180d --- /dev/null +++ b/src/app/(app)/katalog/actions.ts @@ -0,0 +1,291 @@ +'use server'; + +import { revalidatePath } from 'next/cache'; +import { and, eq, ne } from 'drizzle-orm'; +import { db } from '@/db'; +import { instruction, photo, set } from '@/db/schema'; +import { actionData, actionError, actionOk, formToObject, parseForm, type ActionResult } from '@/lib/action-result'; +import { requireUser } from '@/lib/session'; +import { deleteStoredFile, storeImage, storePdf, UploadError } from '@/lib/uploads'; +import { optionalText, optionalUrl, setSchema } from '@/lib/validation'; +import { z } from 'zod'; + +function now() { + return new Date(); +} + +function revalidateSet(id?: string) { + revalidatePath('/katalog'); + revalidatePath('/sammlung'); + if (id) revalidatePath(`/katalog/${id}`); +} + +function fileFrom(formData: FormData, field: string): File | null { + const value = formData.get(field); + return value instanceof File && value.size > 0 ? value : null; +} + +// --------------------------------------------------------------------------- +// Sets +// --------------------------------------------------------------------------- + +export async function createSet(formData: FormData): Promise> { + await requireUser(); + + const parsed = parseForm(setSchema, formToObject(formData)); + if (!parsed.ok) return parsed; + + const clash = db + .select({ id: set.id }) + .from(set) + .where( + and( + eq(set.manufacturerId, parsed.data.manufacturerId), + eq(set.setNumber, parsed.data.setNumber), + ), + ) + .get(); + if (clash) { + return actionError('Dieses Set gibt es beim gewählten Hersteller schon.'); + } + + const id = crypto.randomUUID(); + let imagePath: string | null = null; + + const image = fileFrom(formData, 'image'); + if (image) { + try { + imagePath = (await storeImage(image, 'set-image', id)).filePath; + } catch (error) { + if (error instanceof UploadError) return actionError(error.message); + throw error; + } + } + + db.insert(set) + .values({ + id, + ...parsed.data, + imagePath, + createdAt: now(), + updatedAt: now(), + }) + .run(); + + revalidateSet(id); + return actionData({ id }); +} + +export async function updateSet(id: string, formData: FormData): Promise { + await requireUser(); + + const parsed = parseForm(setSchema, formToObject(formData)); + if (!parsed.ok) return parsed; + + const existing = db.select().from(set).where(eq(set.id, id)).get(); + if (!existing) return actionError('Das Set gibt es nicht mehr.'); + + const clash = db + .select({ id: set.id }) + .from(set) + .where( + and( + eq(set.manufacturerId, parsed.data.manufacturerId), + eq(set.setNumber, parsed.data.setNumber), + ne(set.id, id), + ), + ) + .get(); + if (clash) return actionError('Ein anderes Set des Herstellers hat diese Nummer bereits.'); + + let imagePath = existing.imagePath; + const image = fileFrom(formData, 'image'); + if (image) { + try { + const stored = await storeImage(image, 'set-image', id); + await deleteStoredFile(existing.imagePath); + imagePath = stored.filePath; + } catch (error) { + if (error instanceof UploadError) return actionError(error.message); + throw error; + } + } else if (formData.get('removeImage') === 'on') { + await deleteStoredFile(existing.imagePath); + imagePath = null; + } + + db.update(set) + .set({ ...parsed.data, imagePath, updatedAt: now() }) + .where(eq(set.id, id)) + .run(); + + revalidateSet(id); + return actionOk(); +} + +export async function deleteSet(id: string): Promise { + await requireUser(); + + const existing = db.select().from(set).where(eq(set.id, id)).get(); + if (!existing) return actionOk(); + + const files = [ + existing.imagePath, + ...db + .select({ path: photo.filePath, thumb: photo.thumbnailPath }) + .from(photo) + .where(eq(photo.setId, id)) + .all() + .flatMap((row) => [row.path, row.thumb]), + ...db + .select({ path: instruction.filePath }) + .from(instruction) + .where(eq(instruction.setId, id)) + .all() + .map((row) => row.path), + ]; + + // Rows go first: if a file is already gone the delete still has to succeed. + db.delete(set).where(eq(set.id, id)).run(); + await Promise.all(files.map((file) => deleteStoredFile(file))); + + revalidateSet(id); + return actionOk(); +} + +// --------------------------------------------------------------------------- +// Instructions +// --------------------------------------------------------------------------- + +const instructionUrlSchema = z.object({ + label: optionalText(80), + url: optionalUrl, +}); + +export async function addInstructionPdf(setId: string, formData: FormData): Promise { + await requireUser(); + + const file = fileFrom(formData, 'file'); + if (!file) return actionError('Bitte wähle eine PDF-Datei aus.'); + + const parsed = parseForm(z.object({ label: optionalText(80) }), formToObject(formData)); + if (!parsed.ok) return parsed; + + try { + const stored = await storePdf(file, setId); + db.insert(instruction) + .values({ + id: crypto.randomUUID(), + setId, + kind: 'pdf_upload', + filePath: stored.filePath, + fileSizeBytes: stored.fileSizeBytes, + label: parsed.data.label, + sortOrder: nextInstructionSortOrder(setId), + createdAt: now(), + }) + .run(); + } catch (error) { + if (error instanceof UploadError) return actionError(error.message); + throw error; + } + + revalidateSet(setId); + return actionOk(); +} + +export async function addInstructionUrl(setId: string, input: unknown): Promise { + await requireUser(); + + const parsed = parseForm(instructionUrlSchema, input); + if (!parsed.ok) return parsed; + if (!parsed.data.url) return actionError('Bitte gib die Adresse der Anleitung an.'); + + db.insert(instruction) + .values({ + id: crypto.randomUUID(), + setId, + kind: 'external_url', + url: parsed.data.url, + label: parsed.data.label, + sortOrder: nextInstructionSortOrder(setId), + createdAt: now(), + }) + .run(); + + revalidateSet(setId); + return actionOk(); +} + +export async function deleteInstruction(id: string): Promise { + await requireUser(); + + const existing = db.select().from(instruction).where(eq(instruction.id, id)).get(); + if (!existing) return actionOk(); + + db.delete(instruction).where(eq(instruction.id, id)).run(); + await deleteStoredFile(existing.filePath); + + revalidateSet(existing.setId); + return actionOk(); +} + +function nextInstructionSortOrder(setId: string): number { + const rows = db + .select({ sortOrder: instruction.sortOrder }) + .from(instruction) + .where(eq(instruction.setId, setId)) + .all(); + return rows.reduce((max, row) => Math.max(max, row.sortOrder), -1) + 1; +} + +// --------------------------------------------------------------------------- +// Photos +// --------------------------------------------------------------------------- + +export async function addSetPhoto(setId: string, formData: FormData): Promise { + await requireUser(); + + const file = fileFrom(formData, 'file'); + if (!file) return actionError('Bitte wähle ein Bild aus.'); + + const parsed = parseForm(z.object({ caption: optionalText(160) }), formToObject(formData)); + if (!parsed.ok) return parsed; + + try { + const stored = await storeImage(file, 'photo', setId); + db.insert(photo) + .values({ + id: crypto.randomUUID(), + setId, + filePath: stored.filePath, + thumbnailPath: stored.thumbnailPath, + caption: parsed.data.caption, + sortOrder: 0, + createdAt: now(), + }) + .run(); + } catch (error) { + if (error instanceof UploadError) return actionError(error.message); + throw error; + } + + revalidateSet(setId); + return actionOk(); +} + +export async function deletePhoto(id: string): Promise { + await requireUser(); + + const existing = db.select().from(photo).where(eq(photo.id, id)).get(); + if (!existing) return actionOk(); + + db.delete(photo).where(eq(photo.id, id)).run(); + await Promise.all([ + deleteStoredFile(existing.filePath), + deleteStoredFile(existing.thumbnailPath), + ]); + + if (existing.setId) revalidateSet(existing.setId); + return actionOk(); +} diff --git a/src/app/(app)/katalog/neu/page.tsx b/src/app/(app)/katalog/neu/page.tsx new file mode 100644 index 0000000..07b544a --- /dev/null +++ b/src/app/(app)/katalog/neu/page.tsx @@ -0,0 +1,51 @@ +import type { Metadata } from 'next'; +import Link from 'next/link'; +import { SetForm } from '@/components/katalog/set-form'; +import { Button } from '@/components/ui/button'; +import { listManufacturers, listThemes } from '@/lib/queries/master-data'; +import { requireUser } from '@/lib/session'; + +export const metadata: Metadata = { title: 'Neues Set' }; + +type SearchParams = Promise>; + +export default async function NeuesSetPage({ searchParams }: { searchParams: SearchParams }) { + await requireUser(); + const params = await searchParams; + const ean = typeof params.ean === 'string' ? params.ean : undefined; + + const manufacturers = listManufacturers().map((row) => ({ id: row.id, name: row.name })); + const themes = listThemes().map((row) => ({ + id: row.id, + name: row.name, + manufacturerId: row.manufacturerId, + })); + + if (manufacturers.length === 0) { + return ( +
+

Neues Set

+

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

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

Neues Set

+

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

+
+ +
+ ); +} diff --git a/src/app/(app)/katalog/page.tsx b/src/app/(app)/katalog/page.tsx new file mode 100644 index 0000000..1098964 --- /dev/null +++ b/src/app/(app)/katalog/page.tsx @@ -0,0 +1,83 @@ +import type { Metadata } from 'next'; +import Link from 'next/link'; +import { Plus } from 'lucide-react'; +import { CatalogFilters } from '@/components/katalog/catalog-filters'; +import { CatalogGrid } from '@/components/katalog/catalog-grid'; +import { Pagination } from '@/components/katalog/pagination'; +import { Button } from '@/components/ui/button'; +import { pluralize } from '@/lib/format'; +import { OWNERSHIP_FILTER_LABELS, parseOwnershipFilter } from '@/lib/ownership'; +import { CATALOG_PAGE_SIZE, listSets, listSetYears } from '@/lib/queries/catalog'; +import { listManufacturers, listThemes } from '@/lib/queries/master-data'; +import { listWishlistTargets } from '@/lib/queries/wishlists'; +import { requireUser } from '@/lib/session'; + +export const metadata: Metadata = { title: 'Katalog' }; + +type SearchParams = Promise>; + +function single(value: string | string[] | undefined): string | undefined { + const raw = Array.isArray(value) ? value[0] : value; + return raw && raw.length > 0 ? raw : undefined; +} + +export default async function KatalogPage({ searchParams }: { searchParams: SearchParams }) { + await requireUser(); + const params = await searchParams; + + const ownership = parseOwnershipFilter(single(params.besitz)); + const yearParam = single(params.jahr); + const page = Number(single(params.seite) ?? '1') || 1; + + const filters = { + q: single(params.q), + manufacturerId: single(params.hersteller), + themeId: single(params.thema), + year: yearParam ? Number(yearParam) : undefined, + ownership, + page, + }; + + const { rows, total } = listSets(filters); + const manufacturers = listManufacturers().map((row) => ({ id: row.id, name: row.name })); + const themes = listThemes().map((row) => ({ + id: row.id, + name: row.name, + manufacturerId: row.manufacturerId, + })); + const wishlists = listWishlistTargets(); + + return ( +
+
+
+

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

+

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

+
+ +
+ + + +

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

+ + + + +
+ ); +} diff --git a/src/app/(app)/sammlung/actions.ts b/src/app/(app)/sammlung/actions.ts new file mode 100644 index 0000000..3c1f5c5 --- /dev/null +++ b/src/app/(app)/sammlung/actions.ts @@ -0,0 +1,105 @@ +'use server'; + +import { revalidatePath } from 'next/cache'; +import { eq } from 'drizzle-orm'; +import { db } from '@/db'; +import { collectionItem, set } from '@/db/schema'; +import { + actionData, + actionError, + actionOk, + parseForm, + type ActionResult, +} from '@/lib/action-result'; +import { reconcileFulfilledWishlistItems } from '@/lib/queries/wishlists'; +import { requireUser } from '@/lib/session'; +import { collectionItemSchema } from '@/lib/validation'; +import type { Condition } from '@/db/types'; + +function now() { + return new Date(); +} + +function revalidateCollection(setId?: string) { + revalidatePath('/sammlung'); + revalidatePath('/katalog'); + revalidatePath('/wunschlisten'); + revalidatePath('/'); + if (setId) revalidatePath(`/katalog/${setId}`); +} + +/** + * Quick add from the set detail page: one tap, sensible defaults, details can + * be filled in later. + */ +export async function addSetToCollection(setId: string): Promise> { + await requireUser(); + + const exists = db.select({ id: set.id }).from(set).where(eq(set.id, setId)).get(); + if (!exists) return actionError('Das Set gibt es nicht mehr.'); + + const id = crypto.randomUUID(); + db.insert(collectionItem) + .values({ + id, + setId, + status: 'owned', + completeness: 'unknown', + createdAt: now(), + updatedAt: now(), + }) + .run(); + + // A wish that just came true is marked as fulfilled right away. + reconcileFulfilledWishlistItems(); + + revalidateCollection(setId); + return actionData({ id }); +} + +export async function updateCollectionItem(id: string, input: unknown): Promise { + await requireUser(); + + const parsed = parseForm(collectionItemSchema, input); + if (!parsed.ok) return parsed; + + const existing = db.select().from(collectionItem).where(eq(collectionItem.id, id)).get(); + if (!existing) return actionError('Dieser Sammlungseintrag gibt es nicht mehr.'); + + db.update(collectionItem) + .set({ + status: parsed.data.status, + completeness: parsed.data.completeness, + condition: parsed.data.condition as Condition | null, + locationId: parsed.data.locationId, + acquiredAt: parsed.data.acquiredAt, + acquiredPriceCents: parsed.data.acquiredPriceCents, + acquiredFrom: parsed.data.acquiredFrom, + giftedBy: parsed.data.giftedBy, + lentOutTo: parsed.data.lentOutTo, + notes: parsed.data.notes, + updatedAt: now(), + }) + .where(eq(collectionItem.id, id)) + .run(); + + // Selling the last copy puts the set back into "fehlt" - and back onto the + // wishlists as an open wish. + reconcileFulfilledWishlistItems(); + + revalidateCollection(existing.setId); + return actionOk(); +} + +export async function deleteCollectionItem(id: string): Promise { + await requireUser(); + + const existing = db.select().from(collectionItem).where(eq(collectionItem.id, id)).get(); + if (!existing) return actionOk(); + + db.delete(collectionItem).where(eq(collectionItem.id, id)).run(); + reconcileFulfilledWishlistItems(); + + revalidateCollection(existing.setId); + return actionOk(); +} diff --git a/src/app/(app)/wunschlisten/actions.ts b/src/app/(app)/wunschlisten/actions.ts new file mode 100644 index 0000000..f71d08f --- /dev/null +++ b/src/app/(app)/wunschlisten/actions.ts @@ -0,0 +1,254 @@ +'use server'; + +import { revalidatePath } from 'next/cache'; +import { and, eq, inArray } from 'drizzle-orm'; +import { db } from '@/db'; +import { set, wishlist, wishlistItem } from '@/db/schema'; +import { + actionData, + actionError, + actionOk, + parseForm, + type ActionResult, +} from '@/lib/action-result'; +import { ownedCopiesSql } from '@/lib/ownership'; +import { + findExistingWishlistSetIds, + getWishlist, + nextWishlistSortOrder, +} from '@/lib/queries/wishlists'; +import { requireUser } from '@/lib/session'; +import { createToken } from '@/lib/tokens'; +import { describeBulkAdd, planBulkAdd } from '@/lib/wishlist-logic'; +import { + addSetsToWishlistSchema, + wishlistItemSchema, + wishlistSchema, +} from '@/lib/validation'; + +function now() { + return new Date(); +} + +function revalidateWishlist(id?: string) { + revalidatePath('/wunschlisten'); + revalidatePath('/katalog'); + if (id) revalidatePath(`/wunschlisten/${id}`); +} + +/** Every wishlist action first proves the current user owns the list. */ +async function requireOwnedWishlist(id: string) { + const user = await requireUser(); + const list = getWishlist(id); + if (!list || list.ownerUserId !== user.id) return null; + return list; +} + +// --------------------------------------------------------------------------- +// Lists +// --------------------------------------------------------------------------- + +export async function createWishlist(input: unknown): Promise> { + const user = await requireUser(); + const parsed = parseForm(wishlistSchema, input); + if (!parsed.ok) return parsed; + + const id = crypto.randomUUID(); + db.insert(wishlist) + .values({ + id, + ownerUserId: user.id, + title: parsed.data.title, + occasion: parsed.data.occasion, + eventDate: parsed.data.eventDate, + note: parsed.data.note, + isPublic: parsed.data.isPublic, + surpriseMode: parsed.data.surpriseMode, + shareToken: createToken(), + createdAt: now(), + updatedAt: now(), + }) + .run(); + + revalidateWishlist(id); + return actionData({ id }); +} + +export async function updateWishlist(id: string, input: unknown): Promise { + const list = await requireOwnedWishlist(id); + if (!list) return actionError('Diese Wunschliste gibt es nicht.'); + + const parsed = parseForm(wishlistSchema, input); + if (!parsed.ok) return parsed; + + db.update(wishlist) + .set({ + title: parsed.data.title, + occasion: parsed.data.occasion, + eventDate: parsed.data.eventDate, + note: parsed.data.note, + isPublic: parsed.data.isPublic, + surpriseMode: parsed.data.surpriseMode, + updatedAt: now(), + }) + .where(eq(wishlist.id, id)) + .run(); + + revalidateWishlist(id); + return actionOk(); +} + +export async function deleteWishlist(id: string): Promise { + const list = await requireOwnedWishlist(id); + if (!list) return actionError('Diese Wunschliste gibt es nicht.'); + + db.delete(wishlist).where(eq(wishlist.id, id)).run(); + revalidateWishlist(id); + return actionOk(); +} + +/** + * Issues a fresh share token. The old link stops working immediately - the way + * out when a list was shared with the wrong people. + */ +export async function rotateShareToken(id: string): Promise> { + const list = await requireOwnedWishlist(id); + if (!list) return actionError('Diese Wunschliste gibt es nicht.'); + + const token = createToken(); + db.update(wishlist) + .set({ shareToken: token, updatedAt: now() }) + .where(eq(wishlist.id, id)) + .run(); + + revalidateWishlist(id); + return actionData({ token }); +} + +// --------------------------------------------------------------------------- +// Items +// --------------------------------------------------------------------------- + +/** + * The central path from "fehlende Sets" onto a wishlist. + * + * Sets that are already on the list are skipped and named in the result, and + * so are sets that are already in the collection. The unique index on + * (wishlist_id, set_id) makes that a guarantee rather than a hope. + */ +export async function addSetsToWishlist( + input: unknown, +): Promise> { + await requireUser(); + + const parsed = parseForm(addSetsToWishlistSchema, input); + if (!parsed.ok) return parsed; + + const list = await requireOwnedWishlist(parsed.data.wishlistId); + if (!list) return actionError('Diese Wunschliste gibt es nicht.'); + + const setIds = parsed.data.setIds; + const known = db + .select({ + id: set.id, + name: set.name, + setNumber: set.setNumber, + ownedCopies: ownedCopiesSql(set.id).mapWith(Number), + }) + .from(set) + .where(inArray(set.id, setIds)) + .all(); + + if (known.length === 0) return actionError('Die ausgewählten Sets gibt es nicht mehr.'); + + const names = new Map(known.map((row) => [row.id, `${row.setNumber} ${row.name}`])); + const ownedSetIds = new Set(known.filter((row) => row.ownedCopies > 0).map((row) => row.id)); + const existing = findExistingWishlistSetIds(list.id, setIds); + + const plan = planBulkAdd( + setIds.filter((id) => names.has(id)), + existing, + ownedSetIds, + ); + + if (plan.toAdd.length > 0) { + let sortOrder = nextWishlistSortOrder(list.id); + db.insert(wishlistItem) + .values( + plan.toAdd.map((setId) => ({ + id: crypto.randomUUID(), + wishlistId: list.id, + setId, + priority: parsed.data.priority, + sortOrder: sortOrder++, + createdAt: now(), + })), + ) + // Belt and braces: a concurrent add must not blow up the whole batch. + .onConflictDoNothing() + .run(); + } + + revalidateWishlist(list.id); + return actionData({ added: plan.toAdd.length, message: describeBulkAdd(plan, names) }); +} + +export async function updateWishlistItem(itemId: string, input: unknown): Promise { + const user = await requireUser(); + const parsed = parseForm(wishlistItemSchema, input); + if (!parsed.ok) return parsed; + + const item = db + .select({ id: wishlistItem.id, wishlistId: wishlistItem.wishlistId }) + .from(wishlistItem) + .innerJoin(wishlist, eq(wishlist.id, wishlistItem.wishlistId)) + .where(and(eq(wishlistItem.id, itemId), eq(wishlist.ownerUserId, user.id))) + .get(); + if (!item) return actionError('Dieser Eintrag gehört nicht zu deinen Wunschlisten.'); + + db.update(wishlistItem) + .set({ priority: parsed.data.priority, note: parsed.data.note }) + .where(eq(wishlistItem.id, itemId)) + .run(); + + revalidateWishlist(item.wishlistId); + return actionOk(); +} + +export async function removeWishlistItem(itemId: string): Promise { + const user = await requireUser(); + + const item = db + .select({ id: wishlistItem.id, wishlistId: wishlistItem.wishlistId }) + .from(wishlistItem) + .innerJoin(wishlist, eq(wishlist.id, wishlistItem.wishlistId)) + .where(and(eq(wishlistItem.id, itemId), eq(wishlist.ownerUserId, user.id))) + .get(); + if (!item) return actionOk(); + + db.delete(wishlistItem).where(eq(wishlistItem.id, itemId)).run(); + + revalidateWishlist(item.wishlistId); + return actionOk(); +} + +/** Persists the order after drag and drop or the move buttons. */ +export async function reorderWishlistItems( + wishlistId: string, + orderedItemIds: string[], +): Promise { + const list = await requireOwnedWishlist(wishlistId); + if (!list) return actionError('Diese Wunschliste gibt es nicht.'); + + db.transaction((tx) => { + orderedItemIds.forEach((itemId, index) => { + tx.update(wishlistItem) + .set({ sortOrder: index }) + .where(and(eq(wishlistItem.id, itemId), eq(wishlistItem.wishlistId, wishlistId))) + .run(); + }); + }); + + revalidateWishlist(wishlistId); + return actionOk(); +} diff --git a/src/app/api/files/[...path]/route.ts b/src/app/api/files/[...path]/route.ts new file mode 100644 index 0000000..bbe4d51 --- /dev/null +++ b/src/app/api/files/[...path]/route.ts @@ -0,0 +1,55 @@ +import fs from 'node:fs/promises'; +import { NextResponse, type NextRequest } from 'next/server'; +import { contentTypeFor, isPublicFilePath, resolveFilePath, UploadError } from '@/lib/uploads'; +import { getSession } from '@/lib/session'; + +/** + * Serves uploads from DATA_DIR/files. + * + * Set images stay public because the shared wishlist page needs them; photos + * and instruction PDFs require a session. + */ +export async function GET( + request: NextRequest, + context: { params: Promise<{ path: string[] }> }, +) { + const { path: segments } = await context.params; + const relativePath = segments.join('/'); + + if (!isPublicFilePath(relativePath) && !(await getSession())) { + return new NextResponse('Nicht angemeldet', { status: 401 }); + } + + let absolute: string; + try { + absolute = resolveFilePath(relativePath); + } catch (error) { + if (error instanceof UploadError) return new NextResponse('Ungültiger Pfad', { status: 400 }); + throw error; + } + + try { + const file = await fs.readFile(absolute); + const stats = await fs.stat(absolute); + + return new NextResponse(new Uint8Array(file), { + headers: { + 'Content-Type': contentTypeFor(relativePath), + 'Content-Length': String(stats.size), + // Uploaded files never change under the same name, so they can be + // cached hard - privately, since some of them need a session. + 'Cache-Control': isPublicFilePath(relativePath) + ? 'public, max-age=31536000, immutable' + : 'private, max-age=3600', + 'Content-Disposition': `inline; filename="${encodeURIComponent(relativePath.split('/').pop() ?? 'datei')}"`, + 'X-Content-Type-Options': 'nosniff', + }, + }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return new NextResponse('Nicht gefunden', { status: 404 }); + } + console.error('[files] Datei konnte nicht gelesen werden', relativePath, error); + return new NextResponse('Fehler beim Lesen der Datei', { status: 500 }); + } +} diff --git a/src/components/katalog/catalog-filters.tsx b/src/components/katalog/catalog-filters.tsx new file mode 100644 index 0000000..790dbb4 --- /dev/null +++ b/src/components/katalog/catalog-filters.tsx @@ -0,0 +1,148 @@ +'use client'; + +import { usePathname, useRouter, useSearchParams } from 'next/navigation'; +import { useEffect, useState, useTransition } from 'react'; +import { Loader2, Search, X } from 'lucide-react'; +import { OWNERSHIP_FILTERS, OWNERSHIP_FILTER_LABELS } from '@/lib/ownership'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { NativeSelect } from '@/components/ui/native-select'; + +export type FilterOptions = { + manufacturers: Array<{ id: string; name: string }>; + themes: Array<{ id: string; name: string; manufacturerId: string }>; + years: number[]; +}; + +/** + * Filters live in the URL, so every state of the catalog is a shareable link + * and the back button behaves the way people expect. + */ +export function CatalogFilters({ options }: { options: FilterOptions }) { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const [pending, startTransition] = useTransition(); + + const [query, setQuery] = useState(searchParams.get('q') ?? ''); + const manufacturerId = searchParams.get('hersteller') ?? ''; + const themeId = searchParams.get('thema') ?? ''; + const year = searchParams.get('jahr') ?? ''; + const ownership = searchParams.get('besitz') ?? 'alle'; + + function apply(changes: Record) { + const params = new URLSearchParams(searchParams.toString()); + for (const [key, value] of Object.entries(changes)) { + if (value === null || value === '') params.delete(key); + else params.set(key, value); + } + // Any filter change starts over at page one. + params.delete('seite'); + startTransition(() => router.push(`${pathname}?${params.toString()}`)); + } + + // Debounced search so typing on a phone does not fire a request per keystroke. + useEffect(() => { + const current = searchParams.get('q') ?? ''; + if (query === current) return; + + const timer = setTimeout(() => apply({ q: query || null }), 350); + return () => clearTimeout(timer); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [query]); + + const visibleThemes = manufacturerId + ? options.themes.filter((theme) => theme.manufacturerId === manufacturerId) + : options.themes; + + const hasFilters = Boolean(manufacturerId || themeId || year || query) || ownership !== 'alle'; + + return ( +
+
+ + setQuery(event.target.value)} + placeholder="Setnummer, Name oder EAN" + aria-label="Sets durchsuchen" + className="pl-9" + /> + {pending ? ( + + ) : null} +
+ +
+ apply({ besitz: event.target.value })} + > + {OWNERSHIP_FILTERS.map((value) => ( + + ))} + + + apply({ hersteller: event.target.value || null, thema: null })} + > + + {options.manufacturers.map((manufacturer) => ( + + ))} + + + apply({ thema: event.target.value || null })} + > + + {visibleThemes.map((theme) => ( + + ))} + + + apply({ jahr: event.target.value || null })} + > + + {options.years.map((value) => ( + + ))} + +
+ + {hasFilters ? ( + + ) : null} +
+ ); +} diff --git a/src/components/katalog/catalog-grid.tsx b/src/components/katalog/catalog-grid.tsx new file mode 100644 index 0000000..d64e288 --- /dev/null +++ b/src/components/katalog/catalog-grid.tsx @@ -0,0 +1,306 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { useRef, useState, useTransition } from 'react'; +import { CheckCheck, Gift, Loader2, X } from 'lucide-react'; +import { toast } from 'sonner'; +import { cn } from 'cn'; +import { addSetsToWishlist, createWishlist } from '@/app/(app)/wunschlisten/actions'; +import type { CatalogRow } from '@/lib/queries/catalog'; +import { PRIORITY_LABELS } from '@/lib/wishlist-logic'; +import { SetCard, SetCardContent } from './set-card'; +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Field } from '@/components/ui/field'; +import { Input } from '@/components/ui/input'; +import { NativeSelect } from '@/components/ui/native-select'; + +const LONG_PRESS_MS = 450; +const NEW_LIST = '__neu__'; + +export type WishlistTarget = { id: string; title: string }; + +type Props = { + sets: CatalogRow[]; + wishlists: WishlistTarget[]; + /** Preselected target, used by the embedded view on a wishlist page. */ + defaultWishlistId?: string; + emptyState?: React.ReactNode; +}; + +/** + * The catalog grid with its selection mode. + * + * On a phone a long press starts the selection, afterwards a tap toggles. + * On a desktop the "Auswählen" button does the same job. Sets that are already + * in the collection cannot be selected - they do not belong on a wishlist. + */ +export function CatalogGrid({ sets, wishlists, defaultWishlistId, emptyState }: Props) { + const router = useRouter(); + const [selecting, setSelecting] = useState(false); + const [selected, setSelected] = useState>(new Set()); + const [dialogOpen, setDialogOpen] = useState(false); + const [targetId, setTargetId] = useState(defaultWishlistId ?? wishlists[0]?.id ?? NEW_LIST); + const [newTitle, setNewTitle] = useState(''); + const [priority, setPriority] = useState('2'); + const [pending, startTransition] = useTransition(); + + const longPressTimer = useRef | null>(null); + const longPressFired = useRef(false); + + const selectable = sets.filter((item) => item.ownedCopies === 0); + + function toggle(setId: string) { + setSelected((current) => { + const next = new Set(current); + if (next.has(setId)) next.delete(setId); + else next.add(setId); + return next; + }); + } + + function startLongPress(item: CatalogRow) { + if (item.ownedCopies > 0) return; + longPressFired.current = false; + longPressTimer.current = setTimeout(() => { + longPressFired.current = true; + setSelecting(true); + toggle(item.id); + // A short buzz confirms the mode switch where the device supports it. + navigator.vibrate?.(15); + }, LONG_PRESS_MS); + } + + function cancelLongPress() { + if (longPressTimer.current) clearTimeout(longPressTimer.current); + longPressTimer.current = null; + } + + function endSelection() { + setSelecting(false); + setSelected(new Set()); + } + + function submit() { + const setIds = [...selected]; + if (setIds.length === 0) return; + + startTransition(async () => { + let wishlistId = targetId; + + if (wishlistId === NEW_LIST) { + const created = await createWishlist({ title: newTitle, occasion: 'other' }); + if (!created.ok) { + toast.error(created.error); + return; + } + wishlistId = created.data.id; + } + + const result = await addSetsToWishlist({ wishlistId, setIds, priority }); + if (!result.ok) { + toast.error(result.error); + return; + } + + if (result.data.added === 0) toast.warning(result.data.message); + else toast.success(result.data.message); + + setDialogOpen(false); + endSelection(); + router.refresh(); + }); + } + + if (sets.length === 0) { + return ( +
+ {emptyState ?? 'Keine Sets gefunden. Passe die Filter an oder leg ein neues Set an.'} +
+ ); + } + + return ( +
+
+

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

+ {selecting ? ( + + ) : ( + + )} +
+ +
+ {sets.map((item) => { + const owned = item.ownedCopies > 0; + const isSelected = selected.has(item.id); + + if (!selecting) { + return ( +
startLongPress(item)} + onPointerUp={cancelLongPress} + onPointerLeave={cancelLongPress} + onPointerCancel={cancelLongPress} + onContextMenu={(event) => { + // The long press must not open the browser context menu. + if (longPressFired.current) event.preventDefault(); + }} + onClickCapture={(event) => { + if (longPressFired.current) { + event.preventDefault(); + event.stopPropagation(); + longPressFired.current = false; + } + }} + > + +
+ ); + } + + return ( + + ); + })} +
+ + {selecting ? ( +
+
+ + {selected.size} ausgewählt + +
+ +
+
+ ) : null} + + + + + + {selected.size === 1 + ? '1 Set auf die Wunschliste' + : `${selected.size} Sets auf die Wunschliste`} + + + Sets, die schon auf der Liste oder bereits in der Sammlung sind, werden + übersprungen. + + + +
+ + setTargetId(event.target.value)} + > + {wishlists.map((list) => ( + + ))} + + + + + {targetId === NEW_LIST ? ( + + setNewTitle(event.target.value)} + placeholder="Weihnachten 2026" + maxLength={80} + /> + + ) : null} + + + setPriority(event.target.value)} + > + {Object.entries(PRIORITY_LABELS).map(([value, label]) => ( + + ))} + + +
+ + + + + +
+
+
+ ); +} diff --git a/src/components/katalog/instructions-section.tsx b/src/components/katalog/instructions-section.tsx new file mode 100644 index 0000000..5d21246 --- /dev/null +++ b/src/components/katalog/instructions-section.tsx @@ -0,0 +1,215 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { useState, useTransition } from 'react'; +import { Download, ExternalLink, FileText, Loader2, Plus, Trash2 } from 'lucide-react'; +import { toast } from 'sonner'; +import { + addInstructionPdf, + addInstructionUrl, + deleteInstruction, +} from '@/app/(app)/katalog/actions'; +import { fileUrl } from '@/lib/images'; +import { PdfViewer } from './pdf-viewer'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Field } from '@/components/ui/field'; +import { Input } from '@/components/ui/input'; +import { NativeSelect } from '@/components/ui/native-select'; + +export type InstructionRow = { + id: string; + kind: 'pdf_upload' | 'external_url'; + filePath: string | null; + url: string | null; + label: string | null; + fileSizeBytes: number | null; +}; + +function formatSize(bytes: number | null): string { + if (!bytes) return ''; + const mb = bytes / (1024 * 1024); + return mb >= 1 + ? `${mb.toLocaleString('de-DE', { maximumFractionDigits: 1 })} MB` + : `${Math.round(bytes / 1024)} KB`; +} + +export function InstructionsSection({ + setId, + instructions, +}: { + setId: string; + instructions: InstructionRow[]; +}) { + const router = useRouter(); + const [pending, startTransition] = useTransition(); + const [addOpen, setAddOpen] = useState(false); + const [kind, setKind] = useState<'pdf_upload' | 'external_url'>('pdf_upload'); + const [viewing, setViewing] = useState(null); + + function submit(event: React.FormEvent) { + event.preventDefault(); + const formData = new FormData(event.currentTarget); + + startTransition(async () => { + const result = + kind === 'pdf_upload' + ? await addInstructionPdf(setId, formData) + : await addInstructionUrl(setId, Object.fromEntries(formData.entries())); + + if (!result.ok) { + toast.error(result.error); + return; + } + toast.success('Anleitung hinzugefügt.'); + setAddOpen(false); + router.refresh(); + }); + } + + function remove(id: string) { + startTransition(async () => { + const result = await deleteInstruction(id); + if (!result.ok) { + toast.error(result.error); + return; + } + toast.success('Anleitung entfernt.'); + router.refresh(); + }); + } + + return ( +
+ {instructions.length === 0 ? ( +

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

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

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

    +

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

    +
    + + {item.kind === 'pdf_upload' && item.filePath ? ( + <> + + + + ) : item.url ? ( + + ) : null} + + +
  • + ))} +
+ )} + + + + + + + Anleitung hinzufügen + + Ein Set kann mehrere Anleitungen haben – zum Beispiel „Buch 1 von 3“. + + + +
+ + setKind(event.target.value as typeof kind)} + > + + + + + + + + + + {kind === 'pdf_upload' ? ( + + + + ) : ( + + + + )} +
+ + + + + +
+
+ + !open && setViewing(null)}> + + + {viewing?.label ?? 'Anleitung'} + + {viewing?.filePath ? ( + + ) : null} + + +
+ ); +} diff --git a/src/components/katalog/pagination.tsx b/src/components/katalog/pagination.tsx new file mode 100644 index 0000000..099473c --- /dev/null +++ b/src/components/katalog/pagination.tsx @@ -0,0 +1,54 @@ +'use client'; + +import { usePathname, useSearchParams } from 'next/navigation'; +import Link from 'next/link'; +import { ChevronLeft, ChevronRight } from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +export function Pagination({ + page, + total, + pageSize, +}: { + page: number; + total: number; + pageSize: number; +}) { + const pathname = usePathname(); + const searchParams = useSearchParams(); + const lastPage = Math.max(1, Math.ceil(total / pageSize)); + + if (lastPage <= 1) return null; + + function hrefFor(target: number) { + const params = new URLSearchParams(searchParams.toString()); + if (target <= 1) params.delete('seite'); + else params.set('seite', String(target)); + const query = params.toString(); + return query ? `${pathname}?${query}` : pathname; + } + + return ( + + ); +} diff --git a/src/components/katalog/pdf-viewer.tsx b/src/components/katalog/pdf-viewer.tsx new file mode 100644 index 0000000..e65073b --- /dev/null +++ b/src/components/katalog/pdf-viewer.tsx @@ -0,0 +1,155 @@ +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { ChevronLeft, ChevronRight, Loader2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +/** + * Minimal pdf.js viewer for building instructions. + * + * The library and its worker are bundled with the app - no CDN, no external + * request. One page at a time, because that is how you actually build a set, + * and because rendering 200 pages at once would stall a phone. + */ +export function PdfViewer({ url, label }: { url: string; label: string }) { + const canvasRef = useRef(null); + const containerRef = useRef(null); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const documentRef = useRef(null); + const renderTaskRef = useRef<{ cancel: () => void } | null>(null); + const loadingTaskRef = useRef<{ destroy: () => Promise } | null>(null); + + const [page, setPage] = useState(1); + const [pageCount, setPageCount] = useState(0); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + + async function load() { + try { + const pdfjs = await import('pdfjs-dist'); + pdfjs.GlobalWorkerOptions.workerSrc = new URL( + 'pdfjs-dist/build/pdf.worker.min.mjs', + import.meta.url, + ).toString(); + + const loadingTask = pdfjs.getDocument({ url }); + const document = await loadingTask.promise; + if (cancelled) { + void loadingTask.destroy(); + return; + } + loadingTaskRef.current = loadingTask; + documentRef.current = document; + setPageCount(document.numPages); + setPage(1); + } catch (cause) { + console.error('[pdf] konnte nicht geladen werden', cause); + if (!cancelled) setError('Die Anleitung konnte nicht geladen werden.'); + } + } + + void load(); + + return () => { + cancelled = true; + renderTaskRef.current?.cancel(); + void loadingTaskRef.current?.destroy(); + loadingTaskRef.current = null; + documentRef.current = null; + }; + }, [url]); + + const renderPage = useCallback(async (pageNumber: number) => { + const document = documentRef.current; + const canvas = canvasRef.current; + const container = containerRef.current; + if (!document || !canvas || !container) return; + + setLoading(true); + renderTaskRef.current?.cancel(); + + try { + const pdfPage = await document.getPage(pageNumber); + const unscaled = pdfPage.getViewport({ scale: 1 }); + // Fit the width of the container, but stay sharp on high-DPI screens. + const scale = (container.clientWidth || 600) / unscaled.width; + const ratio = Math.min(window.devicePixelRatio || 1, 2); + const viewport = pdfPage.getViewport({ scale: scale * ratio }); + + canvas.width = viewport.width; + canvas.height = viewport.height; + canvas.style.width = '100%'; + canvas.style.height = 'auto'; + + const context = canvas.getContext('2d'); + if (!context) return; + + const task = pdfPage.render({ canvas, canvasContext: context, viewport }); + renderTaskRef.current = task; + await task.promise; + } catch (cause) { + if ((cause as { name?: string })?.name !== 'RenderingCancelledException') { + console.error('[pdf] Seite konnte nicht gezeichnet werden', cause); + } + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + if (pageCount > 0) void renderPage(page); + }, [page, pageCount, renderPage]); + + if (error) { + return ( +
+

{error}

+ +
+ ); + } + + return ( +
+
+ + {loading ? ( +
+ +
+ ) : null} +
+ +
+ + + Seite {page} von {pageCount || '…'} + + +
+
+ ); +} diff --git a/src/components/katalog/photos-section.tsx b/src/components/katalog/photos-section.tsx new file mode 100644 index 0000000..1e5d22f --- /dev/null +++ b/src/components/katalog/photos-section.tsx @@ -0,0 +1,138 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { useState, useTransition } from 'react'; +import { Camera, Loader2, Trash2 } from 'lucide-react'; +import { toast } from 'sonner'; +import { addSetPhoto, deletePhoto } from '@/app/(app)/katalog/actions'; +import { fileUrl } from '@/lib/images'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Field } from '@/components/ui/field'; +import { Input } from '@/components/ui/input'; + +export type PhotoRow = { + id: string; + filePath: string; + thumbnailPath: string | null; + caption: string | null; +}; + +export function PhotosSection({ setId, photos }: { setId: string; photos: PhotoRow[] }) { + const router = useRouter(); + const [pending, startTransition] = useTransition(); + const [addOpen, setAddOpen] = useState(false); + + function submit(event: React.FormEvent) { + event.preventDefault(); + const formData = new FormData(event.currentTarget); + + startTransition(async () => { + const result = await addSetPhoto(setId, formData); + if (!result.ok) { + toast.error(result.error); + return; + } + toast.success('Foto gespeichert.'); + setAddOpen(false); + router.refresh(); + }); + } + + function remove(id: string) { + startTransition(async () => { + const result = await deletePhoto(id); + if (!result.ok) { + toast.error(result.error); + return; + } + toast.success('Foto gelöscht.'); + router.refresh(); + }); + } + + return ( +
+ {photos.length > 0 ? ( + + ) : ( +

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

+ )} + + + + + + + Foto hinzufügen + +
+ + {/* capture lets the phone open the camera directly. */} + + + + + +
+ + + + +
+
+
+ ); +} diff --git a/src/components/katalog/set-actions.tsx b/src/components/katalog/set-actions.tsx new file mode 100644 index 0000000..b936745 --- /dev/null +++ b/src/components/katalog/set-actions.tsx @@ -0,0 +1,216 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { useState, useTransition } from 'react'; +import { Gift, Loader2, Plus, Trash2 } from 'lucide-react'; +import { toast } from 'sonner'; +import { deleteSet } from '@/app/(app)/katalog/actions'; +import { addSetToCollection } from '@/app/(app)/sammlung/actions'; +import { addSetsToWishlist, createWishlist } from '@/app/(app)/wunschlisten/actions'; +import { PRIORITY_LABELS } from '@/lib/wishlist-logic'; +import { Button } from '@/components/ui/button'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Field } from '@/components/ui/field'; +import { Input } from '@/components/ui/input'; +import { NativeSelect } from '@/components/ui/native-select'; + +const NEW_LIST = '__neu__'; + +export function SetActions({ + setId, + setName, + owned, + wishlists, +}: { + setId: string; + setName: string; + owned: boolean; + wishlists: Array<{ id: string; title: string }>; +}) { + const router = useRouter(); + const [pending, startTransition] = useTransition(); + const [wishlistOpen, setWishlistOpen] = useState(false); + const [deleteOpen, setDeleteOpen] = useState(false); + const [targetId, setTargetId] = useState(wishlists[0]?.id ?? NEW_LIST); + const [newTitle, setNewTitle] = useState(''); + const [priority, setPriority] = useState('2'); + + function addToCollection() { + startTransition(async () => { + const result = await addSetToCollection(setId); + if (!result.ok) { + toast.error(result.error); + return; + } + toast.success('Zur Sammlung hinzugefügt.'); + router.refresh(); + }); + } + + function addToWishlist() { + startTransition(async () => { + let wishlistId = targetId; + + if (wishlistId === NEW_LIST) { + const created = await createWishlist({ title: newTitle, occasion: 'other' }); + if (!created.ok) { + toast.error(created.error); + return; + } + wishlistId = created.data.id; + } + + const result = await addSetsToWishlist({ wishlistId, setIds: [setId], priority }); + if (!result.ok) { + toast.error(result.error); + return; + } + + if (result.data.added === 0) toast.warning(result.data.message); + else toast.success('Auf die Wunschliste gesetzt.'); + + setWishlistOpen(false); + router.refresh(); + }); + } + + function remove() { + startTransition(async () => { + const result = await deleteSet(setId); + if (!result.ok) { + toast.error(result.error); + return; + } + toast.success('Set gelöscht.'); + router.push('/katalog'); + router.refresh(); + }); + } + + return ( +
+ + + + + + + {owned ? ( +

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

+ ) : null} + + + + + Auf Wunschliste setzen + {setName} + + +
+ + setTargetId(event.target.value)} + > + {wishlists.map((list) => ( + + ))} + + + + + {targetId === NEW_LIST ? ( + + setNewTitle(event.target.value)} + placeholder="Weihnachten 2026" + maxLength={80} + /> + + ) : null} + + + setPriority(event.target.value)} + > + {Object.entries(PRIORITY_LABELS).map(([value, label]) => ( + + ))} + + +
+ + + + + +
+
+ + + + + Set wirklich löschen? + + „{setName}“ wird aus dem Katalog entfernt – mit allen Sammlungseinträgen, + Anleitungen, Fotos und Wunschlisten-Einträgen dazu. Das lässt sich nicht rückgängig + machen. + + + + Abbrechen + Löschen + + + +
+ ); +} diff --git a/src/components/katalog/set-card.tsx b/src/components/katalog/set-card.tsx new file mode 100644 index 0000000..7b83260 --- /dev/null +++ b/src/components/katalog/set-card.tsx @@ -0,0 +1,88 @@ +import Link from 'next/link'; +import { Check, Gift, ImageOff } from 'lucide-react'; +import { cn } from 'cn'; +import type { CatalogRow } from '@/lib/queries/catalog'; +import { formatEuro } from '@/lib/money'; +import { setImageSrc } from '@/lib/images'; +import { Badge } from '@/components/ui/badge'; + +/** + * One set in the catalog grid. + * + * Rendered as a plain block so it can be used both as a link (browsing) and + * inside a selection wrapper (bulk add to a wishlist). + */ +export function SetCardContent({ set }: { set: CatalogRow }) { + const image = setImageSrc(set); + const owned = set.ownedCopies > 0; + + return ( +
+
+ {image ? ( + // Plain img: images are either local files or a URL the user entered, + // and the optimizer must never fetch arbitrary remote hosts. + // eslint-disable-next-line @next/next/no-img-element + + ) : ( +
+ +
+ )} + +
+ {owned ? ( + + + {set.ownedCopies > 1 ? `${set.ownedCopies}× vorhanden` : 'Vorhanden'} + + ) : null} + {set.wishlistCount > 0 ? ( + + + Auf Wunschliste + + ) : null} +
+
+ +
+
+ + {set.setNumber} + + {set.manufacturerName} +
+

{set.name}

+
+ {[set.themeName, set.year].filter(Boolean).join(' · ')} + {set.retailPriceCents !== null ? ( + + {formatEuro(set.retailPriceCents)} + + ) : null} +
+
+
+ ); +} + +export function SetCard({ set, className }: { set: CatalogRow; className?: string }) { + return ( + div]:hover:border-primary/40', + className, + )} + > + + + ); +} diff --git a/src/components/katalog/set-form.tsx b/src/components/katalog/set-form.tsx new file mode 100644 index 0000000..2a95a80 --- /dev/null +++ b/src/components/katalog/set-form.tsx @@ -0,0 +1,248 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { useState, useTransition } from 'react'; +import { Loader2, Save } from 'lucide-react'; +import { toast } from 'sonner'; +import { createSet, updateSet } from '@/app/(app)/katalog/actions'; +import { centsToInput } from '@/lib/money'; +import { setImageSrc } from '@/lib/images'; +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Field } from '@/components/ui/field'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { NativeSelect } from '@/components/ui/native-select'; +import { Textarea } from '@/components/ui/textarea'; + +export type SetFormValues = { + id: string; + manufacturerId: string; + themeId: string | null; + setNumber: string; + name: string; + year: number | null; + pieceCount: number | null; + minifigCount: number | null; + ean: string | null; + ageRange: string | null; + imageUrl: string | null; + imagePath: string | null; + retailPriceCents: number | null; + notes: string | null; +}; + +type Props = { + manufacturers: Array<{ id: string; name: string }>; + themes: Array<{ id: string; name: string; manufacturerId: string }>; + set?: SetFormValues; + /** Prefilled EAN coming from the barcode scanner (phase 2). */ + defaultEan?: string; +}; + +export function SetForm({ manufacturers, themes, set, defaultEan }: Props) { + const router = useRouter(); + const [pending, startTransition] = useTransition(); + const [manufacturerId, setManufacturerId] = useState( + set?.manufacturerId ?? manufacturers[0]?.id ?? '', + ); + + const existingImage = set ? setImageSrc(set) : null; + const availableThemes = themes.filter((theme) => theme.manufacturerId === manufacturerId); + + function onSubmit(event: React.FormEvent) { + event.preventDefault(); + const formData = new FormData(event.currentTarget); + + startTransition(async () => { + if (set) { + const result = await updateSet(set.id, formData); + if (!result.ok) { + toast.error(result.error); + return; + } + toast.success('Set gespeichert.'); + router.push(`/katalog/${set.id}`); + router.refresh(); + return; + } + + const result = await createSet(formData); + if (!result.ok) { + toast.error(result.error); + return; + } + toast.success('Set angelegt.'); + router.push(`/katalog/${result.data.id}`); + router.refresh(); + }); + } + + return ( +
+
+ + setManufacturerId(event.target.value)} + required + > + {manufacturers.map((manufacturer) => ( + + ))} + + + + + + + {availableThemes.map((theme) => ( + + ))} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+ + {existingImage ? ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + + {set?.imagePath ? ( + + ) : null} +
+ ) : null} + +

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

+
+ + +