diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..713a692 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,20 @@ +node_modules +.next +.git +.github +data +data-e2e +test-results +playwright-report +blob-report +coverage +.env +.env.*.local +*.log +docs +README.md +QUICKSTART.md +CHANGELOG.md +CLAUDE.md +.vscode +.idea diff --git a/.env.example b/.env.example index 9e7b007..1306964 100644 --- a/.env.example +++ b/.env.example @@ -21,6 +21,12 @@ MAX_UPLOAD_MB=25 # Zeitzone des Containers. TZ=Europe/Berlin +# Wo der Container auf dem Host lauscht. Voreinstellung: nur localhost, weil +# der Reverse Proxy auf demselben Host laeuft. Sitzt der Proxy woanders: +# BIND_ADDRESS=0.0.0.0 (und dann unbedingt die Firewall zuziehen). +# BIND_ADDRESS=127.0.0.1 +# HOST_PORT=3000 + # Optional (ab Phase 2): Rebrickable-API-Key fuer den Lego-Import. # Ohne Key funktioniert alles manuell weiter. # REBRICKABLE_API_KEY= diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..015ea15 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,17 @@ +# Normalise line endings so files written on Windows still work in the +# Linux container. A shell script with CRLF fails with the confusing +# "no such file or directory" on its shebang line. +* text=auto eol=lf + +*.sh text eol=lf +Dockerfile text eol=lf +*.yml text eol=lf +*.yaml text eol=lf + +# Binary files must never be touched. +*.png binary +*.jpg binary +*.jpeg binary +*.webp binary +*.pdf binary +*.ico binary diff --git a/.gitignore b/.gitignore index fc8ae8e..23c007c 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,10 @@ next-env.d.ts # runtime data (database, uploads) - never in git, this is the backup target /data +/data-e2e +/data-shots +/data-docker +/backups # production *.tsbuildinfo diff --git a/CHANGELOG.md b/CHANGELOG.md index 6346bc2..0eecc79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,14 +7,86 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/). ## [Unreleased] +## [0.1.0] - 2026-09-07 + +Erste lauffaehige Fassung: Phase 1 vollstaendig, im Docker-Stack verifiziert. + ### Hinzugefuegt -- Projekt-Scaffold: Next.js 15 (App Router, TypeScript strict), - Tailwind CSS 4, shadcn/ui, pnpm. -- Vollstaendiges Datenmodell in Drizzle ORM mit initialer Migration: - Katalog, Besitz, Wunschlisten und die Auth-Tabellen von better-auth. -- SQLite-Anbindung mit WAL-Modus, aktivierten Fremdschluesseln und - Migrations-Runner (`pnpm db:migrate`). -- Projektdokumentation: CLAUDE.md, Roadmap, Changelog, Devlog. +**Anmeldung** +- Registrierung des ersten Nutzers, der damit Administrator wird; danach ist + die Registrierung geschlossen. +- Anmeldung per E-Mail und Passwort oder per Passkey (WebAuthn), inklusive + Conditional UI, wo der Browser sie unterstuetzt. +- Passkey-Verwaltung unter "Konto": beliebig viele Geraete, umbenennen, + loeschen. +- Rate-Limiting auf den Login-Endpunkten, in der Datenbank abgelegt und damit + neustartfest. -[Unreleased]: https://git.wilde-server.de/florian.wilde/bricktrack/compare/main...HEAD +**Katalog** +- Set-Katalog mit Kachelansicht, Suche ueber Setnummer, Name und EAN sowie + Filtern nach Hersteller, Thema, Jahr und Besitzstatus. +- Sets anlegen und bearbeiten, mit Bild-Upload oder Bild-URL. +- Set-Detailseite mit Metadaten, Anleitungen, Fotos und Sammlungseintraegen. +- Anleitungen als PDF-Upload mit eingebautem pdf.js-Viewer oder als externer + Link; mehrere Anleitungen je Set. +- Eigene Fotos je Set, am Handy direkt aus der Kamera. + +**Sammlung** +- Uebersicht aller besessenen Sets mit Filtern nach Status, Vollstaendigkeit, + Standort und Hersteller. +- Statistik-Kacheln: Anzahl Sets, Teile gesamt, Wert nach UVP, fehlende Sets, + sowie die Aufteilung nach Hersteller. +- Sammlungseintraege mit Status, Zustand, Standort, Kaufdatum, Kaufpreis und + "geschenkt von". + +**Wunschlisten** +- Ansicht "Fehlende Sets" mit Mehrfachauswahl: langes Druecken startet die + Auswahl, die Aktionsleiste uebernimmt sie in eine bestehende oder neue + Wunschliste. Dubletten und bereits besessene Sets werden uebersprungen und + im Ergebnis benannt. +- Wunschlisten mit Prioritaet, Reihenfolge, Anlass, Termin und Hinweistext. +- Bereich "Aus fehlenden Sets hinzufuegen" auf der Wunschlisten-Seite, + vorgefiltert auf Sets, die noch nicht drauf sind. +- Oeffentliche Ansicht unter /w/[shareToken] ohne Anmeldung; Besucher + reservieren mit ihrem Namen und erhalten einen Storno-Link. +- Ueberraschungsmodus: der Besitzer sieht nur "reserviert", nicht von wem. +- Erfuellte Wuensche werden automatisch markiert, sobald das Set in der + Sammlung landet, und verschwinden aus der oeffentlichen Ansicht. +- Share-Token laesst sich neu erzeugen; der alte Link ist danach tot. + +**Stammdaten** +- Hersteller, Themen (mit Unterthemen) und Standorte (schachtelbar) verwalten. + +**Betrieb** +- Docker-Stack: Multi-Stage-Build mit standalone-Output, non-root Nutzer, + Healthcheck unter /api/health, ein Service und ein Bind-Mount auf ./data. +- Migrationen laufen bei jedem Containerstart automatisch. +- update.sh mit Sicherung, Build, Healthcheck und automatischem Rollback. +- Seed-Skript mit 3 Herstellern, 6 Themen, 10 Sets, 4 Sammlungseintraegen und + einer Wunschliste. +- Dokumentation: Schnellstart inklusive Nginx-Proxy-Manager-Konfiguration, + bebilderte Anleitung, README, Roadmap und Devlog. + +### Sicherheit + +- Serverseitige Validierung aller Eingaben mit zod. +- Uploads auf PDF, JPEG, PNG und WebP begrenzt, geprueft an den Magic Bytes + statt am gemeldeten MIME-Typ; Dateinamen erzeugt immer der Server. +- Oeffentliche Wunschlisten-Routen nutzen ausschliesslich den Share-Token und + geben nichts ausserhalb der Wunschliste preis; die Seite ist noindex. +- Storno- und Einladungs-Token liegen nur als SHA-256-Hash in der Datenbank. +- Session-Cookies httpOnly, sameSite=lax und im Betrieb secure. +- Anleitungs-PDFs und eigene Fotos sind nur mit Session abrufbar; oeffentlich + sind allein die Set-Bilder, die die geteilte Wunschliste braucht. + +### Bekannte Einschraenkungen + +- Der Katalog enthaelt nur, was von Hand angelegt wurde. Der Import ueber + Rebrickable kommt in 0.2.0. +- Weitere Konten sind noch nicht moeglich; Einladungslinks kommen in 0.2.0. +- Unter Windows scheitert `pnpm build` an fehlenden Symlink-Rechten. Der + Docker-Build unter Linux ist davon nicht betroffen. + +[Unreleased]: https://git.wilde-server.de/florian.wilde/bricktrack/compare/v0.1.0...HEAD +[0.1.0]: https://git.wilde-server.de/florian.wilde/bricktrack/releases/tag/v0.1.0 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/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4ca9e32 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,86 @@ +# syntax=docker/dockerfile:1 + +# --------------------------------------------------------------------------- +# Bricktrack - Multi-Stage-Build +# +# Ergebnis ist ein schlankes Runtime-Image mit Next.js im Standalone-Modus, +# das als Nutzer "node" laeuft. Alle Daten liegen ausserhalb des Images unter +# /data, damit ein Update nichts anfasst, was dir gehoert. +# --------------------------------------------------------------------------- + +FROM node:24-alpine AS base +ENV PNPM_HOME=/pnpm +ENV PATH=$PNPM_HOME:$PATH +ENV NEXT_TELEMETRY_DISABLED=1 +RUN corepack enable + +# --- Abhaengigkeiten ------------------------------------------------------- +# better-sqlite3 und sharp bringen Prebuilds mit; die Build-Werkzeuge sind der +# Rueckfallweg, falls fuer die Plattform keines existiert. +FROM base AS deps +RUN apk add --no-cache libc6-compat python3 make g++ +WORKDIR /app +COPY package.json pnpm-lock.yaml ./ +RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ + pnpm install --frozen-lockfile + +# --- Build ----------------------------------------------------------------- +FROM base AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +# Platzhalter nur fuer den Build: src/env.ts validiert beim Import, und die +# echten Werte kommen zur Laufzeit aus der .env. Keiner dieser Werte landet im +# Image-Zustand, weil keine Seite vorgerendert wird. +ENV NODE_ENV=production +ENV BASE_URL=http://localhost:3000 +ENV BETTER_AUTH_SECRET=build-platzhalter-0123456789-abcdefghij +ENV DATA_DIR=/tmp/build-data + +RUN pnpm build + +# Der Migrations-Runner wird zu einer einzelnen Datei gebuendelt. So braucht +# das Runtime-Image kein drizzle-kit und kein tsx - nur das native +# better-sqlite3, das ohnehin dabei ist. Das Buendel ist CommonJS, weil +# mitgebuendelte CJS-Abhaengigkeiten sonst am require() der Node-Builtins +# scheitern. +RUN pnpm exec esbuild scripts/migrate.ts \ + --bundle \ + --platform=node \ + --format=cjs \ + --target=node22 \ + --external:better-sqlite3 \ + --outfile=dist/migrate.cjs + +# --- Laufzeit -------------------------------------------------------------- +FROM base AS runner +WORKDIR /app + +ENV NODE_ENV=production +ENV PORT=3000 +ENV HOSTNAME=0.0.0.0 +ENV DATA_DIR=/data +ENV MIGRATIONS_DIR=/app/drizzle + +# Standalone bringt seine eigenen node_modules mit - kein pnpm install noetig. +COPY --from=builder --chown=node:node /app/.next/standalone ./ +COPY --from=builder --chown=node:node /app/.next/static ./.next/static +COPY --from=builder --chown=node:node /app/public ./public +COPY --from=builder --chown=node:node /app/drizzle ./drizzle +COPY --from=builder --chown=node:node /app/dist/migrate.cjs ./migrate.cjs +COPY --chown=node:node docker/entrypoint.sh /app/entrypoint.sh +RUN chmod +x /app/entrypoint.sh + +# Anlaufstelle fuer Datenbank und Uploads. Wird per Bind-Mount ueberdeckt; +# das Anlegen hier sorgt nur dafuer, dass der Pfad auch ohne Mount existiert. +RUN mkdir -p /data/db /data/files && chown -R node:node /data + +USER node +EXPOSE 3000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD wget -q --spider http://127.0.0.1:3000/api/health || exit 1 + +ENTRYPOINT ["/app/entrypoint.sh"] +CMD ["node", "server.js"] diff --git a/QUICKSTART.md b/QUICKSTART.md new file mode 100644 index 0000000..ac6e172 --- /dev/null +++ b/QUICKSTART.md @@ -0,0 +1,211 @@ +# Bricktrack – Schnellstart + +Von null auf laufende Instanz. Zeitbedarf: etwa 15 Minuten, davon 10 für den +ersten Docker-Build. + +**Voraussetzungen:** Ein Linux-Host (z. B. eine LXC oder VM auf Proxmox) mit +Docker und dem Compose-Plugin, ein Reverse Proxy mit gültigem TLS-Zertifikat +und eine Subdomain, die auf den Proxy zeigt. + +> **Warum HTTPS Pflicht ist:** Passkeys (WebAuthn) funktionieren +> ausschließlich über HTTPS und sind fest an den Hostnamen gebunden. Ohne +> Zertifikat bleibt nur die Anmeldung per Passwort. Wird die Domain später +> geändert, sind **alle** Passkeys ungültig und müssen neu angelegt werden. + +--- + +## 1. Projekt holen + +```bash +sudo mkdir -p /opt/bricktrack +sudo chown "$USER" /opt/bricktrack +git clone https://git.wilde-server.de/florian.wilde/bricktrack.git /opt/bricktrack +cd /opt/bricktrack +``` + +## 2. Konfiguration anlegen + +```bash +cp .env.example .env +``` + +Jetzt `.env` öffnen und zwei Werte setzen: + +```bash +# Die Adresse, unter der du die App im Browser aufrufst. +BASE_URL=https://bricktrack.example.de + +# Signiergeheimnis - hiermit erzeugen: +# openssl rand -base64 48 +BETTER_AUTH_SECRET= +``` + +Alles andere kann so bleiben. `DATA_DIR=/data` ist der Pfad **im Container** +und wird nicht angefasst. + +## 3. Datenverzeichnis vorbereiten + +Der Container läuft aus Sicherheitsgründen nicht als root, sondern als Nutzer +mit der UID 1000. Das gemountete Verzeichnis muss ihm gehören: + +```bash +mkdir -p ./data +sudo chown -R 1000:1000 ./data +``` + +Diesen Schritt vergisst man gern – der Container sagt dir dann aber beim Start +genau das. + +## 4. Starten + +```bash +docker compose up -d --build +``` + +Der erste Build dauert einige Minuten. Danach: + +```bash +docker compose ps # Status "healthy" abwarten +docker compose logs -f app # Log mitlesen, mit Strg+C beenden +curl -s http://127.0.0.1:3000/api/health +# {"status":"ok","time":"..."} +``` + +## 5. Reverse Proxy einrichten (Nginx Proxy Manager) + +In NPM unter **Hosts → Proxy Hosts → Add Proxy Host**: + +**Reiter „Details"** + +| Feld | Wert | +|---|---| +| Domain Names | `bricktrack.example.de` | +| Scheme | `http` | +| Forward Hostname / IP | IP des Docker-Hosts, z. B. `192.168.1.50` | +| Forward Port | `3000` | +| Cache Assets | **aus** (Next.js setzt seine Cache-Header selbst) | +| Block Common Exploits | an | +| Websockets Support | **an** | + +**Reiter „SSL"** + +| Feld | Wert | +|---|---| +| SSL Certificate | Let's Encrypt anfordern oder vorhandenes wählen | +| Force SSL | **an** | +| HTTP/2 Support | an | +| HSTS Enabled | an (optional, aber sinnvoll) | + +**Reiter „Advanced"** – hier hinein, sonst scheitern große Anleitungs-PDFs am +Standardlimit von 1 MB: + +```nginx +# Anleitungs-PDFs koennen mehrere Megabyte gross sein. +client_max_body_size 64m; + +# Uploads und PDF-Auslieferung duerfen etwas dauern. +proxy_read_timeout 300; +proxy_send_timeout 300; + +# Die App leitet ihre oeffentliche Adresse und die Client-IP hieraus ab. +proxy_set_header X-Forwarded-Proto $scheme; +proxy_set_header X-Forwarded-Host $host; +proxy_set_header X-Real-IP $remote_addr; +``` + +`client_max_body_size` muss mindestens so groß sein wie `MAX_UPLOAD_MB` in +deiner `.env` (Standard 25 MB); 64m gibt Luft. + +> **Wo lauscht der Container?** Voreinstellung ist `127.0.0.1:3000` – die App +> ist dann ausschließlich über einen Proxy auf demselben Host erreichbar. +> Zwei Fälle brauchen eine Anpassung in der `.env`: +> +> - **NPM läuft auf einem anderen Host:** `BIND_ADDRESS=0.0.0.0` setzen und +> den Zugriff per Firewall auf die Proxy-IP beschränken. +> - **Port 3000 ist schon belegt:** `HOST_PORT=3001` (o. ä.) setzen und im +> Proxy denselben Port eintragen. +> +> **Läuft NPM im selben Docker-Netz?** Dann brauchst du gar kein +> Port-Mapping: die `ports`-Zeile in `docker-compose.yml` entfernen, beide +> Container in ein gemeinsames Netzwerk hängen und als Forward Hostname den +> Container-Namen `bricktrack` mit Port `3000` eintragen. + +## 6. Ersten Nutzer anlegen + +`https://bricktrack.example.de` aufrufen. Die App leitet direkt auf die +Registrierung. + +**Der erste registrierte Nutzer wird automatisch Administrator.** Danach ist +die Registrierung geschlossen – weitere Konten entstehen nur über einen +Einladungslink (ab Version 0.2.0). + +Also: gleich beim ersten Aufruf selbst registrieren, bevor es jemand anderes +tut. + +## 7. Passkey hinterlegen + +Unter **Konto → Passkeys → Passkey hinzufügen**. Ab dann meldest du dich per +Fingerabdruck, Gesichtserkennung oder Geräte-PIN an. Für jedes Gerät ein +eigener Passkey – Handy, Tablet, Laptop. + +## 8. Loslegen + +1. **Verwaltung → Hersteller**: Lego, Playmobil, Cobi … anlegen. +2. **Verwaltung → Themen**: „City", „Technic", „Novelmore" … +3. **Verwaltung → Standorte**: „Regal Kinderzimmer", „Kiste Keller 3" … +4. **Katalog → Set anlegen**: Setnummer, Name, Bild. +5. Auf der Set-Seite **„Zur Sammlung hinzufügen"**, wenn das Set da ist. +6. **Katalog → Filter „Fehlt noch"**: Kachel lange drücken, mehrere auswählen, + **„Zur Wunschliste hinzufügen"**. +7. **Wunschlisten → Liste öffnen → Link kopieren** und verschicken. + +Wie das im Einzelnen aussieht, zeigt die [bebilderte +Anleitung](docs/ANLEITUNG.md). + +> Beispieldaten (`pnpm db:seed`) gibt es nur in der Entwicklungsumgebung – das +> Produktions-Image bringt sie bewusst nicht mit, damit in deiner Sammlung +> nichts steht, was dir nicht gehört. + +--- + +## Sichern + +Alles, was zählt, liegt in **einem** Verzeichnis: + +```bash +docker compose stop app +tar czf bricktrack-$(date +%F).tar.gz data .env +docker compose start app +``` + +Zurückspielen: + +```bash +docker compose down +tar xzf bricktrack-2026-09-06.tar.gz +sudo chown -R 1000:1000 ./data +docker compose up -d +``` + +## Aktualisieren + +```bash +./update.sh +``` + +Das Skript sichert vorher automatisch, baut neu, wartet auf den Healthcheck +und rollt bei einem Fehlschlag zurück. Details in der [README](README.md). + +--- + +## Wenn etwas klemmt + +| Symptom | Ursache und Lösung | +|---|---| +| `FEHLER: In /data darf nicht geschrieben werden` | `sudo chown -R 1000:1000 ./data` | +| Container startet, aber `unhealthy` | `docker compose logs app` – meist eine fehlende Variable in `.env` | +| `Ungueltige Umgebungsvariablen: BETTER_AUTH_SECRET` | Das Geheimnis braucht mindestens 32 Zeichen | +| Passkey lässt sich nicht anlegen | Kein HTTPS, oder `BASE_URL` passt nicht zur aufgerufenen Domain | +| Passkeys funktionieren nach einem Domainwechsel nicht mehr | Erwartet – Passkeys sind an die Domain gebunden, neu anlegen | +| `413 Request Entity Too Large` beim PDF-Upload | `client_max_body_size` im Proxy erhöhen (Schritt 5) | +| Anmeldung meldet „Zu viele Versuche" | Rate-Limit: fünf Fehlversuche pro Minute, danach kurz warten | diff --git a/README.md b/README.md index e215bc4..409040a 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,229 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +# Bricktrack -## Getting Started +Self-hosted Verwaltung für die Spielzeug-Set-Sammlung deines Kindes – Lego, +Playmobil, Cobi, BlueBrixx, Mega Bloks und jeden weiteren Hersteller, den du +selbst anlegst. -First, run the development server: +Die App beantwortet zwei Fragen sofort: + +- **Welche Sets hat er?** → die Sammlung, mit Standort, Zustand und Statistik. +- **Welche Sets hat er noch nicht?** → alles im Katalog ohne Sammlungseintrag. + +Aus den fehlenden Sets entsteht in wenigen Handgriffen eine Wunschliste, die +du per Link mit Familie und Freunden teilst. Die reservieren dort ihr Geschenk +– **ohne sich zu registrieren**, und ohne dass jemand doppelt kauft. + +> **Neu hier?** → [QUICKSTART.md](QUICKSTART.md) führt dich in 15 Minuten von +> null zur laufenden Instanz, inklusive Nginx Proxy Manager. +> **Bedienung?** → [docs/ANLEITUNG.md](docs/ANLEITUNG.md), bebildert. + +--- + +## Was drin ist + +| | | +|---|---| +| **Katalog** | Sets aller Hersteller, Suche nach Nummer, Name oder EAN, Filter nach Hersteller, Thema, Jahr und Besitzstatus | +| **Sammlung** | Was tatsächlich da ist: Status, Vollständigkeit, Zustand, Standort, Kaufpreis, „geschenkt von" | +| **Anleitungen** | PDFs hochladen und im Browser blättern (pdf.js, ohne externe Dienste) oder auf die Hersteller-Seite verlinken | +| **Fotos** | Eigene Bilder je Set – direkt aus der Handykamera | +| **Wunschlisten** | Mehrfachauswahl aus den fehlenden Sets, Priorität, Reihenfolge, Termin | +| **Teilen** | Öffentlicher Link ohne Anmeldung, Reservierung mit Namen, Storno-Link, Überraschungsmodus | +| **Anmeldung** | E-Mail und Passwort oder Passkey (WebAuthn), mehrere Geräte je Konto | + +Mobil zuerst gebaut: Die App wird im Laden und im Kinderzimmer benutzt, nicht +am Schreibtisch. + +## Technik + +Next.js 15 (App Router, Server Actions, TypeScript strict) · Tailwind CSS 4 · +shadcn/ui · SQLite über Drizzle ORM und better-sqlite3 · better-auth mit +Passkey-Plugin · alles in **einem** Docker-Container. + +Keine Cloud, keine externen Dienste, keine Telemetrie. Der Browser lädt nichts +von fremden Servern außer den Bild-URLs, die du selbst hinterlegt hast. + +Details zu Stack, Konventionen und Datenmodell: [CLAUDE.md](CLAUDE.md). + +--- + +## Installation + +Ausführlich in [QUICKSTART.md](QUICKSTART.md). Die Kurzfassung: ```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev +git clone https://git.wilde-server.de/florian.wilde/bricktrack.git +cd bricktrack + +cp .env.example .env +# BASE_URL und BETTER_AUTH_SECRET setzen: +# openssl rand -base64 48 + +mkdir -p ./data +sudo chown -R 1000:1000 ./data # der Container laeuft als UID 1000 + +docker compose up -d --build ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +Danach `https://deine-domain` aufrufen. **Der erste registrierte Nutzer wird +Administrator**, danach ist die Registrierung geschlossen. -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +### Konfiguration -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. +Alle Werte kommen aus `.env` (Vorlage: `.env.example`). -## Learn More +| Variable | Pflicht | Standard | Bedeutung | +|---|---|---|---| +| `BASE_URL` | ja | `http://localhost:3000` | Öffentliche Adresse. Bestimmt die Passkey-Domain (RP-ID). | +| `BETTER_AUTH_SECRET` | ja | – | Signiergeheimnis, mindestens 32 Zeichen. | +| `DATA_DIR` | nein | `/data` | Datenverzeichnis im Container. Nicht ändern. | +| `MAX_UPLOAD_MB` | nein | `25` | Größenlimit je Datei. Muss zum Proxy passen. | +| `TZ` | nein | `Europe/Berlin` | Zeitzone des Containers. | +| `BIND_ADDRESS` | nein | `127.0.0.1` | Auf welcher Host-Adresse der Container lauscht. `0.0.0.0`, wenn der Proxy auf einem anderen Host läuft. | +| `HOST_PORT` | nein | `3000` | Port auf dem Host, falls 3000 belegt ist. | +| `REBRICKABLE_API_KEY` | nein | – | Ab Version 0.2.0: Lego-Import. Ohne Key läuft alles manuell. | -To learn more about Next.js, take a look at the following resources: +### Reverse Proxy -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +TLS wird außerhalb terminiert, der Container spricht nur HTTP auf Port 3000. +Drei Dinge sind wichtig: -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! +1. **HTTPS mit Force-SSL** – ohne das keine Passkeys. +2. **`client_max_body_size`** mindestens so groß wie `MAX_UPLOAD_MB`, sonst + scheitern große Anleitungs-PDFs mit „413". +3. **`X-Forwarded-For`** durchreichen, damit das Rate-Limit auf dem + Login-Endpunkt die echte Client-IP sieht. -## Deploy on Vercel +Die vollständige Nginx-Proxy-Manager-Konfiguration steht in +[QUICKSTART.md, Schritt 5](QUICKSTART.md#5-reverse-proxy-einrichten-nginx-proxy-manager). -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. +--- -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +## Backup und Restore + +**Ein Verzeichnis reicht.** `./data` enthält die Datenbank *und* alle +hochgeladenen Dateien: + +``` +data/ + db/bricktrack.db SQLite (dazu -wal und -shm) + files/images/sets/… Set-Bilder + files/photos/… eigene Fotos + files/instructions/… Anleitungs-PDFs +``` + +**Sichern** (Container kurz anhalten, damit die Datenbank nicht mitten im +Schreiben eingepackt wird): + +```bash +docker compose stop app +tar czf bricktrack-$(date +%F).tar.gz data .env +docker compose start app +``` + +**Zurückspielen:** + +```bash +docker compose down +rm -rf data +tar xzf bricktrack-2026-09-06.tar.gz +sudo chown -R 1000:1000 ./data +docker compose up -d +``` + +Migrationen laufen bei jedem Start automatisch – ein altes Backup wird beim +Zurückspielen also auf den aktuellen Schemastand gehoben. + +## Aktualisieren + +```bash +./update.sh +``` + +Das Skript sichert `./data` und `.env`, holt den neuen Stand, baut das Image, +startet neu und wartet auf den Healthcheck. Wird der Container nicht gesund, +rollt es Code **und** Container automatisch auf den vorherigen Commit zurück +und sagt dir, wo die Sicherung liegt. + +```bash +./update.sh --no-pull # nur neu bauen, ohne git pull +./update.sh --no-backup # ohne Sicherung (nicht empfohlen) +``` + +Die letzten zehn Sicherungen bleiben unter `./backups` liegen. + +--- + +## Entwicklung + +```bash +corepack enable pnpm # pnpm 10 +pnpm install + +cp .env.example .env # DATA_DIR=./data, BASE_URL=http://localhost:3000 +pnpm db:migrate # Datenbank anlegen +pnpm db:seed # Beispieldaten: 3 Hersteller, 10 Sets, 1 Wunschliste +pnpm dev # http://localhost:3000 +``` + +| Befehl | Wirkung | +|---|---| +| `pnpm dev` | Entwicklungsserver | +| `pnpm build` | Produktionsbuild (standalone) | +| `pnpm lint` / `pnpm typecheck` | ESLint / TypeScript – beide müssen sauber sein | +| `pnpm test` | Unit-Tests (Vitest) | +| `pnpm test:e2e` | Playwright-Smoke-Test gegen ein eigenes `./data-e2e` | +| `pnpm db:generate` | SQL-Migration aus dem Drizzle-Schema erzeugen | +| `pnpm db:migrate` | Migrationen anwenden | +| `pnpm db:studio` | Drizzle Studio | + +> **Unter Windows** schlägt `pnpm build` mit `EPERM … symlink` fehl: der +> Standalone-Output legt Symlinks an, wofür Windows den Entwicklermodus oder +> administrative Rechte braucht. Der Docker-Build unter Linux ist davon nicht +> betroffen. Zum Prüfen lokal: `docker build -t bricktrack:test .` + +Die Passkey-Anmeldung funktioniert lokal auch über `http://localhost` – +WebAuthn behandelt localhost als sicheren Kontext. Auf jeder anderen Adresse +braucht es HTTPS. + +--- + +## Datenmodell in einem Absatz + +Ein **`set`** ist ein Katalogeintrag: Das Set existiert in der Welt, +unabhängig davon, ob wir es besitzen. Ein **`collection_item`** verknüpft ein +Set mit einem tatsächlich vorhandenen Exemplar – dasselbe Set zweimal im Regal +sind zwei Einträge. Daraus folgt die zentrale Regel: + +> „Hat er nicht" = Sets im Katalog, zu denen kein `collection_item` mit einem +> Status ungleich `sold` existiert. + +Ein verkauftes Exemplar zählt also nicht mehr als Besitz – das Set taucht +wieder unter „fehlt noch" auf und darf zurück auf eine Wunschliste. Diese +Regel steht an genau einer Stelle im Code: `src/lib/ownership.ts`. + +Das vollständige Modell inklusive aller Tabellen: [CLAUDE.md](CLAUDE.md#datenmodell). + +## Sicherheit + +- Jede Eingabe wird serverseitig mit zod validiert. +- Uploads sind auf PDF, JPEG, PNG und WebP begrenzt – geprüft an den **Magic + Bytes**, nicht am gemeldeten MIME-Typ. Dateinamen erzeugt immer der Server. +- Die öffentliche Wunschliste nutzt ausschließlich den Share-Token als + Schlüssel und gibt nichts außer den Wunschlisten-Daten preis. Sie ist + `noindex`. +- Storno- und Einladungs-Token liegen nur als SHA-256-Hash in der Datenbank. +- Session-Cookies sind `httpOnly`, `sameSite=lax` und im Betrieb `secure`. + Auf den Login-Endpunkten liegt ein Rate-Limit (5 Versuche pro Minute). +- Set-Bilder sind ohne Anmeldung abrufbar – die geteilte Wunschliste braucht + sie. Anleitungs-PDFs und eigene Fotos **nie**. + +## Stand und Pläne + +Aktuelle Version und Änderungen: [CHANGELOG.md](CHANGELOG.md). +Was als Nächstes kommt: [docs/ROADMAP.md](docs/ROADMAP.md). +Wie lange was gedauert hat: [docs/DEVLOG.md](docs/DEVLOG.md). + +## Lizenz + +Privates Projekt, keine Lizenz vergeben. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1b0ebb8 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,47 @@ +# --------------------------------------------------------------------------- +# Bricktrack - ein Service, ein Datenverzeichnis. +# +# TLS terminiert der Reverse Proxy (z. B. Nginx Proxy Manager). Der Container +# spricht nur HTTP und muss nicht nach aussen erreichbar sein. +# --------------------------------------------------------------------------- + +services: + app: + build: + context: . + dockerfile: Dockerfile + image: bricktrack:latest + container_name: bricktrack + restart: unless-stopped + + env_file: + - .env + + environment: + # Im Container liegen die Daten immer unter /data - der Pfad auf dem Host + # wird ueber den Bind-Mount unten bestimmt. + DATA_DIR: /data + PORT: 3000 + + ports: + # Standardmaessig nur an localhost gebunden: erreichbar wird die App + # ueber den Reverse Proxy. Sitzt der Proxy auf einem anderen Host, in + # der .env BIND_ADDRESS=0.0.0.0 setzen (und die Firewall zuziehen). + - "${BIND_ADDRESS:-127.0.0.1}:${HOST_PORT:-3000}:3000" + + volumes: + # Das eine Verzeichnis, das gesichert werden muss. + - ./data:/data + + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:3000/api/health"] + interval: 30s + timeout: 5s + start_period: 20s + retries: 3 + + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 0000000..cd7e63c --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,30 @@ +#!/bin/sh +# Startet den Container: Verzeichnisse pruefen, Migrationen anwenden, App starten. +set -e + +DATA_DIR="${DATA_DIR:-/data}" + +echo "[bricktrack] Datenverzeichnis: $DATA_DIR" + +if ! mkdir -p "$DATA_DIR/db" "$DATA_DIR/files" 2>/dev/null; then + echo "" + echo " FEHLER: In $DATA_DIR darf nicht geschrieben werden." + echo "" + echo " Der Container laeuft als Nutzer 'node' (UID 1000). Das gemountete" + echo " Verzeichnis muss ihm gehoeren. Auf dem Host einmalig:" + echo "" + echo " mkdir -p ./data && sudo chown -R 1000:1000 ./data" + echo "" + exit 1 +fi + +if [ ! -w "$DATA_DIR/db" ]; then + echo " FEHLER: $DATA_DIR/db ist nicht beschreibbar (sudo chown -R 1000:1000 ./data)." + exit 1 +fi + +echo "[bricktrack] Migrationen werden angewendet …" +node /app/migrate.cjs + +echo "[bricktrack] Start auf Port ${PORT:-3000}" +exec "$@" diff --git a/docs/ANLEITUNG.md b/docs/ANLEITUNG.md new file mode 100644 index 0000000..5578bc2 --- /dev/null +++ b/docs/ANLEITUNG.md @@ -0,0 +1,322 @@ +# Bricktrack – Anleitung + +So benutzt du Bricktrack im Alltag. Die Bilder sind auf einem Handy +aufgenommen, weil die App genau dafür gebaut ist: für den Laden und das +Kinderzimmer, nicht für den Schreibtisch. + +> Installation und Reverse Proxy stehen im [Schnellstart](../QUICKSTART.md). +> Die Bilder hier lassen sich mit `pnpm screenshots` jederzeit neu erzeugen – +> sie stammen aus den Beispieldaten des Seed-Skripts. + +--- + +## Inhalt + +1. [Das erste Konto](#1-das-erste-konto) +2. [Der Überblick](#2-der-überblick) +3. [Stammdaten anlegen](#3-stammdaten-anlegen) +4. [Sets in den Katalog](#4-sets-in-den-katalog) +5. [Was fehlt noch? Mehrere Sets auf einmal auswählen](#5-was-fehlt-noch) +6. [Die Set-Detailseite](#6-die-set-detailseite) +7. [Die Sammlung](#7-die-sammlung) +8. [Wunschlisten](#8-wunschlisten) +9. [Teilen – so sehen es Oma und Tante Anne](#9-teilen) +10. [Passkeys einrichten](#10-passkeys-einrichten) +11. [Antworten auf die zwei wichtigen Fragen](#11-die-zwei-fragen) + +--- + +## 1. Das erste Konto + +![Registrierung](bilder/01-registrierung.png) + +Beim allerersten Aufruf landest du direkt auf der Registrierung. **Wer sich +zuerst registriert, wird Administrator** – danach ist die Registrierung +geschlossen. Melde dich also gleich selbst an, bevor es jemand anderes tut. + +Das Passwort braucht mindestens zehn Zeichen. Einen Passkey hinterlegst du +gleich danach unter „Konto" (siehe [Abschnitt 10](#10-passkeys-einrichten)). + +--- + +## 2. Der Überblick + +![Startseite](bilder/02-start.png) + +Die Startseite ist die Abzweigung zu den vier Bereichen: + +| | | +|---|---| +| **Katalog** | Alle bekannten Sets – egal ob vorhanden oder nicht | +| **Fehlende Sets** | Der direkte Weg zu allem, was noch fehlt | +| **Sammlung** | Was tatsächlich da ist | +| **Wunschlisten** | Zum Teilen mit Familie und Freunden | + +Unten am Bildschirmrand liegt die Navigation – auf dem Handy mit dem Daumen +erreichbar. + +--- + +## 3. Stammdaten anlegen + +![Hersteller verwalten](bilder/03-verwaltung-hersteller.png) + +Bevor du Sets anlegst, brauchst du mindestens einen **Hersteller**. Zu finden +über das Konto-Menü oben rechts → **Verwaltung**. + +Drei Arten von Stammdaten: + +- **Hersteller** – Lego, Playmobil, Cobi, BlueBrixx … beliebig erweiterbar. + Die hinterlegte Website erscheint später als „Zum Shop"-Link auf der + geteilten Wunschliste. +- **Themen** – Serien wie „City", „Technic", „Novelmore". Themen gehören zu + einem Hersteller und können Unterthemen haben („City → Feuerwehr"). +- **Standorte** – „Regal Kinderzimmer", „Kiste Keller 3". Auch schachtelbar, + damit „Keller → Kiste 3" möglich ist. + +> Ein Hersteller oder Thema lässt sich nicht löschen, solange noch Sets daran +> hängen. Standorte schon – die betroffenen Sammlungseinträge verlieren dann +> nur ihren Standort. + +--- + +## 4. Sets in den Katalog + +![Katalog](bilder/04-katalog.png) + +Der Katalog ist die Liste **aller Sets, die du kennst** – unabhängig davon, ob +ihr sie besitzt. Das ist der wichtigste Gedanke der App: + +> Ein Set im Katalog heißt „das gibt es". Erst ein Eintrag in der Sammlung +> heißt „das haben wir". + +Deshalb kannst du unbesorgt Sets anlegen, die ihr euch nur anschaut – genau +die braucht die Wunschliste später. + +**Suchen** kannst du nach Setnummer, Name oder EAN. Die vier Filter darunter: +Besitzstatus, Hersteller, Thema und Jahr. Jeder Filterstand steckt in der +URL – ein Lesezeichen auf „alle fehlenden Lego-Technic-Sets" ist damit möglich. + +Die Kacheln zeigen auf einen Blick: + +- **grüner Haken „Vorhanden"** – ist in der Sammlung (bei mehreren Exemplaren + „2× vorhanden") +- **„Auf Wunschliste"** – steht auf mindestens einer Liste +- Setnummer, Hersteller, Thema, Jahr und UVP + +Mit **„Set anlegen"** oben rechts kommt ein neues Set dazu. Pflicht sind nur +Hersteller, Setnummer und Name; alles andere kannst du nachtragen. Ein Bild +lädst du entweder hoch (es wird verkleinert und lokal gespeichert) oder du +hinterlegst eine Bild-URL. + +--- + +## 5. Was fehlt noch? + +![Fehlende Sets](bilder/05-fehlende-sets.png) + +**Katalog → Filter „Fehlt noch"** (oder direkt `/katalog?besitz=fehlt`) zeigt +ausschließlich Sets, die **nicht** in der Sammlung sind. Das ist der zentrale +Weg zur Wunschliste. + +### Mehrere Sets auf einmal + +![Mehrfachauswahl](bilder/06-mehrfachauswahl.png) + +Eine Kachel **lange drücken** startet den Auswahlmodus – am Handy die +natürliche Geste, das Gerät vibriert kurz. Am Rechner tut es der Knopf +**„Auswählen"**. Danach wählt ein Tippen jede weitere Kachel an oder ab. + +Unten erscheint die Aktionsleiste mit der Anzahl und dem Knopf **„Zur +Wunschliste hinzufügen"**. + +![Zieldialog](bilder/07-wunschliste-dialog.png) + +Im Dialog wählst du die Ziel-Wunschliste oder legst gleich eine neue an, dazu +eine Priorität für alle ausgewählten Sets: + +| Priorität | Bedeutung | +|---|---| +| Großer Wunsch | Das steht ganz oben | +| Gerne | Der Normalfall | +| Irgendwann | Nice to have | + +Die Priorität lässt sich später je Eintrag ändern. + +**Doppelte werden übersprungen.** Sets, die schon auf der Liste stehen oder +bereits in der Sammlung sind, werden nicht noch einmal hinzugefügt – die +Meldung danach sagt dir genau, welche das waren und warum. Das ist keine +UI-Höflichkeit, sondern in der Datenbank abgesichert. + +--- + +## 6. Die Set-Detailseite + +![Set-Detailseite](bilder/08-set-detail.png) + +Hier steht alles zu einem Set: + +- **Zur Sammlung hinzufügen** – ein Tipp, und das Set gilt als vorhanden. + Details wie Standort, Zustand oder Kaufpreis trägst du danach nach. +- **Auf Wunschliste** – derselbe Zieldialog wie oben, nur für dieses eine Set. + Ist das Set bereits in der Sammlung, ist der Knopf ausgegraut: Was da ist, + gehört nicht auf einen Wunschzettel. +- **Anleitungen** – PDFs hochladen (mehrere je Set, etwa „Buch 1 von 3") und + direkt im Browser durchblättern, ohne Download und ohne fremde Dienste. + Alternativ verlinkst du die Anleitung beim Hersteller. +- **Fotos** – eigene Bilder, am Handy direkt aus der Kamera. +- **In der Sammlung** – jedes Exemplar mit Status, Vollständigkeit, Zustand, + Standort, Kaufdatum, Preis und „geschenkt von". + +### Die fünf Status eines Exemplars + +| Status | Bedeutung | +|---|---| +| Vorhanden | Da, aber weder auf- noch abgebaut vermerkt | +| Aufgebaut | Steht zusammengebaut im Regal | +| Zerlegt | In Einzelteilen in der Kiste | +| Verliehen | Gerade bei jemand anderem | +| **Verkauft** | Zählt **nicht** mehr als Besitz | + +Der letzte Punkt ist wichtig: Ein verkauftes Set taucht wieder unter „Fehlt +noch" auf und darf zurück auf eine Wunschliste. + +--- + +## 7. Die Sammlung + +![Sammlung](bilder/09-sammlung.png) + +Oben vier Kacheln: Anzahl Sets, Teile insgesamt, Wert nach UVP und wie viele +Sets im Katalog noch fehlen. Darunter die Aufteilung nach Hersteller. + +Gefiltert wird nach Status, Vollständigkeit, Standort und Hersteller – nützlich +für die Frage „Was steht eigentlich in Kiste 3?". + +Verkaufte Exemplare zählen in keiner Kachel mit. + +--- + +## 8. Wunschlisten + +![Wunschlisten](bilder/10-wunschlisten.png) + +Jede Liste hat einen Titel, einen Anlass und optional einen Termin. Die Karte +zeigt, wie viele Wünsche drauf sind und wie viele davon noch niemand +übernommen hat. + +![Wunschliste bearbeiten](bilder/11-wunschliste-bearbeiten.png) + +Auf der Liste selbst kannst du: + +- die **Reihenfolge** mit den Pfeilen ändern – so stehen die wichtigsten + Wünsche oben, +- die **Priorität** je Eintrag anpassen, +- Einträge entfernen, +- unten im Bereich **„Aus fehlenden Sets hinzufügen"** direkt weitere Sets + dazunehmen. Dort steckt dieselbe Auswahlansicht wie im Katalog, schon + vorgefiltert auf Sets, die weder in der Sammlung noch auf dieser Liste sind. + +### Zwei Einstellungen, die den Unterschied machen + +- **Über Link teilbar** – ohne diesen Haken ist der öffentliche Link tot. + Praktisch, wenn du eine Liste erst fertig bauen willst. +- **Überraschungsmodus** – dann siehst *du* nur „Reserviert", nicht von wem. + Die Schenkenden untereinander sehen die Namen weiterhin, damit niemand + doppelt kauft. + +--- + +## 9. Teilen + +Auf der Wunschliste steht oben der **Link zum Teilen**. Kopieren, oder am +Handy über den Teilen-Knopf direkt in WhatsApp, Signal oder eine Mail geben. + +> Der Link ist der einzige Schlüssel. Wer ihn hat, sieht die Liste – mehr +> nicht: keine Sammlung, keine anderen Listen, keine Namen von Konten. Für +> Suchmaschinen ist die Seite gesperrt. +> +> Ist ein Link in falsche Hände geraten, erzeugst du mit **„Neuen Link +> erzeugen"** einen frischen. Der alte funktioniert sofort nicht mehr. + +### So sieht es für die Beschenkenden aus + +![Öffentliche Wunschliste](bilder/12-oeffentliche-wunschliste.png) + +Kein Konto, keine Anmeldung, keine App. Nur die Liste, mit Bild, Setnummer, +Preis, Priorität und – falls hinterlegt – einem Link zum Shop des Herstellers. + +![Reservieren](bilder/13-reservieren.png) + +Ein Tipp auf **„Das schenke ich"**, Name eintragen, fertig. + +![Reserviert](bilder/14-reserviert.png) + +Danach sehen alle anderen, dass dieser Wunsch vergeben ist. Die Person selbst +sieht „Das schenkst du" und kann die Reservierung jederzeit wieder aufheben – +auf demselben Gerät mit einem Tipp, von einem anderen Gerät über den +Storno-Link, den sie beim Reservieren bekommt. + +![Reservierung in der Übersicht](bilder/15-wunschliste-reserviert.png) + +Auf deiner Seite siehst du, wer was übernimmt – oder im Überraschungsmodus +eben nur, *dass* etwas übernommen ist. + +**Was passiert, wenn das Geschenk ankommt?** Sobald du das Set zur Sammlung +hinzufügst, markiert Bricktrack den Wunsch automatisch als **erfüllt** und +blendet ihn aus der öffentlichen Ansicht aus. Niemand kauft es dann noch ein +zweites Mal. + +--- + +## 10. Passkeys einrichten + +![Konto und Passkeys](bilder/16-konto-passkeys.png) + +Unter **Konto → Passkeys → Passkey hinzufügen** hinterlegst du Fingerabdruck, +Gesichtserkennung oder die Geräte-PIN als Anmeldung. Danach reicht auf der +Anmeldeseite ein Tipp auf „Mit Passkey anmelden" – auf vielen Geräten schlägt +der Browser den Passkey sogar von selbst vor. + +Ein Passkey gilt pro Gerät. Leg ruhig mehrere an – Handy, Tablet, Laptop – und +gib ihnen sprechende Namen. + +> **Zwei Dinge, die man wissen muss:** +> 1. Passkeys brauchen HTTPS. Über eine reine `http://`-Adresse (außer +> `localhost`) bietet der Browser sie gar nicht erst an. +> 2. Passkeys sind an die Domain gebunden. Ziehst du von +> `bricktrack.example.de` auf eine andere Adresse um, musst du alle +> Passkeys neu anlegen. Das Passwort funktioniert weiterhin – deshalb ist +> es sinnvoll, es nicht zu vergessen. + +--- + +## 11. Die zwei Fragen + +Dafür ist die ganze App gebaut: + +**„Welche Sets hat er?"** +→ Sammlung. Oder im Katalog der Filter „In der Sammlung". + +**„Welche Sets hat er noch nicht?"** +→ Katalog, Filter „Fehlt noch". Von dort in zwei Handgriffen auf eine +Wunschliste und per Link zu Oma. + +Im Laden vor dem Regal: Katalog öffnen, Setnummer von der Packung eintippen. +Steht ein grüner Haken dran, habt ihr es schon. + +--- + +## Kleinigkeiten, die den Alltag leichter machen + +- **Filter stecken in der URL.** Ein Lesezeichen auf + `/katalog?besitz=fehlt&hersteller=…` ist eine gespeicherte Suche. +- **Preise** kannst du als `12,99` oder `12.99` eintippen – beides wird + verstanden, gespeichert wird auf den Cent genau. +- **Dasselbe Set zweimal** ist kein Problem: Zwei Exemplare sind zwei Einträge + in der Sammlung, mit eigenem Standort und Zustand. +- **Anleitungs-PDFs** können groß sein. Kommt beim Hochladen ein Fehler + „413", muss im Reverse Proxy `client_max_body_size` hoch – siehe + [QUICKSTART, Schritt 5](../QUICKSTART.md#5-reverse-proxy-einrichten-nginx-proxy-manager). +- **Alles Wichtige liegt in `./data`.** Dieses eine Verzeichnis sichern + genügt – Datenbank, Bilder, Fotos und Anleitungen sind darin. diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index f296602..2769344 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -1,32 +1,98 @@ # Devlog Zeiterfassung und Nachvollziehbarkeit der Entwicklung. Eine Zeile je -abgeschlossenem Arbeitsschritt, Zeiten in `HH:mm` (Europe/Berlin). +abgeschlossenem Arbeitsschritt, Zeiten in `HH:mm` (Europe/Berlin). Die +Commit-Spalte verweist auf den Stand am Ende des Schritts. -## Phase 1 - MVP +## Phase 1 – MVP (v0.1.0) -### 2026-09-06 +### 06.09.2026 | Von | Bis | Dauer | Schritt | Ergebnis | Commit | |---|---|---|---|---|---| -| 22:06 | 22:12 | 0:06 | Planung | Phase-1-Plan und Drizzle-Schema zur Freigabe vorgelegt; 5 Entscheidungsfragen geklaert (Cent statt Float, Migrator statt drizzle-kit im Image, Bilder oeffentlich / PDFs geschuetzt, Token gehasht, Branch pro Phase) | - | -| 22:12 | 22:20 | 0:08 | Scaffold | pnpm via npm global installiert (corepack scheiterte an Rechten in `C:\Program Files\nodejs`), Next 15.5.25 + Tailwind 4 + shadcn/ui (radix-nova) mit 20 Komponenten | `0de49a9` | -| 22:20 | 22:26 | 0:06 | Datenmodell | 19 Tabellen in Drizzle, CHECK-Constraints fuer `instruction`, `photo` und `priority`, Unique-Index `(wishlist_id, set_id)`, Migration erzeugt und angewendet, WAL verifiziert | `06268bc` | -| 22:26 | 22:34 | 0:08 | Dokumentation | CLAUDE.md (Stack, Konventionen, Datenmodell, Sicherheitsregeln), Roadmap, Changelog, Devlog | `folgt` | +| 22:06 | 22:12 | 0:06 | Planung | Phase-1-Plan und Drizzle-Schema zur Freigabe vorgelegt; fünf Entscheidungsfragen geklärt (Cent statt Float, Migrator statt drizzle-kit im Image, Bilder öffentlich / PDFs geschützt, Token gehasht, Branch pro Phase) | – | +| 22:12 | 22:20 | 0:08 | Scaffold | pnpm installiert, Next 15.5.25 + Tailwind 4 + shadcn/ui (radix-nova) mit 20 Komponenten, Touch-Größen auf 44 px angehoben | `0de49a9` | +| 22:20 | 22:25 | 0:05 | Datenmodell | 19 Tabellen in Drizzle, CHECK-Constraints, Unique-Index `(wishlist_id, set_id)`, Migration erzeugt und angewendet, WAL verifiziert | `06268bc` | +| 22:25 | 22:26 | 0:01 | Dokumentation | CLAUDE.md, Roadmap, Changelog, Devlog | `b74cecb` | +| 22:26 | 22:40 | 0:14 | Auth und App-Shell | better-auth mit Passwort und Passkeys, erster Nutzer wird Admin, Rate-Limit in der Datenbank, Konto-Seite, Bottom-Navigation, `/api/health` | `4c78c56` | +| 22:40 | 22:46 | 0:06 | Stammdaten | CRUD für Hersteller, Themen und Standorte über eine gemeinsame CrudSection; zod-Schemata, ActionResult, Slug, Geld- und Datumshelfer | `af0ae03` | +| 22:46 | 22:59 | 0:13 | Katalog | Kachelliste mit Suche und vier Filtern, Besitzlogik in `ownership.ts`, Set-Formular, Detailseite, Upload-Pipeline mit Magic-Byte-Prüfung, pdf.js-Viewer, Seed-Skript | `856800b` | +| 22:59 | 23:06 | 0:07 | Wunschlisten und Sammlung | Mehrfachauswahl mit Dubletten-Skip, Wunschlisten-Verwaltung, Teilen, Überraschungsmodus, öffentliche Ansicht mit Reservierung und Storno, Sammlungsübersicht mit Statistik | `79b0133` | +| 23:06 | 23:31 | 0:25 | Tests | 50 Vitest-Unit-Tests, Playwright-Smoke-Test über den ganzen Weg bis zur Reservierung durch einen Besucher ohne Konto | `b0152b8` | -**Summe Phase 1 bisher: 0:28** +### 07.09.2026 + +| Von | Bis | Dauer | Schritt | Ergebnis | Commit | +|---|---|---|---|---|---| +| 23:31 | 00:09 | 0:38 | Docker | Multi-Stage-Build, Entrypoint mit Rechteprüfung und Migration, Compose-Stack, Healthcheck, `update.sh` mit Rollback; Image gebaut und Container verifiziert | `61b1249` | +| 00:09 | 00:50 | 0:41 | Dokumentation und Screenshots | README, QUICKSTART mit Nginx-Proxy-Manager-Konfiguration, bebilderte Anleitung, 16 automatisiert erzeugte Screenshots, Changelog 0.1.0 | folgt | + +**Summe Phase 1: 2:44** + +--- ## Notizen und Entscheidungen +### Umgebung + - **pnpm**: `corepack enable` scheitert auf diesem Rechner an fehlenden Schreibrechten in `C:\Program Files\nodejs`. Stattdessen - `npm install -g pnpm@10`; pnpm liegt unter - `%APPDATA%\npm\pnpm.ps1` und ist im PATH. -- **shadcn-CLI**: Die aktuelle Version (4.21) arbeitet mit Presets - (`-p nova`) und Base-Libraries (`-b radix`) statt der frueheren - Style-Auswahl. Sie zieht `cn` und `radix-ui` als Pakete; `clsx` und - `tailwind-merge` werden dadurch nicht mehr direkt gebraucht. + `npm install -g pnpm@10`; pnpm liegt unter `%APPDATA%\npm` und ist im PATH. +- **Port 3000** ist auf dem Entwicklungsrechner von einer anderen Anwendung + belegt. Der Dev-Server läuft deshalb lokal auf 3100, die Testläufe auf + 3210 und 3220. +- **`pnpm build` unter Windows** scheitert mit `EPERM … symlink`: Der + Standalone-Output legt Symlinks in `.next/standalone` an, wofür Windows den + Entwicklermodus oder administrative Rechte braucht. Der Docker-Build unter + Linux ist davon nicht betroffen; lokal prüft man mit + `docker build -t bricktrack:test .`. + +### Bibliotheken + +- **shadcn-CLI 4.21** arbeitet mit Presets (`-p nova`) und Base-Libraries + (`-b radix`) statt der früheren Style-Auswahl. Sie zieht `cn` und `radix-ui` + als Pakete; `clsx` und `tailwind-merge` werden dadurch nicht mehr direkt + gebraucht und wurden wieder entfernt. Die Voreinstellungen sind auf + Desktop-Dichte ausgelegt (Buttons `h-8`) – für die Bedienung am Handy auf + 44 px angehoben. - **better-auth 1.7.3**: Das Passkey-Plugin liegt nicht mehr unter `better-auth/plugins/passkey`, sondern im eigenen Paket - `@better-auth/passkey` (Exports `.` und `./client`). Das erwartete - Schema deckt sich exakt mit der Tabelle `passkey`. + `@better-auth/passkey`. Der Drizzle-Adapter löst Modelle über die + TypeScript-Property-Namen auf, nicht über die Spaltennamen – deshalb können + auch die Auth-Tabellen durchgehend `snake_case` verwenden. +- **Drizzle**: Selbstbezügliche Fremdschlüssel (`parent_theme_id`) brauchen + `AnySQLiteColumn` als Rückgabetyp, nicht `AnyColumn`. +- **pdf.js** wird lokal gebündelt, inklusive Worker. Kein CDN, damit der + Browser des Nutzers keine fremden Server kontaktiert. + +### Stolpersteine + +- **CRLF im Entrypoint**: Eine unter Windows geschriebene `entrypoint.sh` mit + CRLF scheitert im Container mit dem irreführenden + `exec /app/entrypoint.sh: no such file or directory` – der Kernel sucht + dann `/bin/sh\r`. Behoben mit einer `.gitattributes`, die für Shell-Skripte + LF erzwingt. +- **Migrations-Bundle**: Der mit esbuild als ESM gebündelte Migrations-Runner + scheiterte an `Dynamic require of "fs" is not supported` – mitgebündelte + CJS-Abhängigkeiten (dotenv) rufen `require()` für Node-Builtins auf. Als + CommonJS gebündelt läuft es. +- **Playwright und Reihenfolge**: Playwright startet den Web-Server **vor** + dem `globalSetup`. Das Löschen des Testdatenverzeichnisses im globalSetup + scheiterte deshalb unter Windows an der bereits geöffneten SQLite-Datei; + das Aufräumen läuft jetzt im npm-Skript davor. +- **Screenshots gegen den Dev-Server** waren nicht reproduzierbar: unter Last + brach der Stream mit `ChunkLoadError` beziehungsweise + `Unexpected end of JSON input` ab. Die Bilder entstehen jetzt gegen das + Produktions-Image im Container – verlässlich und zugleich näher an dem, was + Nutzer wirklich sehen. + +### Beim Testen gefunden und behoben + +- Eine spontan aus dem Katalog angelegte Wunschliste war **nicht teilbar**: + Das Feld `isPublic` kommt aus einer Checkbox und fiel beim programmatischen + Anlegen auf `false`. Die Schnellanlage setzt es jetzt ausdrücklich. +- Das Sternchen an Pflichtfeldern gehörte zum zugänglichen Namen des Feldes + („Name *"). Jetzt `aria-hidden` – der Pflichtstatus steckt ohnehin im + `required` des Eingabefeldes. +- In der Mehrfachauswahl verdeckte die Aktionsleiste die letzte Kachelreihe; + das Raster bekommt im Auswahlmodus zusätzliches Polster nach unten. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 3ca7b74..af1460c 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -2,36 +2,36 @@ Legende: `[ ]` offen · `[~]` in Arbeit · `[x]` fertig -## Phase 1 - MVP (Ziel: v0.1.0) +## Phase 1 - MVP (v0.1.0, abgeschlossen am 07.09.2026) -Alles laeuft vollstaendig im Docker-Stack. +Laeuft vollstaendig im Docker-Stack. - [x] Projekt-Scaffold (Next 15, Tailwind 4, shadcn/ui, pnpm) - [x] Datenmodell in Drizzle, initiale Migration - [x] Projektdokumentation (CLAUDE.md, Roadmap, Changelog, Devlog) -- [ ] Auth: Registrierung des ersten Users (wird Admin), Login mit +- [x] Auth: Registrierung des ersten Users (wird Admin), Login mit Passwort, Passkey anlegen und damit einloggen (Conditional UI), Passkey-Verwaltung unter "Konto", Rate-Limit auf Login -- [ ] Mobile-first Layout-Shell mit Bottom-Navigation -- [ ] Stammdaten: Hersteller, Themen (mit Unterthemen), Standorte -- [ ] Set-Katalog: Kachelliste, Suche, Filter (Hersteller, Thema, Jahr, +- [x] Mobile-first Layout-Shell mit Bottom-Navigation +- [x] Stammdaten: Hersteller, Themen (mit Unterthemen), Standorte +- [x] Set-Katalog: Kachelliste, Suche, Filter (Hersteller, Thema, Jahr, Besitzstatus), Anlegen und Bearbeiten -- [ ] Upload-Pipeline (PDF/JPEG/PNG/WebP, Magic-Byte-Pruefung, +- [x] Upload-Pipeline (PDF/JPEG/PNG/WebP, Magic-Byte-Pruefung, serverseitige Dateinamen, Thumbnails) -- [ ] Set-Detailseite: Metadaten, Bild, Anleitungen mit pdf.js-Viewer, +- [x] Set-Detailseite: Metadaten, Bild, Anleitungen mit pdf.js-Viewer, Fotos, Sammlungseintraege -- [ ] Sammlung: Uebersicht, Filter, Statistik-Kacheln -- [ ] Fehlende Sets (`/katalog?besitz=fehlt`) mit Mehrfachauswahl und +- [x] Sammlung: Uebersicht, Filter, Statistik-Kacheln +- [x] Fehlende Sets (`/katalog?besitz=fehlt`) mit Mehrfachauswahl und Bulk-Aktion "Zur Wunschliste hinzufuegen" -- [ ] Wunschlisten: Verwaltung, Prioritaet, Sortierung, Teilen, +- [x] Wunschlisten: Verwaltung, Prioritaet, Sortierung, Teilen, Ueberraschungsmodus, automatisches "erfuellt" -- [ ] Oeffentliche Wunschliste `/w/[shareToken]` mit Reservierung ohne +- [x] Oeffentliche Wunschliste `/w/[shareToken]` mit Reservierung ohne Anmeldung und Storno-Link -- [ ] Seed-Skript (3 Hersteller, 10 Sets, 4 in Sammlung, 1 Wunschliste) -- [ ] Unit-Tests (Besitzstatus, Bulk-Add inkl. Dubletten, Reservierung, +- [x] Seed-Skript (3 Hersteller, 10 Sets, 4 in Sammlung, 1 Wunschliste) +- [x] Unit-Tests (Besitzstatus, Bulk-Add inkl. Dubletten, Reservierung, Token-Erzeugung) und Playwright-Smoke-Test -- [ ] Docker: Multi-Stage-Build, Compose-Stack, Healthcheck, Entrypoint -- [ ] Dokumentation: README, QUICKSTART, bebilderte Anleitung, +- [x] Docker: Multi-Stage-Build, Compose-Stack, Healthcheck, Entrypoint +- [x] Dokumentation: README, QUICKSTART, bebilderte Anleitung, update.sh, Nginx-Proxy-Manager-Konfiguration ## Phase 2 - Komfort (Ziel: v0.2.0) diff --git a/docs/bilder/01-registrierung.png b/docs/bilder/01-registrierung.png new file mode 100644 index 0000000..38e44e1 Binary files /dev/null and b/docs/bilder/01-registrierung.png differ diff --git a/docs/bilder/02-start.png b/docs/bilder/02-start.png new file mode 100644 index 0000000..7a0be01 Binary files /dev/null and b/docs/bilder/02-start.png differ diff --git a/docs/bilder/03-verwaltung-hersteller.png b/docs/bilder/03-verwaltung-hersteller.png new file mode 100644 index 0000000..d64c892 Binary files /dev/null and b/docs/bilder/03-verwaltung-hersteller.png differ diff --git a/docs/bilder/04-katalog.png b/docs/bilder/04-katalog.png new file mode 100644 index 0000000..f9a3021 Binary files /dev/null and b/docs/bilder/04-katalog.png differ diff --git a/docs/bilder/05-fehlende-sets.png b/docs/bilder/05-fehlende-sets.png new file mode 100644 index 0000000..8c1a8e1 Binary files /dev/null and b/docs/bilder/05-fehlende-sets.png differ diff --git a/docs/bilder/06-mehrfachauswahl.png b/docs/bilder/06-mehrfachauswahl.png new file mode 100644 index 0000000..fa3787b Binary files /dev/null and b/docs/bilder/06-mehrfachauswahl.png differ diff --git a/docs/bilder/07-wunschliste-dialog.png b/docs/bilder/07-wunschliste-dialog.png new file mode 100644 index 0000000..518254e Binary files /dev/null and b/docs/bilder/07-wunschliste-dialog.png differ diff --git a/docs/bilder/08-set-detail.png b/docs/bilder/08-set-detail.png new file mode 100644 index 0000000..d33cc18 Binary files /dev/null and b/docs/bilder/08-set-detail.png differ diff --git a/docs/bilder/09-sammlung.png b/docs/bilder/09-sammlung.png new file mode 100644 index 0000000..57ff976 Binary files /dev/null and b/docs/bilder/09-sammlung.png differ diff --git a/docs/bilder/10-wunschlisten.png b/docs/bilder/10-wunschlisten.png new file mode 100644 index 0000000..b0b69fe Binary files /dev/null and b/docs/bilder/10-wunschlisten.png differ diff --git a/docs/bilder/11-wunschliste-bearbeiten.png b/docs/bilder/11-wunschliste-bearbeiten.png new file mode 100644 index 0000000..94816f6 Binary files /dev/null and b/docs/bilder/11-wunschliste-bearbeiten.png differ diff --git a/docs/bilder/12-oeffentliche-wunschliste.png b/docs/bilder/12-oeffentliche-wunschliste.png new file mode 100644 index 0000000..8a61b0a Binary files /dev/null and b/docs/bilder/12-oeffentliche-wunschliste.png differ diff --git a/docs/bilder/13-reservieren.png b/docs/bilder/13-reservieren.png new file mode 100644 index 0000000..5b08544 Binary files /dev/null and b/docs/bilder/13-reservieren.png differ diff --git a/docs/bilder/14-reserviert.png b/docs/bilder/14-reserviert.png new file mode 100644 index 0000000..5988793 Binary files /dev/null and b/docs/bilder/14-reserviert.png differ diff --git a/docs/bilder/15-wunschliste-reserviert.png b/docs/bilder/15-wunschliste-reserviert.png new file mode 100644 index 0000000..a4c315e Binary files /dev/null and b/docs/bilder/15-wunschliste-reserviert.png differ diff --git a/docs/bilder/16-konto-passkeys.png b/docs/bilder/16-konto-passkeys.png new file mode 100644 index 0000000..18ae769 Binary files /dev/null and b/docs/bilder/16-konto-passkeys.png differ 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/e2e/global-setup.ts b/e2e/global-setup.ts new file mode 100644 index 0000000..e0bb8a4 --- /dev/null +++ b/e2e/global-setup.ts @@ -0,0 +1,13 @@ +import { execFileSync } from 'node:child_process'; + +/** + * Migrates the smoke-test database. The directory itself is wiped earlier by + * scripts/reset-e2e.mjs, because Playwright starts its web server before this + * hook runs and the open database file would block the delete on Windows. + */ +export default function globalSetup() { + execFileSync('node', ['node_modules/tsx/dist/cli.mjs', 'scripts/migrate.ts'], { + stdio: 'inherit', + env: { ...process.env, DATA_DIR: './data-e2e' }, + }); +} diff --git a/e2e/screenshots.spec.ts b/e2e/screenshots.spec.ts new file mode 100644 index 0000000..8289d7e --- /dev/null +++ b/e2e/screenshots.spec.ts @@ -0,0 +1,130 @@ +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { expect, test, type Page } from '@playwright/test'; + +/** + * Captures the screenshots used by docs/ANLEITUNG.md. + * + * Reproducible on purpose: whenever the UI changes, `pnpm screenshots` + * refreshes every picture in the manual from the same seeded data. + */ +const SHOT_DIR = path.resolve(process.cwd(), 'docs/bilder'); + +function shotPath(name: string) { + fs.mkdirSync(SHOT_DIR, { recursive: true }); + return path.join(SHOT_DIR, `${name}.png`); +} + +async function shoot(page: Page, name: string) { + // Let fonts and images settle so the picture is not half-rendered. + await page.waitForLoadState('networkidle'); + await page.screenshot({ path: shotPath(name), fullPage: true }); +} + +/** Small wrapper so every navigation waits the same way. */ +async function visit(page: Page, url: string) { + await page.goto(url, { waitUntil: 'domcontentloaded' }); +} + +test('Screenshots fuer die Anleitung', async ({ page, browser }) => { + // --- 1. registration ------------------------------------------------------ + await visit(page, '/registrieren'); + await expect(page.getByText('Erstes Konto anlegen')).toBeVisible(); + await shoot(page, '01-registrierung'); + + await page.getByLabel('Name').fill('Florian'); + await page.getByLabel('E-Mail').fill('papa@bricktrack.local'); + await page.getByLabel('Passwort').fill('anleitung-passwort'); + await page.getByRole('button', { name: 'Konto anlegen' }).click(); + await expect(page.getByRole('heading', { name: 'Hallo Florian' })).toBeVisible(); + + // Example data needs the user to exist first (the wishlist has an owner). + execFileSync('node', ['node_modules/tsx/dist/cli.mjs', 'scripts/seed.ts'], { + stdio: 'inherit', + env: { ...process.env, DATA_DIR: './data-shots' }, + }); + + // --- 2. dashboard --------------------------------------------------------- + await visit(page, '/'); + await shoot(page, '02-start'); + + // --- 3. master data ------------------------------------------------------- + await visit(page, '/verwaltung/hersteller'); + await expect(page.getByText('Lego', { exact: true })).toBeVisible(); + await shoot(page, '03-verwaltung-hersteller'); + + // --- 4. catalog ----------------------------------------------------------- + await visit(page, '/katalog'); + await expect(page.getByText('Familienhaus und Elektroauto').first()).toBeVisible(); + await shoot(page, '04-katalog'); + + // --- 5. missing sets, selection mode -------------------------------------- + await visit(page, '/katalog?besitz=fehlt'); + await expect(page.getByRole('heading', { name: 'Fehlende Sets' })).toBeVisible(); + await shoot(page, '05-fehlende-sets'); + + await page.getByRole('button', { name: 'Auswählen' }).click(); + await expect(page.getByText('Tippe die Sets an')).toBeVisible(); + const cards = page.locator('button[aria-pressed]'); + await cards.nth(0).click(); + await cards.nth(1).click(); + await expect(page.getByText('2 ausgewählt')).toBeVisible(); + await shoot(page, '06-mehrfachauswahl'); + + await page.getByRole('button', { name: 'Zur Wunschliste hinzufügen' }).click(); + await expect(page.getByText('Sets auf die Wunschliste')).toBeVisible(); + await page.screenshot({ path: shotPath('07-wunschliste-dialog') }); + await page.getByRole('button', { name: 'Abbrechen' }).click(); + + // --- 6. set detail -------------------------------------------------------- + await visit(page, '/katalog?q=60398'); + await page.getByText('Familienhaus und Elektroauto').first().click(); + await expect(page.getByRole('heading', { name: 'Familienhaus und Elektroauto' })).toBeVisible(); + await shoot(page, '08-set-detail'); + + // --- 7. collection -------------------------------------------------------- + await visit(page, '/sammlung'); + await expect(page.getByText('Wert nach UVP').first()).toBeVisible(); + await shoot(page, '09-sammlung'); + + // --- 8. wishlist ---------------------------------------------------------- + await visit(page, '/wunschlisten'); + await expect(page.getByText('Weihnachten 2026').first()).toBeVisible(); + await shoot(page, '10-wunschlisten'); + + const listHref = await page + .getByRole('link', { name: /Weihnachten 2026/ }) + .getAttribute('href'); + await visit(page, listHref!); + await expect(page.getByRole('heading', { name: 'Weihnachten 2026' })).toBeVisible(); + await shoot(page, '11-wunschliste-bearbeiten'); + + const shareUrl = await page.getByLabel('Link zum Teilen').inputValue(); + + // --- 9. public view, as a visitor without an account ---------------------- + const visitorContext = await browser.newContext({ locale: 'de-DE' }); + const visitor = await visitorContext.newPage(); + await visitor.setViewportSize({ width: 412, height: 915 }); + await visit(visitor, shareUrl); + await expect(visitor.getByRole('heading', { name: 'Weihnachten 2026' })).toBeVisible(); + await shoot(visitor, '12-oeffentliche-wunschliste'); + + await visitor.getByRole('button', { name: 'Das schenke ich' }).first().click(); + await visitor.getByLabel('Dein Name').fill('Tante Anne'); + await visitor.screenshot({ path: shotPath('13-reservieren') }); + await visitor.getByRole('button', { name: 'Reservieren' }).click(); + await expect(visitor.getByText('Reservierung gespeichert')).toBeVisible(); + await visitor.getByRole('button', { name: 'Alles klar' }).click(); + await shoot(visitor, '14-reserviert'); + await visitorContext.close(); + + // --- 10. the owner sees the reservation ----------------------------------- + await page.reload(); + await shoot(page, '15-wunschliste-reserviert'); + + // --- 11. account and passkeys -------------------------------------------- + await visit(page, '/konto'); + await expect(page.getByText('Passkeys', { exact: true })).toBeVisible(); + await shoot(page, '16-konto-passkeys'); +}); diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts new file mode 100644 index 0000000..9a4047f --- /dev/null +++ b/e2e/smoke.spec.ts @@ -0,0 +1,92 @@ +import { expect, test } from '@playwright/test'; + +/** + * The one path that has to work: register, put a set into the catalog, and + * share it on a wishlist that a visitor can reserve from without an account. + */ +test('Registrieren, Set anlegen, Wunschliste teilen und reservieren', async ({ page, browser }) => { + // --- first run: the very first visitor registers and becomes admin -------- + await page.goto('/'); + await expect(page).toHaveURL(/\/registrieren/); + // Card titles are plain elements, not headings - hence getByText here. + await expect(page.getByText('Erstes Konto anlegen')).toBeVisible(); + + await page.getByLabel('Name').fill('Florian'); + await page.getByLabel('E-Mail').fill('test@bricktrack.local'); + await page.getByLabel('Passwort').fill('smoketest-passwort'); + await page.getByRole('button', { name: 'Konto anlegen' }).click(); + + await expect(page.getByRole('heading', { name: 'Hallo Florian' })).toBeVisible(); + + // --- master data: a set needs a manufacturer ----------------------------- + await page.goto('/verwaltung/hersteller'); + await page.getByRole('button', { name: 'Hersteller anlegen' }).click(); + await page.getByLabel('Name').fill('Lego'); + await page.getByRole('button', { name: 'Speichern' }).click(); + await expect(page.getByText('Lego')).toBeVisible(); + + // --- catalog: create a set ------------------------------------------------ + await page.goto('/katalog/neu'); + await page.getByLabel('Setnummer').fill('60398'); + await page.getByRole('textbox', { name: 'Name', exact: true }).fill('Familienhaus und Elektroauto'); + await page.getByLabel('Jahr').fill('2023'); + await page.getByLabel('UVP').fill('49,99'); + await page.getByRole('button', { name: 'Set anlegen' }).click(); + + await expect(page.getByRole('heading', { name: 'Familienhaus und Elektroauto' })).toBeVisible(); + await expect(page.getByText('Fehlt noch')).toBeVisible(); + + // --- wishlist: put the set on a fresh list -------------------------------- + await page.getByRole('button', { name: 'Auf Wunschliste' }).click(); + await page.getByRole('combobox', { name: 'Wunschliste' }).selectOption('__neu__'); + await page.getByLabel('Titel der neuen Liste').fill('Weihnachten 2026'); + await page.getByRole('button', { name: 'Hinzufügen' }).click(); + + // Wait for the confirmation, not for the button label that is on screen anyway. + await expect(page.getByText('Auf die Wunschliste gesetzt.')).toBeVisible(); + + // --- sharing: grab the public link --------------------------------------- + await page.goto('/wunschlisten'); + // Navigate by href: a toast can still be on screen and would swallow the click. + const listHref = await page + .getByRole('link', { name: /Weihnachten 2026/ }) + .getAttribute('href'); + expect(listHref).toMatch(/^\/wunschlisten\//); + await page.goto(listHref!); + await expect(page.getByRole('heading', { name: 'Weihnachten 2026' })).toBeVisible(); + + const shareUrl = await page.getByLabel('Link zum Teilen').inputValue(); + expect(shareUrl).toContain('/w/'); + + // --- the visitor: no account, no session --------------------------------- + const visitorContext = await browser.newContext(); + const visitor = await visitorContext.newPage(); + await visitor.goto(shareUrl); + + await expect(visitor.getByRole('heading', { name: 'Weihnachten 2026' })).toBeVisible(); + await expect(visitor.getByText('Familienhaus und Elektroauto')).toBeVisible(); + + await visitor.getByRole('button', { name: 'Das schenke ich' }).click(); + await visitor.getByLabel('Dein Name').fill('Tante Anne'); + await visitor.getByRole('button', { name: 'Reservieren' }).click(); + + await expect(visitor.getByText('Reservierung gespeichert')).toBeVisible(); + await visitor.getByRole('button', { name: 'Alles klar' }).click(); + await expect(visitor.getByText('Das schenkst du')).toBeVisible(); + + // --- back on the owner side: the reservation shows up --------------------- + await page.reload(); + await expect(page.getByText('Reserviert von Tante Anne')).toBeVisible(); + + await visitorContext.close(); +}); + +test('Ohne Anmeldung fuehrt jeder geschuetzte Pfad zur Anmeldung', async ({ page }) => { + await page.goto('/sammlung'); + await expect(page).toHaveURL(/\/anmelden|\/registrieren/); +}); + +test('Ein unbekannter Share-Token liefert 404', async ({ page }) => { + const response = await page.goto('/w/dieser-token-existiert-nicht'); + expect(response?.status()).toBe(404); +}); diff --git a/package.json b/package.json index d24db96..49f9650 100644 --- a/package.json +++ b/package.json @@ -1,70 +1,73 @@ -{ - "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": "node scripts/reset-e2e.mjs && playwright test", + "screenshots": "node scripts/screenshots-prepare.mjs && 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", + "pdfjs-dist": "^6.3.289", + "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", + "esbuild": "^0.25.12", + "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/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..069409c --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,55 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Smoke test setup. + * + * Runs against its own data directory (./data-e2e) so a test never touches the + * real database. The global setup wipes and migrates it before each run. + */ +const PORT = 3210; +const BASE_URL = `http://localhost:${PORT}`; + +export default defineConfig({ + testDir: './e2e', + // The screenshot run has its own config, its own server and its own data + // directory - it must never join the smoke run. + testMatch: 'smoke.spec.ts', + globalSetup: './e2e/global-setup.ts', + fullyParallel: false, + workers: 1, + retries: 0, + // Generous: the dev server compiles each route on first visit. + timeout: 240_000, + // A first navigation to a route the dev server has not compiled yet can + // take well over ten seconds on a cold cache. + expect: { timeout: 30_000 }, + reporter: process.env.CI ? 'github' : 'list', + use: { + baseURL: BASE_URL, + trace: 'retain-on-failure', + locale: 'de-DE', + timezoneId: 'Europe/Berlin', + }, + projects: [ + { + name: 'mobile', + // The app is built for phones first, so the smoke test uses one. + use: { ...devices['Pixel 7'] }, + }, + ], + webServer: { + command: `pnpm exec next dev -p ${PORT}`, + url: `${BASE_URL}/api/health`, + reuseExistingServer: false, + timeout: 180_000, + stdout: 'pipe', + stderr: 'pipe', + env: { + NODE_ENV: 'development', + DATA_DIR: './data-e2e', + BASE_URL, + BETTER_AUTH_SECRET: 'playwright-smoke-test-secret-0123456789abcdef', + MAX_UPLOAD_MB: '25', + }, + }, +}); diff --git a/playwright.screenshots.config.ts b/playwright.screenshots.config.ts new file mode 100644 index 0000000..f921090 --- /dev/null +++ b/playwright.screenshots.config.ts @@ -0,0 +1,53 @@ +import path from 'node:path'; +import { defineConfig, devices } from '@playwright/test'; + +/** + * Produces the screenshots for docs/ANLEITUNG.md. + * + * Runs against the production container, not the dev server: the dev server + * compiles routes on demand, which made the capture run flaky, and the manual + * should show exactly what a user gets after `docker compose up`. + * + * pnpm screenshots + */ +const PORT = 3220; +const BASE_URL = `http://localhost:${PORT}`; + +// Docker wants forward slashes, also on Windows. +const dataDir = path.resolve(process.cwd(), 'data-shots').replace(/\\/g, '/'); + +export default defineConfig({ + testDir: './e2e', + testMatch: 'screenshots.spec.ts', + fullyParallel: false, + workers: 1, + // The run registers a user, so a second attempt would start from a state + // that no longer matches the script. + retries: 0, + timeout: 300_000, + expect: { timeout: 30_000 }, + reporter: 'list', + use: { + baseURL: BASE_URL, + locale: 'de-DE', + timezoneId: 'Europe/Berlin', + // A phone, because that is how the app is actually used. + ...devices['Pixel 7'], + }, + webServer: { + command: [ + 'docker run --rm --name bricktrack-shots', + `-p ${PORT}:3000`, + `-e BASE_URL=${BASE_URL}`, + '-e BETTER_AUTH_SECRET=screenshot-run-secret-0123456789abcdefgh', + '-e TZ=Europe/Berlin', + `-v "${dataDir}:/data"`, + 'bricktrack:shots', + ].join(' '), + url: `${BASE_URL}/api/health`, + reuseExistingServer: false, + timeout: 180_000, + stdout: 'pipe', + stderr: 'pipe', + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d3989d8..fa513f6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,10 +10,10 @@ importers: dependencies: '@better-auth/passkey': specifier: ^1.7.3 - version: 1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.7.3(better-sqlite3@13.0.3)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3)(kysely@0.29.5))(next@15.5.25(@babel/core@7.29.7)(@playwright/test@1.63.0)(@types/node@20.19.43)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vitest@5.0.0(@types/node@20.19.43)(vite@8.2.2(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13))))(better-call@1.4.0(zod@4.5.4))(nanostores@1.5.3) + version: 1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.7.3(better-sqlite3@13.0.3)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3)(kysely@0.29.5))(next@15.5.25(@babel/core@7.29.7)(@playwright/test@1.63.0)(@types/node@20.19.43)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vitest@5.0.0(@types/node@20.19.43)(vite@8.2.2(@types/node@20.19.43)(esbuild@0.25.12)(jiti@2.7.0)(tsx@4.23.13))))(better-call@1.4.0(zod@4.5.4))(nanostores@1.5.3) better-auth: specifier: ^1.7.3 - version: 1.7.3(better-sqlite3@13.0.3)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3)(kysely@0.29.5))(next@15.5.25(@babel/core@7.29.7)(@playwright/test@1.63.0)(@types/node@20.19.43)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vitest@5.0.0(@types/node@20.19.43)(vite@8.2.2(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13))) + version: 1.7.3(better-sqlite3@13.0.3)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3)(kysely@0.29.5))(next@15.5.25(@babel/core@7.29.7)(@playwright/test@1.63.0)(@types/node@20.19.43)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vitest@5.0.0(@types/node@20.19.43)(vite@8.2.2(@types/node@20.19.43)(esbuild@0.25.12)(jiti@2.7.0)(tsx@4.23.13))) better-sqlite3: specifier: ^13.0.3 version: 13.0.3 @@ -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) @@ -47,6 +50,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) @@ -87,6 +93,9 @@ importers: drizzle-kit: specifier: ^0.31.10 version: 0.31.10 + esbuild: + specifier: ^0.25.12 + version: 0.25.12 eslint: specifier: ^9 version: 9.39.5(jiti@2.7.0) @@ -107,7 +116,7 @@ importers: version: 5.9.3 vitest: specifier: ^5.0.0 - version: 5.0.0(@types/node@20.19.43)(vite@8.2.2(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)) + version: 5.0.0(@types/node@20.19.43)(vite@8.2.2(@types/node@20.19.43)(esbuild@0.25.12)(jiti@2.7.0)(tsx@4.23.13)) packages: @@ -1083,6 +1092,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} @@ -4171,6 +4255,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==} @@ -4417,6 +4505,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==} @@ -5155,14 +5246,14 @@ snapshots: '@better-auth/core': 1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3) '@better-auth/utils': 0.4.2 - '@better-auth/passkey@1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.7.3(better-sqlite3@13.0.3)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3)(kysely@0.29.5))(next@15.5.25(@babel/core@7.29.7)(@playwright/test@1.63.0)(@types/node@20.19.43)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vitest@5.0.0(@types/node@20.19.43)(vite@8.2.2(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13))))(better-call@1.4.0(zod@4.5.4))(nanostores@1.5.3)': + '@better-auth/passkey@1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.7.3(better-sqlite3@13.0.3)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3)(kysely@0.29.5))(next@15.5.25(@babel/core@7.29.7)(@playwright/test@1.63.0)(@types/node@20.19.43)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vitest@5.0.0(@types/node@20.19.43)(vite@8.2.2(@types/node@20.19.43)(esbuild@0.25.12)(jiti@2.7.0)(tsx@4.23.13))))(better-call@1.4.0(zod@4.5.4))(nanostores@1.5.3)': dependencies: '@better-auth/core': 1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3) '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 '@simplewebauthn/browser': 13.3.0 '@simplewebauthn/server': 13.3.3 - better-auth: 1.7.3(better-sqlite3@13.0.3)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3)(kysely@0.29.5))(next@15.5.25(@babel/core@7.29.7)(@playwright/test@1.63.0)(@types/node@20.19.43)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vitest@5.0.0(@types/node@20.19.43)(vite@8.2.2(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13))) + better-auth: 1.7.3(better-sqlite3@13.0.3)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3)(kysely@0.29.5))(next@15.5.25(@babel/core@7.29.7)(@playwright/test@1.63.0)(@types/node@20.19.43)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vitest@5.0.0(@types/node@20.19.43)(vite@8.2.2(@types/node@20.19.43)(esbuild@0.25.12)(jiti@2.7.0)(tsx@4.23.13))) better-call: 1.4.0(zod@4.5.4) nanostores: 1.5.3 zod: 4.5.4 @@ -5698,6 +5789,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 @@ -6954,14 +7093,14 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.12.2': optional: true - '@vitest/mocker@5.0.0(vite@8.2.2(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13))': + '@vitest/mocker@5.0.0(vite@8.2.2(@types/node@20.19.43)(esbuild@0.25.12)(jiti@2.7.0)(tsx@4.23.13))': dependencies: '@jridgewell/trace-mapping': 0.3.31 '@vitest/spy': 5.0.0 estree-walker: 3.0.3 magic-string: 1.2.3 optionalDependencies: - vite: 8.2.2(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13) + vite: 8.2.2(@types/node@20.19.43)(esbuild@0.25.12)(jiti@2.7.0)(tsx@4.23.13) '@vitest/spy@5.0.0': {} @@ -7115,7 +7254,7 @@ snapshots: baseline-browser-mapping@2.11.21: {} - better-auth@1.7.3(better-sqlite3@13.0.3)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3)(kysely@0.29.5))(next@15.5.25(@babel/core@7.29.7)(@playwright/test@1.63.0)(@types/node@20.19.43)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vitest@5.0.0(@types/node@20.19.43)(vite@8.2.2(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13))): + better-auth@1.7.3(better-sqlite3@13.0.3)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3)(kysely@0.29.5))(next@15.5.25(@babel/core@7.29.7)(@playwright/test@1.63.0)(@types/node@20.19.43)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vitest@5.0.0(@types/node@20.19.43)(vite@8.2.2(@types/node@20.19.43)(esbuild@0.25.12)(jiti@2.7.0)(tsx@4.23.13))): dependencies: '@better-auth/core': 1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3) '@better-auth/drizzle-adapter': 1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.2(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3)(kysely@0.29.5)) @@ -7141,7 +7280,7 @@ snapshots: next: 15.5.25(@babel/core@7.29.7)(@playwright/test@1.63.0)(@types/node@20.19.43)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) - vitest: 5.0.0(@types/node@20.19.43)(vite@8.2.2(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)) + vitest: 5.0.0(@types/node@20.19.43)(vite@8.2.2(@types/node@20.19.43)(esbuild@0.25.12)(jiti@2.7.0)(tsx@4.23.13)) transitivePeerDependencies: - '@cloudflare/workers-types' - '@opentelemetry/api' @@ -8782,6 +8921,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: {} @@ -9111,6 +9254,8 @@ snapshots: transitivePeerDependencies: - supports-color + server-only@0.0.1: {} + set-cookie-parser@3.1.2: {} set-function-length@1.2.2: @@ -9564,7 +9709,7 @@ snapshots: vary@1.1.2: {} - vite@8.2.2(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13): + vite@8.2.2(@types/node@20.19.43)(esbuild@0.25.12)(jiti@2.7.0)(tsx@4.23.13): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 @@ -9573,15 +9718,15 @@ snapshots: tinyglobby: 0.2.17 optionalDependencies: '@types/node': 20.19.43 - esbuild: 0.28.2 + esbuild: 0.25.12 fsevents: 2.3.3 jiti: 2.7.0 tsx: 4.23.13 - vitest@5.0.0(@types/node@20.19.43)(vite@8.2.2(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)): + vitest@5.0.0(@types/node@20.19.43)(vite@8.2.2(@types/node@20.19.43)(esbuild@0.25.12)(jiti@2.7.0)(tsx@4.23.13)): dependencies: '@types/chai': 5.2.3 - '@vitest/mocker': 5.0.0(vite@8.2.2(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)) + '@vitest/mocker': 5.0.0(vite@8.2.2(@types/node@20.19.43)(esbuild@0.25.12)(jiti@2.7.0)(tsx@4.23.13)) chai: 6.2.2 es-module-lexer: 2.3.2 expect-type: 1.4.0 @@ -9592,7 +9737,7 @@ snapshots: tinybench: 6.1.4 tinyexec: 1.3.0 tinyglobby: 0.2.17 - vite: 8.2.2(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13) + vite: 8.2.2(@types/node@20.19.43)(esbuild@0.25.12)(jiti@2.7.0)(tsx@4.23.13) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 20.19.43 diff --git a/scripts/reset-e2e.mjs b/scripts/reset-e2e.mjs new file mode 100644 index 0000000..8a11602 --- /dev/null +++ b/scripts/reset-e2e.mjs @@ -0,0 +1,13 @@ +/** + * Wipes the Playwright data directory before the smoke run. + * + * Runs before Playwright starts, because Playwright boots its web server + * before global setup - on Windows the open SQLite file would then block the + * delete. + */ +import fs from 'node:fs'; +import path from 'node:path'; + +const dataDir = path.resolve(process.cwd(), 'data-e2e'); +fs.rmSync(dataDir, { recursive: true, force: true }); +console.log(`[e2e] ${dataDir} zurueckgesetzt.`); diff --git a/scripts/screenshots-prepare.mjs b/scripts/screenshots-prepare.mjs new file mode 100644 index 0000000..69ef5ef --- /dev/null +++ b/scripts/screenshots-prepare.mjs @@ -0,0 +1,21 @@ +/** + * Prepares the screenshot run. + * + * The pictures are taken against the production image rather than the dev + * server: the dev server recompiles routes on demand, which made the capture + * run flaky, and the manual should show what people actually run. + */ +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; + +const dataDir = path.resolve(process.cwd(), 'data-shots'); + +fs.rmSync(dataDir, { recursive: true, force: true }); +fs.mkdirSync(path.join(dataDir, 'db'), { recursive: true }); +fs.mkdirSync(path.join(dataDir, 'files'), { recursive: true }); +console.log(`[screenshots] ${dataDir} zurueckgesetzt.`); + +console.log('[screenshots] Image wird gebaut (bricktrack:shots) …'); +execFileSync('docker', ['build', '-t', 'bricktrack:shots', '.'], { stdio: 'inherit' }); +console.log('[screenshots] Image ist bereit.'); 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)/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/(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)/sammlung/page.tsx b/src/app/(app)/sammlung/page.tsx new file mode 100644 index 0000000..fddc188 --- /dev/null +++ b/src/app/(app)/sammlung/page.tsx @@ -0,0 +1,150 @@ +import type { Metadata } from 'next'; +import Link from 'next/link'; +import { Boxes, Euro, ImageOff, Layers, PackageSearch } from 'lucide-react'; +import { CollectionFilters } from '@/components/sammlung/collection-filters'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent } from '@/components/ui/card'; +import { formatDate, formatNumber, pluralize } from '@/lib/format'; +import { setImageSrc } from '@/lib/images'; +import { completenessLabel, statusLabel } from '@/lib/labels'; +import { formatEuro } from '@/lib/money'; +import { + countMissingSets, + getCollectionStats, + listCollectionItems, + type CollectionFilters as Filters, +} from '@/lib/queries/collection'; +import { listLocations, listManufacturers } from '@/lib/queries/master-data'; +import { requireUser } from '@/lib/session'; +import type { CollectionStatus, Completeness } from '@/db/types'; + +export const metadata: Metadata = { title: 'Sammlung' }; + +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 SammlungPage({ searchParams }: { searchParams: SearchParams }) { + await requireUser(); + const params = await searchParams; + + const filters: Filters = { + q: single(params.q), + status: single(params.status) as CollectionStatus | undefined, + completeness: single(params.zustand) as Completeness | undefined, + locationId: single(params.standort), + manufacturerId: single(params.hersteller), + }; + + const items = listCollectionItems(filters); + const stats = getCollectionStats(); + const missing = countMissingSets(); + + const tiles = [ + { icon: Boxes, label: 'Sets', value: formatNumber(stats.setCount) }, + { icon: Layers, label: 'Teile gesamt', value: formatNumber(stats.pieceCount) }, + { icon: Euro, label: 'Wert nach UVP', value: formatEuro(stats.retailValueCents) }, + { icon: PackageSearch, label: 'Fehlt noch', value: formatNumber(missing) }, + ] as const; + + return ( +
+
+

Sammlung

+

Alles, was tatsächlich da ist.

+
+ +
+ {tiles.map((tile) => ( + + + +

{tile.value}

+

{tile.label}

+
+
+ ))} +
+ + {stats.byManufacturer.length > 0 ? ( +
+ {stats.byManufacturer.map((row) => ( + + {row.name}: {formatNumber(row.count)} + + ))} +
+ ) : null} + + ({ id: row.id, name: row.name }))} + manufacturers={listManufacturers().map((row) => ({ id: row.id, name: row.name }))} + /> + +

+ {pluralize(items.length, 'Eintrag', 'Einträge')} +

+ + {items.length === 0 ? ( +
+

+ Hier ist noch nichts. Sets kommen über den Katalog in die Sammlung. +

+ + Zum Katalog + +
+ ) : ( +
    + {items.map((item) => { + const image = setImageSrc(item); + return ( +
  • + +
    + {image ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( +
    + +
    + )} +
    + +
    +

    {item.setName}

    +

    + {[item.setNumber, item.manufacturerName, item.locationName] + .filter(Boolean) + .join(' · ')} +

    +
    + + {statusLabel(item.status)} + + {item.completeness !== 'unknown' ? ( + {completenessLabel(item.completeness)} + ) : null} + {item.acquiredAt ? ( + + seit {formatDate(item.acquiredAt)} + + ) : null} +
    +
    + +
  • + ); + })} +
+ )} +
+ ); +} 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/app/(app)/wunschlisten/[id]/page.tsx b/src/app/(app)/wunschlisten/[id]/page.tsx new file mode 100644 index 0000000..fc433ff --- /dev/null +++ b/src/app/(app)/wunschlisten/[id]/page.tsx @@ -0,0 +1,135 @@ +import type { Metadata } from 'next'; +import { notFound } from 'next/navigation'; +import { CatalogFilters } from '@/components/katalog/catalog-filters'; +import { CatalogGrid } from '@/components/katalog/catalog-grid'; +import { Pagination } from '@/components/katalog/pagination'; +import { ShareCard } from '@/components/wunschlisten/share-card'; +import { WishlistHeaderActions } from '@/components/wunschlisten/wishlist-header-actions'; +import { WishlistItems } from '@/components/wunschlisten/wishlist-items'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { formatDate, pluralize } from '@/lib/format'; +import { occasionLabel } from '@/lib/labels'; +import { CATALOG_PAGE_SIZE, listSets, listSetYears } from '@/lib/queries/catalog'; +import { listManufacturers, listThemes } from '@/lib/queries/master-data'; +import { getWishlist, listWishlistItems } from '@/lib/queries/wishlists'; +import { requireUser } from '@/lib/session'; + +type Props = { + params: Promise<{ id: string }>; + 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 async function generateMetadata({ params }: Props): Promise { + const { id } = await params; + const list = getWishlist(id); + return { title: list?.title ?? 'Wunschliste' }; +} + +export default async function WunschlisteDetailPage({ params, searchParams }: Props) { + const user = await requireUser(); + const { id } = await params; + const query = await searchParams; + + const list = getWishlist(id); + if (!list || list.ownerUserId !== user.id) notFound(); + + const items = listWishlistItems(list.id); + + // The embedded picker: missing sets that are not on this list yet. It shares + // the URL parameters with the catalog, so the filters behave identically. + const yearParam = single(query.jahr); + const page = Number(single(query.seite) ?? '1') || 1; + const missing = listSets({ + q: single(query.q), + manufacturerId: single(query.hersteller), + themeId: single(query.thema), + year: yearParam ? Number(yearParam) : undefined, + ownership: 'fehlt', + excludeWishlistId: list.id, + page, + }); + + return ( +
+
+
+
+ {occasionLabel(list.occasion)} + {list.eventDate ? {formatDate(list.eventDate)} : null} + {list.surpriseMode ? Überraschungsmodus : null} +
+

{list.title}

+

+ {pluralize(items.length, 'Wunsch', 'Wünsche')} +

+
+ +
+ + + + {list.note ? ( +

{list.note}

+ ) : null} + + + + + + Aus fehlenden Sets hinzufügen + + Nur Sets, die noch nicht in der Sammlung und noch nicht auf dieser Liste sind. Kachel + lange drücken oder „Auswählen“ tippen, dann mehrere auf einmal übernehmen. + + + + ({ id: row.id, name: row.name })), + themes: listThemes().map((row) => ({ + id: row.id, + name: row.name, + manufacturerId: row.manufacturerId, + })), + years: listSetYears(), + }} + /> + +

+ {pluralize(missing.total, 'Set kommt infrage', 'Sets kommen infrage')} +

+ + + + +
+
+
+ ); +} 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/(app)/wunschlisten/page.tsx b/src/app/(app)/wunschlisten/page.tsx new file mode 100644 index 0000000..7a99d73 --- /dev/null +++ b/src/app/(app)/wunschlisten/page.tsx @@ -0,0 +1,86 @@ +import type { Metadata } from 'next'; +import Link from 'next/link'; +import { CalendarDays, Gift, Link2Off } from 'lucide-react'; +import { NewWishlistButton } from '@/components/wunschlisten/wishlist-form-dialog'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { formatDate, pluralize } from '@/lib/format'; +import { occasionLabel } from '@/lib/labels'; +import { listWishlists } from '@/lib/queries/wishlists'; +import { requireUser } from '@/lib/session'; + +export const metadata: Metadata = { title: 'Wunschlisten' }; + +export default async function WunschlistenPage() { + const user = await requireUser(); + const lists = listWishlists(user.id); + + return ( +
+
+
+

Wunschlisten

+

+ Zum Teilen mit Familie und Freunden – ganz ohne Konto für die anderen. +

+
+ +
+ + {lists.length === 0 ? ( +
+

+ Noch keine Wunschliste. Der schnellste Weg: im Katalog auf „Fehlt noch“ filtern, + mehrere Sets auswählen und daraus eine Liste anlegen. +

+ + Zu den fehlenden Sets + +
+ ) : ( +
    + {lists.map((list) => ( +
  • + + + +
    + + {list.title} +
    + + {occasionLabel(list.occasion)} + {list.eventDate ? ` · ${formatDate(list.eventDate)}` : ''} + +
    + + {pluralize(list.itemCount, 'Wunsch', 'Wünsche')} + {list.openCount > 0 ? ( + {list.openCount} noch offen + ) : list.itemCount > 0 ? ( + + Alles vergeben + + ) : null} + {list.isPublic ? null : ( + + + Link deaktiviert + + )} + {list.eventDate ? ( + + + {formatDate(list.eventDate)} + + ) : null} + +
    + +
  • + ))} +
+ )} +
+ ); +} 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/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/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/app/w/[shareToken]/page.tsx b/src/app/w/[shareToken]/page.tsx new file mode 100644 index 0000000..0e1639c --- /dev/null +++ b/src/app/w/[shareToken]/page.tsx @@ -0,0 +1,103 @@ +import type { Metadata } from 'next'; +import { notFound } from 'next/navigation'; +import { Blocks, CalendarDays, Gift } from 'lucide-react'; +import { PublicWishlist, type PublicItem } from '@/components/wunschlisten/public-wishlist'; +import { Badge } from '@/components/ui/badge'; +import { formatDate, pluralize } from '@/lib/format'; +import { occasionLabel } from '@/lib/labels'; +import { getWishlistByShareToken, listWishlistItems } from '@/lib/queries/wishlists'; + +type Props = { params: Promise<{ shareToken: string }> }; + +/** + * The public face of a wishlist. + * + * Reachable with the share token alone, and it exposes nothing beyond the + * wishlist itself - no owner name, no collection, no other lists. Search + * engines are kept out; this is meant to be shared, not found. + */ +export const metadata: Metadata = { + title: 'Wunschliste', + robots: { index: false, follow: false }, +}; + +export default async function PublicWishlistPage({ params }: Props) { + const { shareToken } = await params; + + const list = getWishlistByShareToken(shareToken); + if (!list || !list.isPublic) notFound(); + + const rows = listWishlistItems(list.id); + + // Fulfilled wishes are gone from the public view: the present is already + // under the tree, no reason to let someone buy it twice. + const open = rows.filter((row) => row.fulfilledAt === null); + + const items: PublicItem[] = open.map((row) => ({ + id: row.id, + setNumber: row.setNumber, + setName: row.setName, + year: row.year, + pieceCount: row.pieceCount, + imageUrl: row.imageUrl, + imagePath: row.imagePath, + retailPriceCents: row.retailPriceCents, + manufacturerName: row.manufacturerName, + manufacturerWebsite: row.manufacturerWebsite, + themeName: row.themeName, + priority: row.priority, + note: row.note, + isReserved: row.reservedAt !== null, + reservedByName: row.reservedByName, + })); + + const openCount = items.filter((item) => !item.isReserved).length; + + return ( +
+
+
+ + Bricktrack +
+ +

{list.title}

+ +
+ + + {occasionLabel(list.occasion)} + + {list.eventDate ? ( + + + {formatDate(list.eventDate)} + + ) : null} + + {pluralize(openCount, 'Wunsch noch offen', 'Wünsche noch offen')} + +
+ + {list.note ? ( +

{list.note}

+ ) : null} + +

+ Klick auf „Das schenke ich“, damit niemand dasselbe doppelt kauft. Eine Anmeldung + brauchst du dafür nicht. +

+
+ +
+ {items.length === 0 ? ( +

+ Auf dieser Liste steht gerade nichts. +

+ ) : ( + + )} +
+
+ ); +} diff --git a/src/app/w/[shareToken]/storno/[itemId]/[token]/page.tsx b/src/app/w/[shareToken]/storno/[itemId]/[token]/page.tsx new file mode 100644 index 0000000..b1f0cc1 --- /dev/null +++ b/src/app/w/[shareToken]/storno/[itemId]/[token]/page.tsx @@ -0,0 +1,38 @@ +import type { Metadata } from 'next'; +import { notFound } from 'next/navigation'; +import { CancelReservation } from '@/components/wunschlisten/cancel-reservation'; +import { getWishlistByShareToken, listWishlistItems } from '@/lib/queries/wishlists'; + +export const metadata: Metadata = { + title: 'Reservierung aufheben', + robots: { index: false, follow: false }, +}; + +/** The link a visitor receives after reserving a gift. */ +export default async function CancelReservationPage({ + params, +}: { + params: Promise<{ shareToken: string; itemId: string; token: string }>; +}) { + const { shareToken, itemId, token } = await params; + + const list = getWishlistByShareToken(shareToken); + if (!list || !list.isPublic) notFound(); + + const item = listWishlistItems(list.id).find((row) => row.id === itemId); + if (!item) notFound(); + + return ( +
+ +
+ ); +} diff --git a/src/app/w/actions.ts b/src/app/w/actions.ts new file mode 100644 index 0000000..6469200 --- /dev/null +++ b/src/app/w/actions.ts @@ -0,0 +1,93 @@ +'use server'; + +import { revalidatePath } from 'next/cache'; +import { and, eq, isNull } from 'drizzle-orm'; +import { db } from '@/db'; +import { wishlist, wishlistItem } from '@/db/schema'; +import { actionData, actionError, actionOk, parseForm, type ActionResult } from '@/lib/action-result'; +import { createToken, hashToken, tokensMatch } from '@/lib/tokens'; +import { reservationSchema } from '@/lib/validation'; + +/** + * Actions for the public wishlist page. + * + * These run without a session. The share token is the only key, and every + * query is scoped by it - nothing outside the one wishlist is reachable, and + * nothing but wishlist data is returned. + */ + +function loadPublicList(shareToken: string) { + const list = db + .select({ id: wishlist.id, isPublic: wishlist.isPublic }) + .from(wishlist) + .where(eq(wishlist.shareToken, shareToken)) + .get(); + + return list && list.isPublic ? list : null; +} + +export async function reserveItem( + shareToken: string, + input: unknown, +): Promise> { + const list = loadPublicList(shareToken); + if (!list) return actionError('Diese Wunschliste ist nicht mehr erreichbar.'); + + const parsed = parseForm(reservationSchema, input); + if (!parsed.ok) return parsed; + + const cancelToken = createToken(); + + // The `reserved_at is null` condition makes this a claim, not a read-then-write: + // if two people tap at the same moment, only the first one wins. + const result = db + .update(wishlistItem) + .set({ + reservedByName: parsed.data.name, + reservedAt: new Date(), + reservationTokenHash: hashToken(cancelToken), + }) + .where( + and( + eq(wishlistItem.id, parsed.data.itemId), + eq(wishlistItem.wishlistId, list.id), + isNull(wishlistItem.reservedAt), + ), + ) + .run(); + + if (result.changes === 0) { + return actionError('Dieser Wunsch ist gerade eben schon von jemand anderem übernommen worden.'); + } + + revalidatePath(`/w/${shareToken}`); + return actionData({ cancelToken }); +} + +export async function cancelReservation( + shareToken: string, + itemId: string, + cancelToken: string, +): Promise { + const list = loadPublicList(shareToken); + if (!list) return actionError('Diese Wunschliste ist nicht mehr erreichbar.'); + + const item = db + .select({ id: wishlistItem.id, hash: wishlistItem.reservationTokenHash }) + .from(wishlistItem) + .where(and(eq(wishlistItem.id, itemId), eq(wishlistItem.wishlistId, list.id))) + .get(); + + if (!item) return actionError('Diesen Eintrag gibt es nicht mehr.'); + if (!tokensMatch(cancelToken, item.hash)) { + return actionError('Dieser Link passt nicht zu der Reservierung.'); + } + + db.update(wishlistItem) + .set({ reservedByName: null, reservedAt: null, reservationTokenHash: null }) + .where(eq(wishlistItem.id, itemId)) + .run(); + + revalidatePath(`/w/${shareToken}`); + return actionOk(); +} 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/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..7c4fcfb --- /dev/null +++ b/src/components/katalog/catalog-grid.tsx @@ -0,0 +1,317 @@ +'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) { + // isPublic: a list created on the fly is meant to be shared right away. + const created = await createWishlist({ + title: newTitle, + occasion: 'other', + isPublic: 'on', + }); + 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..3ec9f6d --- /dev/null +++ b/src/components/katalog/set-actions.tsx @@ -0,0 +1,221 @@ +'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) { + // isPublic: a list created on the fly is meant to be shared right away. + const created = await createWishlist({ + title: newTitle, + occasion: 'other', + isPublic: 'on', + }); + 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. +

+
+ + +