diff --git a/.env.example b/.env.example index 1306964..75817ac 100644 --- a/.env.example +++ b/.env.example @@ -27,6 +27,8 @@ TZ=Europe/Berlin # 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. +# Optional: Rebrickable-API-Key fuer den Lego-Import. Damit reicht auf der +# Seite "Neues Set" die Setnummer, und Name, Jahr, Teile, Minifiguren, Thema +# und Bild werden uebernommen. Ohne Key funktioniert alles manuell weiter. +# Kostenlos anlegen: https://rebrickable.com/api/ (Konto -> Settings -> API) # REBRICKABLE_API_KEY= diff --git a/.gitignore b/.gitignore index 23c007c..7e8447c 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,8 @@ next-env.d.ts /data-shots /data-docker /backups +/data-test +/data-test-export # production *.tsbuildinfo diff --git a/CHANGELOG.md b/CHANGELOG.md index 0eecc79..2cbb832 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,65 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/). ## [Unreleased] +## [0.2.0] - 2026-09-07 + +Phase 2: die Handgriffe, die den Alltag schneller machen. + +### Hinzugefuegt + +**Lego-Import (Rebrickable)** +- Auf "Neues Set" reicht die Setnummer von der Packung: Name, Jahr, Teile, + Minifiguren und Thema werden uebernommen, das Bild lokal gespeichert. +- Hersteller und Thema werden bei Bedarf angelegt, ein Oberthema inklusive. +- Alle Aufrufe laufen serverseitig. Ohne `REBRICKABLE_API_KEY` bleibt der + Import sichtbar, aber abgeschaltet - von Hand anlegen geht unveraendert. + +**Barcode-Scanner** +- Knopf im Katalog: Strichcode mit der Handykamera scannen. Ist das Set + bekannt, oeffnet es sich; sonst das Formular mit ausgefuellter EAN. +- Jeder Code wird gegen seine Pruefziffer geprueft; dekodiert wird im + Browser, nichts verlaesst das Geraet. + +**Fehlteile** +- Fehlende Teile je Sammlungseintrag mit Teilenummer, Farbe, Anzahl und + Notiz, abhakbar wenn nachgekauft. +- Die Vollstaendigkeit pflegt sich daraus selbst: der erste offene Eintrag + setzt "Teile fehlen", der letzte erledigte zurueck auf "vollstaendig". + +**Einladungen** +- Der Admin erzeugt Registrierungslinks, die genau einmal funktionieren, + optional an eine E-Mail-Adresse gebunden, mit waehlbarer Rolle und + Gueltigkeit. In der Datenbank liegt nur der Hash des Tokens. + +**Export und Import** +- Alles als ZIP (Daten plus hochgeladene Dateien), nur die Daten als JSON, + die Sammlung als CSV mit Semikolon und BOM fuer deutsches Excel. +- Import ersetzt die Anwendungsdaten in einer Transaktion, nur fuer + Administratoren und nur nach getipptem ERSETZEN. +- Nicht im Export: Konten, Passwoerter, Passkeys, Reservierungen und + Teilen-Links. + +**PWA** +- Manifest, Icons und Service Worker: installierbar auf dem Startbildschirm, + mit Verknuepfungen zu "Fehlende Sets" und zur Sammlung. +- Einmal geladene Seiten bleiben ohne Verbindung lesbar; Set-Bilder und + Build-Dateien kommen aus dem Cache. Beim Abmelden wird der Cache geraeumt. + +### Geaendert + +- `src/env.ts` behandelt leere Werte wie nicht gesetzt: ein `BASE_URL=` in der + `.env` fuehrte bisher zum Start-Abbruch statt zum Standardwert. +- Unit-Tests koennen jetzt auch Server-Module laden (`server-only` wird in der + Testumgebung durch ein leeres Modul ersetzt). + +### Bekannte Einschraenkungen + +- Rebrickable liefert keine Anleitungsdateien. Der Import legt stattdessen + einen Link auf die LEGO-Serviceseite zur Setnummer an. +- Der Barcode-Scanner braucht wie Passkeys HTTPS. +- Beim Import wandern Wunschlisten an den importierenden Admin und sind + zunaechst nicht geteilt. + ## [0.1.0] - 2026-09-07 Erste lauffaehige Fassung: Phase 1 vollstaendig, im Docker-Stack verifiziert. @@ -88,5 +147,6 @@ Erste lauffaehige Fassung: Phase 1 vollstaendig, im Docker-Stack verifiziert. - 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 +[Unreleased]: https://git.wilde-server.de/florian.wilde/bricktrack/compare/v0.2.0...HEAD +[0.2.0]: https://git.wilde-server.de/florian.wilde/bricktrack/releases/tag/v0.2.0 [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 37b21c4..142e680 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,6 +19,9 @@ teilbare Wunschlisten. | Validierung | zod (serverseitig, immer) | | | Bilder | sharp (nur serverseitig, Thumbnails) | | | Tests | Vitest (Unit), Playwright (E2E, Screenshots) | | +| PDF | pdfjs-dist, lokal gebuendelt | kein CDN | +| Barcode | @zxing/browser, lazy geladen | nur beim Scannen | +| Archive | fflate | Export/Import als ZIP | | Paketmanager | pnpm 10 | | Bewusst **kein** Prisma (Standalone-Build im Container), **keine** @@ -43,6 +46,20 @@ Bild-URLs, die der Nutzer selbst hinterlegt hat. - Commits: Conventional Commits, Betreff auf Deutsch, Imperativ, erste Zeile <= 72 Zeichen. +## Wichtige Module + +| Datei | Zustaendig fuer | +|---|---| +| `src/lib/ownership.ts` | die eine Definition von "hat er" / "hat er nicht" | +| `src/lib/wishlist-logic.ts` | Mehrfachauswahl, Dubletten, Prioritaeten (rein, testbar) | +| `src/lib/tokens.ts` | Share-, Storno- und Einladungs-Token | +| `src/lib/invites.ts` | Einladungen inklusive der Freigabe fuer die Registrierung | +| `src/lib/rebrickable.ts` | Lego-Import, ausschliesslich serverseitig | +| `src/lib/set-number.ts`, `src/lib/ean.ts` | Setnummern und Barcodes (rein, testbar) | +| `src/lib/uploads.ts` | Datei-Uploads, Magic Bytes, Thumbnails | +| `src/lib/export.ts` | Export und Import des gesamten Bestands | +| `src/lib/money.ts`, `src/lib/format.ts` | Cent-Umrechnung, dd.MM.yyyy | + ## Befehle ```bash @@ -58,8 +75,22 @@ pnpm db:studio # Drizzle Studio pnpm test # Vitest pnpm test:e2e # Playwright pnpm screenshots # Screenshots fuer docs/ANLEITUNG.md erzeugen + +node scripts/generate-icons.mjs # PWA-Icons aus der SVG-Quelle rendern ``` +### Testkonventionen + +- Unit-Tests liegen in `tests/unit`, laufen unter Node und duerfen auch + Server-Module laden: `server-only` ist in `vitest.config.mts` auf einen + leeren Stub gemappt. +- `tests/setup-env.ts` setzt Platzhalter fuer die Umgebung. `BASE_URL` wird + dort **immer** ueberschrieben, weil Vite denselben Namen fuer seinen + Public-Base-Path benutzt und ihn auf `/` setzt. +- Der Playwright-Smoke-Test nutzt `./data-e2e`, der Screenshot-Lauf + `./data-shots` gegen das Produktions-Image. Beide Verzeichnisse werden vor + jedem Lauf geleert und liegen nie im Git. + ## Datenmodell Kernregel: Ein **`set`** ist ein *Katalogeintrag* - es existiert, @@ -145,13 +176,19 @@ Session. serverseitig. 5. Session-Cookies: `httpOnly`, `secure`, `sameSite=lax`. Rate-Limiting auf den Login-Endpunkten. +6. Der Export enthaelt **keine** Konten, Passwoerter oder Passkeys - und + keine Reservierungen oder Teilen-Token. Das Backup ist `./data`, nicht der + Export. +7. Der Service Worker beantwortet nur GET-Anfragen aus dem Cache und niemals + `/api/auth`, `/api/export` oder den Healthcheck. Beim Abmelden wird der + Cache geleert. ## Phasenplan - **Phase 1 (MVP)** - Auth mit Passkeys, Stammdaten, Katalog, Sammlung, - Wunschlisten inkl. oeffentlicher Ansicht, Docker-Stack. + Wunschlisten inkl. oeffentlicher Ansicht, Docker-Stack. *Fertig in 0.1.0.* - **Phase 2 (Komfort)** - Rebrickable-Import, Barcode-Scanner, Fehlteile, - Einladungen, Export/Import, PWA. + Einladungen, Export/Import, PWA. *Fertig in 0.2.0.* - **Phase 3 (Nice to have)** - mehrere Sammler, Preisbeobachtung, E-Mail-Erinnerungen, Dublettenpruefung. diff --git a/README.md b/README.md index 409040a..91bc6a7 100644 --- a/README.md +++ b/README.md @@ -172,10 +172,16 @@ pnpm dev # http://localhost:3000 | `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 test:e2e` | Playwright-Smoke-Test gegen das Docker-Image (eigenes `./data-e2e`) | | `pnpm db:generate` | SQL-Migration aus dem Drizzle-Schema erzeugen | | `pnpm db:migrate` | Migrationen anwenden | | `pnpm db:studio` | Drizzle Studio | +| `pnpm screenshots` | Bilder für `docs/ANLEITUNG.md` neu erzeugen | + +`pnpm test:e2e` und `pnpm screenshots` bauen jeweils das Docker-Image und +fahren es hoch. Sie brauchen also einen laufenden Docker-Daemon, testen dafür +aber genau das Artefakt, das später auch deployt wird – der Dev-Server hat +unter der Last eines ganzen Durchlaufs reproduzierbar Chunks verloren. > **Unter Windows** schlägt `pnpm build` mit `EPERM … symlink` fehl: der > Standalone-Output legt Symlinks an, wofür Windows den Entwicklermodus oder diff --git a/docs/ANLEITUNG.md b/docs/ANLEITUNG.md index 5578bc2..71a73c8 100644 --- a/docs/ANLEITUNG.md +++ b/docs/ANLEITUNG.md @@ -22,7 +22,12 @@ Kinderzimmer, nicht für den Schreibtisch. 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) +11. [Sets im Schnellverfahren: Import und Scanner](#11-import-und-scanner) +12. [Fehlteile](#12-fehlteile) +13. [Noch ein Konto für die Familie](#13-einladungen) +14. [Daten sichern, umziehen, auswerten](#14-daten) +15. [Auf den Startbildschirm legen](#15-startbildschirm) +16. [Antworten auf die zwei wichtigen Fragen](#16-die-zwei-fragen) --- @@ -291,7 +296,130 @@ gib ihnen sprechende Namen. --- -## 11. Die zwei Fragen +## 11. Import und Scanner + +Sets von Hand einzutippen ist der langsame Weg. Für Lego gibt es zwei +schnellere. + +### Lego-Import über die Setnummer + +![Lego-Import](bilder/17-lego-import.png) + +Auf **Katalog → Set anlegen** steht oben das Feld für den Import. Setnummer +von der Packung eintippen, „Holen" – und Name, Jahr, Teile, Minifiguren und +Thema stehen drin. Das Bild wird **lokal gespeichert**, funktioniert also auch +weiter, wenn die Quelle es irgendwann austauscht. Fehlen Hersteller oder +Thema noch, legt der Import sie an. + +Dafür braucht es einen kostenlosen API-Key von +[rebrickable.com/api](https://rebrickable.com/api/), eingetragen als +`REBRICKABLE_API_KEY` in der `.env`. Ohne Key steht an derselben Stelle ein +Hinweis, und alles andere funktioniert unverändert weiter. + +> Anleitungsdateien liefert Rebrickable nicht. Der Import legt stattdessen +> einen Link auf die LEGO-Serviceseite zur Setnummer an – von dort kommst du +> zum PDF, das du dann hochladen kannst. + +### Barcode scannen + +Im Katalog sitzt neben „Set anlegen" ein Scanner-Symbol. Strichcode der +Packung ins Bild halten: + +- **Set schon im Katalog** → es öffnet sich direkt. Genau das, was man im + Laden vor dem Regal wissen will. +- **Noch unbekannt** → das Formular „Neues Set" öffnet sich mit + ausgefüllter EAN. + +Gelesen wird ausschließlich im Browser, es geht nichts an einen fremden +Dienst. Jeder Code wird gegen seine Prüfziffer geprüft – verlesene Ziffern +landen so gar nicht erst im Katalog. Die Kamera braucht wie Passkeys HTTPS. + +--- + +## 12. Fehlteile + +![Fehlteile](bilder/18-fehlteile.png) + +Beim Aufbauen fehlt ein Stein. Auf der Set-Detailseite hat jeder +Sammlungseintrag ein Puzzle-Symbol: dort trägst du ein, was fehlt – mit +Teilenummer und Farbe, wenn du sie kennst, sonst reicht eine Notiz wie +„Dachstein vorne links". + +Ist das Teil nachgekauft, hakst du es ab. Die **Vollständigkeit pflegt sich +dabei selbst**: Der erste offene Eintrag setzt den Sammlungseintrag auf +„Teile fehlen", der letzte erledigte zurück auf „vollständig". Der Filter in +der Sammlung stimmt damit, ohne dass du daran denken musst. + +--- + +## 13. Einladungen + +![Einladungen](bilder/19-einladungen.png) + +Die Registrierung ist nach dem ersten Konto geschlossen – sonst könnte sich +jeder, der die Adresse kennt, ein Konto anlegen. Für den zweiten Elternteil +gibt es deshalb **Verwaltung → Einladungen**: + +1. „Einladung erstellen", optional an eine E-Mail-Adresse gebunden, mit Rolle + und Gültigkeit. +2. Den Link kopieren und weitergeben. Er wird **nur einmal angezeigt** – in + der Datenbank liegt nur sein Hash. +3. Die eingeladene Person legt darüber ihr Konto an. Danach ist der Link + verbraucht. + +„Mitglied" darf die Sammlung pflegen und Wunschlisten anlegen; „Administrator" +zusätzlich einladen und Daten exportieren. + +--- + +## 14. Daten + +![Daten](bilder/20-daten.png) + +Unter **Verwaltung → Daten** gibt es drei Downloads: + +| | Wofür | +|---|---| +| **Alles als ZIP** | Umziehen auf einen anderen Server, zweites Netz | +| **Nur Daten (JSON)** | Zum Reinschauen oder Weiterverarbeiten | +| **Sammlung als CSV** | Für Excel oder LibreOffice, Semikolon-getrennt | + +**Nicht im Export:** Konten, Passwörter und Passkeys. Ein Export voller +Passwort-Hashes im Download-Ordner wäre ein Risiko, kein Backup. Ebenso außen +vor: Reservierungen und Teilen-Links – die gehören zu der Instanz, auf der sie +entstanden sind. + +> **Das richtige Backup bleibt `./data`.** Ein Verzeichnis, Datenbank und +> Dateien darin. Siehe [README](../README.md#backup-und-restore). + +Der **Import** liest ein solches ZIP wieder ein – und **ersetzt dabei alle +vorhandenen Daten**. Zusammenführen zweier Sammlungen ist bewusst nicht +möglich: Es bräuchte Konfliktregeln für jede Tabelle, und das Ergebnis wäre +nicht vorhersagbar. Deshalb muss man zur Bestätigung `ERSETZEN` eintippen. +Wunschlisten gehören danach dir und sind zunächst nicht geteilt. + +--- + +## 15. Startbildschirm + +Bricktrack lässt sich wie eine App installieren: + +- **iPhone/iPad (Safari):** Teilen-Symbol → „Zum Home-Bildschirm". +- **Android (Chrome):** Menü → „App installieren" bzw. „Zum Startbildschirm + hinzufügen". + +Danach startet Bricktrack ohne Browserleiste, und langes Drücken auf das Symbol +springt direkt zu „Fehlende Sets" oder zur Sammlung. + +**Ohne Verbindung** bleibt lesbar, was du schon einmal geöffnet hast – der +Katalog also, wenn du ihn vorher im WLAN durchgeblättert hast. Ändern lässt +sich offline nichts: Eine Reservierung, die nur auf deinem Gerät liegt, wäre +für alle anderen unsichtbar und damit eine Lüge. Beim Abmelden räumt die App +den Zwischenspeicher, damit auf einem geteilten Gerät nichts lesbar bleibt. + +--- + +## 16. Die zwei Fragen Dafür ist die ganze App gebaut: @@ -302,8 +430,8 @@ Dafür ist die ganze App gebaut: → 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. +Im Laden vor dem Regal: Scanner öffnen, Strichcode einlesen. Steht ein grüner +Haken dran, habt ihr es schon. --- diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index 2769344..60d4e5c 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -29,6 +29,22 @@ Commit-Spalte verweist auf den Stand am Ende des Schritts. **Summe Phase 1: 2:44** +## Phase 2 – Komfort (v0.2.0) + +### 07.09.2026 + +| Von | Bis | Dauer | Schritt | Ergebnis | Commit | +|---|---|---|---|---|---| +| 06:50 | 07:01 | 0:11 | Einladungen | Admin erzeugt einmal gültige Registrierungslinks; Freigabe über AsyncLocalStorage, damit die Rolle nicht aus dem Formular kommen kann. Smoke-Test deckt den ganzen Weg ab | `52d69e5` | +| 07:01 | 07:12 | 0:11 | Rebrickable-Import | Setnummer genügt: Metadaten, Thema und lokal gespeichertes Bild; 14 Tests gegen gestubbtes `fetch` inklusive aller Fehlerfälle | `4e3cbb8` | +| 07:12 | 07:15 | 0:03 | Barcode-Scanner | EAN mit der Handykamera, Prüfziffer-Kontrolle, zxing wird erst beim Öffnen geladen | `baa94bf` | +| 07:15 | 07:18 | 0:03 | Fehlteile | Liste je Sammlungseintrag; Vollständigkeit pflegt sich daraus selbst | `5c5c521` | +| 07:18 | 07:23 | 0:05 | Export und Import | ZIP, JSON und CSV; Import ersetzt in einer Transaktion, 10 Tests für den Rundweg | `45aa8f1` | +| 07:23 | 07:27 | 0:04 | PWA | Manifest, Icons aus SVG-Quelle, Service Worker mit Offline-Katalog | `39271f8` | +| 07:27 | 07:45 | 0:18 | Verifikation und Dokumentation | Smoke-Test, Docker-Build, Changelog 0.2.0, Anleitung und Roadmap | folgt | + +**Summe Phase 2: 0:55** + --- ## Notizen und Entscheidungen @@ -86,6 +102,24 @@ Commit-Spalte verweist auf den Stand am Ende des Schritts. Produktions-Image im Container – verlässlich und zugleich näher an dem, was Nutzer wirklich sehen. +### Phase 2 + +- **`server-only` in Tests**: Das Paket wirft beim Import ausserhalb einer + Server-Komponente, wodurch sich Servermodule nicht testen liessen. In + `vitest.config.mts` ist es jetzt auf einen leeren Stub gemappt – der Import + ist eine Build-Zeit-Absicherung, kein Verhalten. +- **Namenskonflikt `BASE_URL`**: Vite benutzt genau diesen Namen für seinen + Public-Base-Path und setzt ihn auf `/`. Die Tests überschreiben ihn deshalb + ausdrücklich. Dabei ist aufgefallen, dass `src/env.ts` bei einem leeren Wert + abbrach statt auf den Standard zurückzufallen – behoben, denn ein + `BASE_URL=` in der `.env` ist ein realistischer Tippfehler. +- **Rebrickable und Anleitungen**: Die API v3 stellt keine Anleitungsdateien + bereit. Statt das zu verschleiern, legt der Import einen externen Link auf + die LEGO-Serviceseite zur Setnummer an. +- **Import statt Zusammenführen**: Zwei Sammlungen zu mischen bräuchte + Konfliktregeln für jede Tabelle und wäre im Ergebnis nicht vorhersagbar. + Der Import ersetzt deshalb – dafür mit getipptem `ERSETZEN` als Bremse. + ### Beim Testen gefunden und behoben - Eine spontan aus dem Katalog angelegte Wunschliste war **nicht teilbar**: diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index af1460c..fe01b00 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -34,17 +34,17 @@ Laeuft vollstaendig im Docker-Stack. - [x] Dokumentation: README, QUICKSTART, bebilderte Anleitung, update.sh, Nginx-Proxy-Manager-Konfiguration -## Phase 2 - Komfort (Ziel: v0.2.0) +## Phase 2 - Komfort (v0.2.0, abgeschlossen am 07.09.2026) -- [ ] Rebrickable-Import: Setnummer eingeben, Metadaten und Bild +- [x] Rebrickable-Import: Setnummer eingeben, Metadaten und Bild uebernehmen, Bild lokal cachen. Ohne API-Key bleibt alles manuell. -- [ ] Barcode-Scanner (EAN) per Handykamera, Suche nach EAN, bei +- [x] Barcode-Scanner (EAN) per Handykamera, Suche nach EAN, bei Unbekanntem direkt ins Formular "Neues Set" -- [ ] Fehlteile je Sammlungseintrag pflegen -- [ ] Einladungslinks durch den Admin -- [ ] Export/Import: gesamte Datenbank als JSON, Dateien als ZIP, +- [x] Fehlteile je Sammlungseintrag pflegen +- [x] Einladungslinks durch den Admin +- [x] Export/Import: gesamte Datenbank als JSON, Dateien als ZIP, Sammlung zusaetzlich als CSV -- [ ] PWA: Manifest, Service Worker, Katalog offline lesbar +- [x] PWA: Manifest, Service Worker, Katalog offline lesbar ## Phase 3 - Nice to have (Ziel: v0.3.0) diff --git a/docs/bilder/01-registrierung.png b/docs/bilder/01-registrierung.png index 38e44e1..7788f17 100644 Binary files a/docs/bilder/01-registrierung.png and b/docs/bilder/01-registrierung.png differ diff --git a/docs/bilder/02-start.png b/docs/bilder/02-start.png index 7a0be01..441cab0 100644 Binary files a/docs/bilder/02-start.png and b/docs/bilder/02-start.png differ diff --git a/docs/bilder/03-verwaltung-hersteller.png b/docs/bilder/03-verwaltung-hersteller.png index d64c892..e4db9b5 100644 Binary files a/docs/bilder/03-verwaltung-hersteller.png and b/docs/bilder/03-verwaltung-hersteller.png differ diff --git a/docs/bilder/04-katalog.png b/docs/bilder/04-katalog.png index f9a3021..77f9643 100644 Binary files a/docs/bilder/04-katalog.png and b/docs/bilder/04-katalog.png differ diff --git a/docs/bilder/05-fehlende-sets.png b/docs/bilder/05-fehlende-sets.png index 8c1a8e1..2a1bd7f 100644 Binary files a/docs/bilder/05-fehlende-sets.png and b/docs/bilder/05-fehlende-sets.png differ diff --git a/docs/bilder/06-mehrfachauswahl.png b/docs/bilder/06-mehrfachauswahl.png index fa3787b..eb08bc7 100644 Binary files a/docs/bilder/06-mehrfachauswahl.png and b/docs/bilder/06-mehrfachauswahl.png differ diff --git a/docs/bilder/07-wunschliste-dialog.png b/docs/bilder/07-wunschliste-dialog.png index 518254e..3527da4 100644 Binary files a/docs/bilder/07-wunschliste-dialog.png 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 index d33cc18..dade78c 100644 Binary files a/docs/bilder/08-set-detail.png and b/docs/bilder/08-set-detail.png differ diff --git a/docs/bilder/11-wunschliste-bearbeiten.png b/docs/bilder/11-wunschliste-bearbeiten.png index 94816f6..becf7c2 100644 Binary files a/docs/bilder/11-wunschliste-bearbeiten.png and b/docs/bilder/11-wunschliste-bearbeiten.png differ diff --git a/docs/bilder/14-reserviert.png b/docs/bilder/14-reserviert.png index 5988793..e9ec42c 100644 Binary files a/docs/bilder/14-reserviert.png and b/docs/bilder/14-reserviert.png differ diff --git a/docs/bilder/15-wunschliste-reserviert.png b/docs/bilder/15-wunschliste-reserviert.png index a4c315e..f406a05 100644 Binary files a/docs/bilder/15-wunschliste-reserviert.png and b/docs/bilder/15-wunschliste-reserviert.png differ diff --git a/docs/bilder/17-lego-import.png b/docs/bilder/17-lego-import.png new file mode 100644 index 0000000..380e195 Binary files /dev/null and b/docs/bilder/17-lego-import.png differ diff --git a/docs/bilder/18-fehlteile.png b/docs/bilder/18-fehlteile.png new file mode 100644 index 0000000..a857431 Binary files /dev/null and b/docs/bilder/18-fehlteile.png differ diff --git a/docs/bilder/19-einladungen.png b/docs/bilder/19-einladungen.png new file mode 100644 index 0000000..fb4e304 Binary files /dev/null and b/docs/bilder/19-einladungen.png differ diff --git a/docs/bilder/20-daten.png b/docs/bilder/20-daten.png new file mode 100644 index 0000000..6d51743 Binary files /dev/null and b/docs/bilder/20-daten.png differ diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts deleted file mode 100644 index e0bb8a4..0000000 --- a/e2e/global-setup.ts +++ /dev/null @@ -1,13 +0,0 @@ -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 index 8289d7e..695c6af 100644 --- a/e2e/screenshots.spec.ts +++ b/e2e/screenshots.spec.ts @@ -127,4 +127,37 @@ test('Screenshots fuer die Anleitung', async ({ page, browser }) => { await visit(page, '/konto'); await expect(page.getByText('Passkeys', { exact: true })).toBeVisible(); await shoot(page, '16-konto-passkeys'); + + // --- 12. Lego import ------------------------------------------------------ + // The screenshot container runs with a placeholder API key so the manual + // shows the form, not the "not configured" hint. Nothing is submitted. + await visit(page, '/katalog/neu'); + await expect(page.getByText('Lego-Import')).toBeVisible(); + await shoot(page, '17-lego-import'); + + // --- 13. missing parts ---------------------------------------------------- + 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 page.getByRole('button', { name: 'Fehlteile' }).first().click(); + await expect(page.getByRole('dialog')).toBeVisible(); + await page.getByLabel('Teilenummer').fill('3001'); + await page.getByLabel('Farbe').fill('Rot'); + await page.getByRole('button', { name: 'Hinzufügen' }).click(); + await expect(page.getByText('3001')).toBeVisible(); + await page.screenshot({ path: shotPath('18-fehlteile') }); + await page.getByRole('button', { name: 'Fertig' }).click(); + + // --- 14. invitations ------------------------------------------------------ + await visit(page, '/verwaltung/einladungen'); + await expect(page.getByRole('button', { name: 'Einladung erstellen' })).toBeVisible(); + await page.getByRole('button', { name: 'Einladung erstellen' }).click(); + await expect(page.getByRole('dialog')).toBeVisible(); + await page.screenshot({ path: shotPath('19-einladungen') }); + await page.getByRole('button', { name: 'Abbrechen' }).click(); + + // --- 15. export and import ------------------------------------------------ + await visit(page, '/verwaltung/daten'); + await expect(page.getByText('Alles als ZIP')).toBeVisible(); + await shoot(page, '20-daten'); }); diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts index 9a4047f..c3f21cf 100644 --- a/e2e/smoke.spec.ts +++ b/e2e/smoke.spec.ts @@ -81,6 +81,53 @@ test('Registrieren, Set anlegen, Wunschliste teilen und reservieren', async ({ p await visitorContext.close(); }); +test('Einladung: zweiter Elternteil bekommt ein eigenes Konto', async ({ page, browser }) => { + // The admin from the first test is still the only account, so log in. + await page.goto('/anmelden'); + await page.getByLabel('E-Mail').fill('test@bricktrack.local'); + await page.getByLabel('Passwort').fill('smoketest-passwort'); + await page.getByRole('button', { name: 'Anmelden', exact: true }).click(); + await expect(page.getByRole('heading', { name: 'Hallo Florian' })).toBeVisible(); + + // --- the admin issues an invitation -------------------------------------- + await page.goto('/verwaltung/einladungen'); + await page.getByRole('button', { name: 'Einladung erstellen' }).click(); + await page.getByRole('button', { name: 'Erstellen' }).click(); + + const inviteUrl = await page + .getByRole('textbox', { name: 'Einladungslink' }) + .inputValue(); + expect(inviteUrl).toContain('/einladung/'); + await page.getByRole('button', { name: 'Fertig' }).click(); + await expect(page.getByText('Offen').first()).toBeVisible(); + + // --- the invited person registers ---------------------------------------- + const guestContext = await browser.newContext(); + const guest = await guestContext.newPage(); + await guest.goto(inviteUrl); + await expect(guest.getByRole('button', { name: 'Konto anlegen' })).toBeVisible(); + + await guest.getByLabel('Name').fill('Mama'); + await guest.getByLabel('E-Mail').fill('mama@bricktrack.local'); + await guest.getByLabel('Passwort').fill('zweites-passwort'); + await guest.getByRole('button', { name: 'Konto anlegen' }).click(); + + await expect(guest.getByRole('heading', { name: 'Hallo Mama' })).toBeVisible(); + + // A member is not an admin: no invitation management for them. + await guest.goto('/verwaltung/einladungen'); + await expect(guest).toHaveURL(/\/$|\/anmelden/); + + // --- the link is single use ---------------------------------------------- + const secondTry = await browser.newContext(); + const secondPage = await secondTry.newPage(); + await secondPage.goto(inviteUrl); + await expect(secondPage.getByText('Einladung ungültig')).toBeVisible(); + + await guestContext.close(); + await secondTry.close(); +}); + test('Ohne Anmeldung fuehrt jeder geschuetzte Pfad zur Anmeldung', async ({ page }) => { await page.goto('/sammlung'); await expect(page).toHaveURL(/\/anmelden|\/registrieren/); diff --git a/eslint.config.mjs b/eslint.config.mjs index 719cea2..df2f176 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -11,6 +11,17 @@ const compat = new FlatCompat({ const eslintConfig = [ ...compat.extends("next/core-web-vitals", "next/typescript"), + { + rules: { + // Destructuring a key out in order to drop it is how we omit fields + // (share tokens, reservations) from an export - the binding is + // deliberately unused. + "@typescript-eslint/no-unused-vars": [ + "warn", + { ignoreRestSiblings: true, argsIgnorePattern: "^_", varsIgnorePattern: "^_" }, + ], + }, + }, { ignores: [ "node_modules/**", diff --git a/package.json b/package.json index 49f9650..06c3cb8 100644 --- a/package.json +++ b/package.json @@ -1,73 +1,76 @@ -{ - "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" - ] - } -} +{ + "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/e2e-prepare.mjs && playwright test", + "screenshots": "node scripts/screenshots-prepare.mjs && playwright test --config=playwright.screenshots.config.ts" + }, + "dependencies": { + "@better-auth/passkey": "^1.7.3", + "@zxing/browser": "^0.2.1", + "@zxing/library": "^0.23.0", + "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", + "fflate": "^0.8.3", + "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 index 069409c..4e407e1 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,55 +1,58 @@ -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', - }, - }, -}); +import path from 'node:path'; +import { defineConfig, devices } from '@playwright/test'; + +/** + * Smoke test setup. + * + * Runs against the production container with its own data directory + * (./data-e2e), so a test never touches the real database and never depends on + * the dev server compiling fast enough. + */ +const PORT = 3210; +const BASE_URL = `http://localhost:${PORT}`; + +// Docker wants forward slashes, also on Windows. +const dataDir = path.resolve(process.cwd(), 'data-e2e').replace(/\\/g, '/'); + +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', + fullyParallel: false, + workers: 1, + retries: 0, + timeout: 90_000, + expect: { timeout: 15_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: [ + 'docker run --rm --name bricktrack-e2e', + `-p ${PORT}:3000`, + `-e BASE_URL=${BASE_URL}`, + '-e BETTER_AUTH_SECRET=playwright-smoke-test-secret-0123456789abcdef', + '-e TZ=Europe/Berlin', + '-e MAX_UPLOAD_MB=25', + `-v "${dataDir}:/data"`, + 'bricktrack:e2e', + ].join(' '), + url: `${BASE_URL}/api/health`, + reuseExistingServer: false, + timeout: 180_000, + stdout: 'pipe', + stderr: 'pipe', + }, +}); diff --git a/playwright.screenshots.config.ts b/playwright.screenshots.config.ts index f921090..5bf0038 100644 --- a/playwright.screenshots.config.ts +++ b/playwright.screenshots.config.ts @@ -41,6 +41,9 @@ export default defineConfig({ `-e BASE_URL=${BASE_URL}`, '-e BETTER_AUTH_SECRET=screenshot-run-secret-0123456789abcdefgh', '-e TZ=Europe/Berlin', + // A placeholder key so the manual shows the import form rather than the + // "not configured" hint. The screenshot run never submits it. + '-e REBRICKABLE_API_KEY=screenshot-placeholder', `-v "${dataDir}:/data"`, 'bricktrack:shots', ].join(' '), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fa513f6..8fef224 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,12 @@ importers: '@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.25.12)(jiti@2.7.0)(tsx@4.23.13))))(better-call@1.4.0(zod@4.5.4))(nanostores@1.5.3) + '@zxing/browser': + specifier: ^0.2.1 + version: 0.2.1(@zxing/library@0.23.0) + '@zxing/library': + specifier: ^0.23.0 + version: 0.23.0 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.25.12)(jiti@2.7.0)(tsx@4.23.13))) @@ -29,6 +35,9 @@ importers: drizzle-orm: specifier: ^0.45.2 version: 0.45.2(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3)(kysely@0.29.5) + fflate: + specifier: ^0.8.3 + version: 0.8.3 lucide-react: specifier: ^1.41.0 version: 1.41.0(react@19.1.0) @@ -2457,6 +2466,18 @@ packages: '@vitest/spy@5.0.0': resolution: {integrity: sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==} + '@zxing/browser@0.2.1': + resolution: {integrity: sha512-92pVfVDUXbc15xu9vIEmhNyqIEASMEkDF92CwT6T+7sg2EHXJRktH9CQKoQSXe51UtbNsj+Fm09H/JBWHMs/Sw==} + peerDependencies: + '@zxing/library': ^0.23.0 + + '@zxing/library@0.23.0': + resolution: {integrity: sha512-6fkkoFwP8CHxl6ugnPsj74PLJgX2iRv5zczGAyt5OBzQgxFhuhF0NCEc4t4OvSr8xAv2MRLlI0Iu9ZGDZQ2urA==} + engines: {node: '>= 24.0.0'} + + '@zxing/text-encoding@0.9.0': + resolution: {integrity: sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==} + accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} @@ -3330,6 +3351,9 @@ packages: picomatch: optional: true + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + figures@6.1.0: resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} engines: {node: '>=18'} @@ -4745,6 +4769,10 @@ packages: peerDependencies: typescript: '>=4.8.4' + ts-custom-error@3.3.1: + resolution: {integrity: sha512-5OX1tzOjxWEgsr/YEUWSuPrQ00deKLh6D7OTWcvNHm12/7QPyRh8SYpyWvA4IZv8H/+GQWQEh/kwo95Q9OVW1A==} + engines: {node: '>=14.0.0'} + ts-morph@26.0.0: resolution: {integrity: sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug==} @@ -7104,6 +7132,21 @@ snapshots: '@vitest/spy@5.0.0': {} + '@zxing/browser@0.2.1(@zxing/library@0.23.0)': + dependencies: + '@zxing/library': 0.23.0 + optionalDependencies: + '@zxing/text-encoding': 0.9.0 + + '@zxing/library@0.23.0': + dependencies: + ts-custom-error: 3.3.1 + optionalDependencies: + '@zxing/text-encoding': 0.9.0 + + '@zxing/text-encoding@0.9.0': + optional: true + accepts@2.0.0: dependencies: mime-types: 3.0.2 @@ -8094,6 +8137,8 @@ snapshots: optionalDependencies: picomatch: 4.0.7 + fflate@0.8.3: {} + figures@6.1.0: dependencies: is-unicode-supported: 2.1.0 @@ -9555,6 +9600,8 @@ snapshots: dependencies: typescript: 5.9.3 + ts-custom-error@3.3.1: {} + ts-morph@26.0.0: dependencies: '@ts-morph/common': 0.27.0 diff --git a/public/favicon.png b/public/favicon.png new file mode 100644 index 0000000..a7ff39a Binary files /dev/null and b/public/favicon.png differ diff --git a/public/icons/apple-touch-icon.png b/public/icons/apple-touch-icon.png new file mode 100644 index 0000000..cc0eb0a Binary files /dev/null and b/public/icons/apple-touch-icon.png differ diff --git a/public/icons/icon-192.png b/public/icons/icon-192.png new file mode 100644 index 0000000..4f65f83 Binary files /dev/null and b/public/icons/icon-192.png differ diff --git a/public/icons/icon-512.png b/public/icons/icon-512.png new file mode 100644 index 0000000..69dcaa8 Binary files /dev/null and b/public/icons/icon-512.png differ diff --git a/public/icons/icon-maskable-512.png b/public/icons/icon-maskable-512.png new file mode 100644 index 0000000..68eedb8 Binary files /dev/null and b/public/icons/icon-maskable-512.png differ diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest new file mode 100644 index 0000000..062a079 --- /dev/null +++ b/public/manifest.webmanifest @@ -0,0 +1,45 @@ +{ + "name": "Bricktrack", + "short_name": "Bricktrack", + "description": "Sammlung und Wunschlisten für Spielzeug-Sets", + "lang": "de", + "dir": "ltr", + "start_url": "/", + "scope": "/", + "display": "standalone", + "orientation": "portrait", + "background_color": "#ffffff", + "theme_color": "#0a0a0a", + "icons": [ + { + "src": "/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icons/icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ], + "shortcuts": [ + { + "name": "Fehlende Sets", + "short_name": "Fehlt noch", + "url": "/katalog?besitz=fehlt" + }, + { + "name": "Sammlung", + "short_name": "Sammlung", + "url": "/sammlung" + } + ] +} diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..b38cb19 --- /dev/null +++ b/public/sw.js @@ -0,0 +1,122 @@ +/** + * Bricktrack service worker. + * + * Goal: the catalog stays readable in the cellar, in the shop, and wherever + * else the phone loses its connection. Anything that changes data still needs + * the network - a wish reserved offline would be a lie. + * + * Caches are versioned; bumping VERSION drops the old ones on activation. + */ +const VERSION = 'v1'; +const PAGE_CACHE = `bricktrack-pages-${VERSION}`; +const ASSET_CACHE = `bricktrack-assets-${VERSION}`; +const IMAGE_CACHE = `bricktrack-images-${VERSION}`; +const OFFLINE_URL = '/offline'; + +self.addEventListener('install', (event) => { + event.waitUntil( + caches + .open(PAGE_CACHE) + .then((cache) => cache.addAll([OFFLINE_URL, '/manifest.webmanifest'])) + .then(() => self.skipWaiting()), + ); +}); + +self.addEventListener('activate', (event) => { + const keep = new Set([PAGE_CACHE, ASSET_CACHE, IMAGE_CACHE]); + + event.waitUntil( + caches + .keys() + .then((keys) => Promise.all(keys.filter((key) => !keep.has(key)).map((key) => caches.delete(key)))) + .then(() => self.clients.claim()), + ); +}); + +/** + * Signing out must not leave readable pages behind on a shared device, so the + * app tells the worker to drop everything it holds. + */ +self.addEventListener('message', (event) => { + if (event.data === 'bricktrack:clear-cache') { + event.waitUntil(caches.keys().then((keys) => Promise.all(keys.map((key) => caches.delete(key))))); + } +}); + +/** Long-lived, content-addressed build output. */ +function isStaticAsset(url) { + return url.pathname.startsWith('/_next/static/') || url.pathname.startsWith('/icons/'); +} + +/** Set pictures; their file names contain a UUID and never change. */ +function isCacheableImage(url) { + return url.pathname.startsWith('/api/files/images/'); +} + +/** Everything that must never be answered from a cache. */ +function isNeverCached(url) { + return ( + url.pathname.startsWith('/api/auth/') || + url.pathname.startsWith('/api/export/') || + url.pathname.startsWith('/api/health') + ); +} + +async function cacheFirst(request, cacheName) { + const cache = await caches.open(cacheName); + const hit = await cache.match(request); + if (hit) return hit; + + const response = await fetch(request); + if (response.ok) cache.put(request, response.clone()); + return response; +} + +/** + * Network first, cache as a fallback: the catalog is always current when there + * is a connection, and still readable when there is not. + */ +async function networkFirst(request, cacheName) { + const cache = await caches.open(cacheName); + + try { + const response = await fetch(request); + if (response.ok) cache.put(request, response.clone()); + return response; + } catch (error) { + const hit = await cache.match(request); + if (hit) return hit; + + if (request.mode === 'navigate') { + const offline = await cache.match(OFFLINE_URL); + if (offline) return offline; + } + throw error; + } +} + +self.addEventListener('fetch', (event) => { + const { request } = event; + + // Only plain GETs are safe to serve from a cache. + if (request.method !== 'GET') return; + + const url = new URL(request.url); + if (url.origin !== self.location.origin) return; + if (isNeverCached(url)) return; + + if (isStaticAsset(url)) { + event.respondWith(cacheFirst(request, ASSET_CACHE)); + return; + } + + if (isCacheableImage(url)) { + event.respondWith(cacheFirst(request, IMAGE_CACHE)); + return; + } + + // Page loads and the RSC payloads behind client-side navigation. + if (request.mode === 'navigate' || url.searchParams.has('_rsc')) { + event.respondWith(networkFirst(request, PAGE_CACHE)); + } +}); diff --git a/scripts/e2e-prepare.mjs b/scripts/e2e-prepare.mjs new file mode 100644 index 0000000..a0aff46 --- /dev/null +++ b/scripts/e2e-prepare.mjs @@ -0,0 +1,22 @@ +/** + * Prepares the smoke run. + * + * The tests run against the production image, not the dev server: the dev + * server compiles routes on demand and, under the load of a full run, would + * occasionally drop a chunk mid-navigation. Testing the artefact that people + * actually deploy is also simply more useful. + */ +import { execFileSync } from 'node:child_process'; +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 }); +fs.mkdirSync(path.join(dataDir, 'db'), { recursive: true }); +fs.mkdirSync(path.join(dataDir, 'files'), { recursive: true }); +console.log(`[e2e] ${dataDir} zurueckgesetzt.`); + +console.log('[e2e] Image wird gebaut (bricktrack:e2e) …'); +execFileSync('docker', ['build', '-t', 'bricktrack:e2e', '.'], { stdio: 'inherit' }); +console.log('[e2e] Image ist bereit.'); diff --git a/scripts/generate-icons.mjs b/scripts/generate-icons.mjs new file mode 100644 index 0000000..3846f19 --- /dev/null +++ b/scripts/generate-icons.mjs @@ -0,0 +1,67 @@ +/** + * Renders the app icons from one SVG source. + * + * Run with `node scripts/generate-icons.mjs` after changing the mark; the + * results are committed so neither the build nor the container needs to draw + * anything at runtime. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import sharp from 'sharp'; + +const OUT_DIR = path.resolve(process.cwd(), 'public/icons'); + +// Four studs on a rounded plate - readable down to 32px, which a detailed +// brick drawing would not be. +const mark = (background, foreground) => ` + + + + + + + + +`; + +/** + * A maskable icon has to survive being cropped to a circle, so the mark sits + * on a full-bleed background with generous padding. + */ +const maskable = (background, foreground) => ` + + + + + + + + +`; + +const DARK = '#0a0a0a'; +const LIGHT = '#fafafa'; + +const targets = [ + { file: 'icon-192.png', size: 192, svg: mark(DARK, LIGHT) }, + { file: 'icon-512.png', size: 512, svg: mark(DARK, LIGHT) }, + { file: 'icon-maskable-512.png', size: 512, svg: maskable(DARK, LIGHT) }, + { file: 'apple-touch-icon.png', size: 180, svg: mark(DARK, LIGHT) }, +]; + +fs.mkdirSync(OUT_DIR, { recursive: true }); + +for (const target of targets) { + await sharp(Buffer.from(target.svg)) + .resize(target.size, target.size) + .png({ compressionLevel: 9 }) + .toFile(path.join(OUT_DIR, target.file)); + console.log(`[icons] ${target.file} (${target.size}px)`); +} + +// The favicon keeps the same mark so the browser tab matches the home screen. +await sharp(Buffer.from(mark(DARK, LIGHT))) + .resize(32, 32) + .png({ compressionLevel: 9 }) + .toFile(path.resolve(process.cwd(), 'public/favicon.png')); +console.log('[icons] favicon.png (32px)'); diff --git a/scripts/reset-e2e.mjs b/scripts/reset-e2e.mjs deleted file mode 100644 index 8a11602..0000000 --- a/scripts/reset-e2e.mjs +++ /dev/null @@ -1,13 +0,0 @@ -/** - * 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/src/app/(app)/katalog/[id]/page.tsx b/src/app/(app)/katalog/[id]/page.tsx index 769ffb0..ac2add6 100644 --- a/src/app/(app)/katalog/[id]/page.tsx +++ b/src/app/(app)/katalog/[id]/page.tsx @@ -18,6 +18,7 @@ import { listInstructionsForSet, listPhotosForSet, } from '@/lib/queries/catalog'; +import { listMissingParts } from '@/lib/queries/collection'; import { listLocations } from '@/lib/queries/master-data'; import { listWishlistsForSet, listWishlistTargets } from '@/lib/queries/wishlists'; import { requireUser } from '@/lib/session'; @@ -45,6 +46,8 @@ export default async function SetDetailPage({ params }: Props) { const collectionItems = listCollectionItemsForSet(set.id).map((item) => ({ ...item, locationName: item.locationId ? (locationNames.get(item.locationId) ?? null) : null, + setName: `${set.setNumber} · ${set.name}`, + missingParts: listMissingParts(item.id), })); const facts: Array<[string, string]> = [ diff --git a/src/app/(app)/katalog/import-actions.ts b/src/app/(app)/katalog/import-actions.ts new file mode 100644 index 0000000..c8b9aee --- /dev/null +++ b/src/app/(app)/katalog/import-actions.ts @@ -0,0 +1,237 @@ +'use server'; + +import { revalidatePath } from 'next/cache'; +import { and, eq } from 'drizzle-orm'; +import { db } from '@/db'; +import { instruction, manufacturer, set, theme } from '@/db/schema'; +import { actionData, actionError, type ActionResult } from '@/lib/action-result'; +import { + fetchImageBytes, + fetchMinifigCount, + fetchSet, + fetchTheme, + isRebrickableEnabled, + legoInstructionsUrl, + RebrickableError, +} from '@/lib/rebrickable'; +import { requireUser } from '@/lib/session'; +import { uniqueSlug } from '@/lib/slug'; +import { storeImageBytes, UploadError } from '@/lib/uploads'; + +/** + * Import of a Lego set from Rebrickable. + * + * Runs entirely on the server, creates the manufacturer and the theme if they + * are missing, and stores the picture locally so the catalog keeps working + * when Rebrickable changes it. + */ + +const LEGO_SLUG = 'lego'; + +function now() { + return new Date(); +} + +/** Returns the Lego manufacturer, creating it on first import. */ +function ensureLegoManufacturer(): string { + const existing = db + .select({ id: manufacturer.id }) + .from(manufacturer) + .where(eq(manufacturer.slug, LEGO_SLUG)) + .get(); + if (existing) return existing.id; + + const taken = db.select({ slug: manufacturer.slug }).from(manufacturer).all(); + const id = crypto.randomUUID(); + + db.insert(manufacturer) + .values({ + id, + name: 'Lego', + slug: uniqueSlug('Lego', taken.map((row) => row.slug)), + website: 'https://www.lego.com/de-de', + createdAt: now(), + updatedAt: now(), + }) + .run(); + + return id; +} + +/** + * Maps a Rebrickable theme onto a local one, creating it (and its parent) when + * needed. Rebrickable nests themes deeply; one level of parent is enough to + * keep "Technic > Cars" readable without importing the whole tree. + */ +async function ensureTheme(manufacturerId: string, themeId: number | null): Promise { + if (themeId === null) return null; + + const remote = await fetchTheme(themeId); + if (!remote) return null; + + const existing = db + .select({ id: theme.id }) + .from(theme) + .where(and(eq(theme.manufacturerId, manufacturerId), eq(theme.name, remote.name))) + .get(); + if (existing) return existing.id; + + let parentThemeId: string | null = null; + if (remote.parentId !== null) { + const parent = await fetchTheme(remote.parentId); + if (parent) { + const existingParent = db + .select({ id: theme.id }) + .from(theme) + .where(and(eq(theme.manufacturerId, manufacturerId), eq(theme.name, parent.name))) + .get(); + + if (existingParent) { + parentThemeId = existingParent.id; + } else { + parentThemeId = crypto.randomUUID(); + db.insert(theme) + .values({ + id: parentThemeId, + manufacturerId, + name: parent.name, + createdAt: now(), + updatedAt: now(), + }) + .run(); + } + } + } + + const id = crypto.randomUUID(); + db.insert(theme) + .values({ + id, + manufacturerId, + name: remote.name, + parentThemeId, + createdAt: now(), + updatedAt: now(), + }) + .run(); + + return id; +} + +export type ImportResult = { + setId: string; + setNumber: string; + name: string; + imported: { + image: boolean; + theme: boolean; + minifigs: boolean; + }; +}; + +export async function importSetFromRebrickable( + rawSetNumber: unknown, +): Promise> { + await requireUser(); + + if (!isRebrickableEnabled()) { + return actionError( + 'Der Import ist nicht eingerichtet. Trag REBRICKABLE_API_KEY in der .env ein und starte den Container neu.', + ); + } + + const setNumber = typeof rawSetNumber === 'string' ? rawSetNumber.trim() : ''; + if (!setNumber) return actionError('Bitte gib eine Setnummer ein.'); + if (setNumber.length > 40) return actionError('Das sieht nicht nach einer Setnummer aus.'); + + let remote; + try { + remote = await fetchSet(setNumber); + } catch (error) { + if (error instanceof RebrickableError) return actionError(error.message); + throw error; + } + + if (!remote) { + return actionError( + `Rebrickable kennt die Setnummer ${setNumber} nicht. Du kannst das Set von Hand anlegen.`, + ); + } + + const manufacturerId = ensureLegoManufacturer(); + + const duplicate = db + .select({ id: set.id }) + .from(set) + .where(and(eq(set.manufacturerId, manufacturerId), eq(set.setNumber, remote.setNumber))) + .get(); + if (duplicate) { + return actionError(`${remote.setNumber} ist schon im Katalog: ${remote.name}.`); + } + + const themeId = await ensureTheme(manufacturerId, remote.themeId); + const minifigCount = await fetchMinifigCount(remote.setNumber); + + const setId = crypto.randomUUID(); + + // Cache the picture locally; a failed download must not fail the import. + let imagePath: string | null = null; + if (remote.imageUrl) { + const bytes = await fetchImageBytes(remote.imageUrl); + if (bytes) { + try { + imagePath = (await storeImageBytes(bytes, 'set-image', setId)).filePath; + } catch (error) { + if (!(error instanceof UploadError)) throw error; + console.error('[import] Bild konnte nicht gespeichert werden', error); + } + } + } + + db.insert(set) + .values({ + id: setId, + manufacturerId, + themeId, + setNumber: remote.setNumber, + name: remote.name, + year: remote.year, + pieceCount: remote.pieceCount, + minifigCount, + imagePath, + // Keep the source URL as a fallback if the local file is ever lost. + imageUrl: imagePath ? null : remote.imageUrl, + externalIds: { rebrickable: `${remote.setNumber}-1` }, + createdAt: now(), + updatedAt: now(), + }) + .run(); + + // Rebrickable does not serve instruction files, so this points at Lego's + // own service page for the set number. + db.insert(instruction) + .values({ + id: crypto.randomUUID(), + setId, + kind: 'external_url', + url: legoInstructionsUrl(remote.setNumber), + label: 'Bauanleitung bei LEGO', + sortOrder: 0, + createdAt: now(), + }) + .run(); + + revalidatePath('/katalog'); + revalidatePath('/verwaltung/themen'); + + return actionData({ + setId, + setNumber: remote.setNumber, + name: remote.name, + imported: { + image: imagePath !== null, + theme: themeId !== null, + minifigs: minifigCount !== null, + }, + }); +} diff --git a/src/app/(app)/katalog/neu/page.tsx b/src/app/(app)/katalog/neu/page.tsx index 07b544a..8abdeab 100644 --- a/src/app/(app)/katalog/neu/page.tsx +++ b/src/app/(app)/katalog/neu/page.tsx @@ -1,8 +1,10 @@ import type { Metadata } from 'next'; import Link from 'next/link'; +import { RebrickableImport } from '@/components/katalog/rebrickable-import'; import { SetForm } from '@/components/katalog/set-form'; import { Button } from '@/components/ui/button'; import { listManufacturers, listThemes } from '@/lib/queries/master-data'; +import { isRebrickableEnabled } from '@/lib/rebrickable'; import { requireUser } from '@/lib/session'; export const metadata: Metadata = { title: 'Neues Set' }; @@ -21,12 +23,19 @@ export default async function NeuesSetPage({ searchParams }: { searchParams: Sea manufacturerId: row.manufacturerId, })); + const importEnabled = isRebrickableEnabled(); + if (manufacturers.length === 0) { return ( -
+

Neues Set

+ + {/* The Lego import creates its manufacturer itself, so it works even + on a completely empty instance. */} + +

- Jedes Set gehört zu einem Hersteller. Leg zuerst einen an – Lego, Playmobil, Cobi oder + Für ein Set von Hand brauchst du zuerst einen Hersteller – Lego, Playmobil, Cobi oder was auch immer im Kinderzimmer steht.

+ + +
+ + oder von Hand + +
+
); diff --git a/src/app/(app)/katalog/page.tsx b/src/app/(app)/katalog/page.tsx index 1098964..b3f7f4d 100644 --- a/src/app/(app)/katalog/page.tsx +++ b/src/app/(app)/katalog/page.tsx @@ -1,6 +1,7 @@ import type { Metadata } from 'next'; import Link from 'next/link'; import { Plus } from 'lucide-react'; +import { BarcodeScannerButton } from '@/components/katalog/barcode-scanner'; import { CatalogFilters } from '@/components/katalog/catalog-filters'; import { CatalogGrid } from '@/components/katalog/catalog-grid'; import { Pagination } from '@/components/katalog/pagination'; @@ -60,12 +61,15 @@ export default async function KatalogPage({ searchParams }: { searchParams: Sear : 'Alle bekannten Sets – unabhängig davon, ob sie schon da sind.'}

- +
+ + +
diff --git a/src/app/(app)/katalog/scan-actions.ts b/src/app/(app)/katalog/scan-actions.ts new file mode 100644 index 0000000..b07163e --- /dev/null +++ b/src/app/(app)/katalog/scan-actions.ts @@ -0,0 +1,37 @@ +'use server'; + +import { actionData, actionError, type ActionResult } from '@/lib/action-result'; +import { isValidEan } from '@/lib/ean'; +import { findSetByEan } from '@/lib/queries/catalog'; +import { requireUser } from '@/lib/session'; + +export type ScanLookup = + | { found: true; setId: string; setNumber: string; name: string } + | { found: false; ean: string }; + +/** + * Resolves a scanned barcode. + * + * Either the set is already in the catalog - then we jump straight to it - or + * it is not, and the caller opens the "new set" form with the number filled in. + */ +export async function lookupScannedEan(rawEan: unknown): Promise> { + await requireUser(); + + const ean = typeof rawEan === 'string' ? rawEan.replace(/\D/g, '') : ''; + if (!isValidEan(ean)) { + return actionError('Der Strichcode wurde nicht sauber erkannt. Bitte noch einmal scannen.'); + } + + const match = findSetByEan(ean); + if (match) { + return actionData({ + found: true as const, + setId: match.id, + setNumber: match.setNumber, + name: match.name, + }); + } + + return actionData({ found: false as const, ean }); +} diff --git a/src/app/(app)/sammlung/actions.ts b/src/app/(app)/sammlung/actions.ts index 3c1f5c5..a0b1ca3 100644 --- a/src/app/(app)/sammlung/actions.ts +++ b/src/app/(app)/sammlung/actions.ts @@ -1,9 +1,9 @@ 'use server'; import { revalidatePath } from 'next/cache'; -import { eq } from 'drizzle-orm'; +import { and, eq } from 'drizzle-orm'; import { db } from '@/db'; -import { collectionItem, set } from '@/db/schema'; +import { collectionItem, missingPart, set } from '@/db/schema'; import { actionData, actionError, @@ -13,7 +13,7 @@ import { } from '@/lib/action-result'; import { reconcileFulfilledWishlistItems } from '@/lib/queries/wishlists'; import { requireUser } from '@/lib/session'; -import { collectionItemSchema } from '@/lib/validation'; +import { collectionItemSchema, missingPartSchema } from '@/lib/validation'; import type { Condition } from '@/db/types'; function now() { @@ -103,3 +103,123 @@ export async function deleteCollectionItem(id: string): Promise { revalidateCollection(existing.setId); return actionOk(); } + +// --------------------------------------------------------------------------- +// Missing parts +// --------------------------------------------------------------------------- + +/** + * Keeps `completeness` honest without ever fighting the user. + * + * The moment a part is reported missing the item is not complete any more, and + * once the last open entry is resolved it is complete again. A deliberate + * "unknown" is left alone in the second direction only. + */ +function syncCompleteness(collectionItemId: string): void { + const open = db + .select({ id: missingPart.id }) + .from(missingPart) + .where(and(eq(missingPart.collectionItemId, collectionItemId), eq(missingPart.resolved, false))) + .all(); + + const item = db + .select({ completeness: collectionItem.completeness }) + .from(collectionItem) + .where(eq(collectionItem.id, collectionItemId)) + .get(); + if (!item) return; + + if (open.length > 0 && item.completeness !== 'missing_parts') { + db.update(collectionItem) + .set({ completeness: 'missing_parts', updatedAt: now() }) + .where(eq(collectionItem.id, collectionItemId)) + .run(); + return; + } + + if (open.length === 0 && item.completeness === 'missing_parts') { + db.update(collectionItem) + .set({ completeness: 'complete', updatedAt: now() }) + .where(eq(collectionItem.id, collectionItemId)) + .run(); + } +} + +function loadItemSetId(collectionItemId: string): string | null { + const row = db + .select({ setId: collectionItem.setId }) + .from(collectionItem) + .where(eq(collectionItem.id, collectionItemId)) + .get(); + return row?.setId ?? null; +} + +export async function addMissingPart( + collectionItemId: string, + input: unknown, +): Promise { + await requireUser(); + + const parsed = parseForm(missingPartSchema, input); + if (!parsed.ok) return parsed; + + const setId = loadItemSetId(collectionItemId); + if (!setId) return actionError('Diesen Sammlungseintrag gibt es nicht mehr.'); + + db.insert(missingPart) + .values({ + id: crypto.randomUUID(), + collectionItemId, + partNumber: parsed.data.partNumber, + colorName: parsed.data.colorName, + quantity: parsed.data.quantity, + resolved: false, + notes: parsed.data.notes, + createdAt: now(), + }) + .run(); + + syncCompleteness(collectionItemId); + revalidateCollection(setId); + return actionOk(); +} + +export async function setMissingPartResolved( + missingPartId: string, + resolved: boolean, +): Promise { + await requireUser(); + + const row = db + .select({ collectionItemId: missingPart.collectionItemId }) + .from(missingPart) + .where(eq(missingPart.id, missingPartId)) + .get(); + if (!row) return actionOk(); + + db.update(missingPart) + .set({ resolved }) + .where(eq(missingPart.id, missingPartId)) + .run(); + + syncCompleteness(row.collectionItemId); + revalidateCollection(loadItemSetId(row.collectionItemId) ?? undefined); + return actionOk(); +} + +export async function deleteMissingPart(missingPartId: string): Promise { + await requireUser(); + + const row = db + .select({ collectionItemId: missingPart.collectionItemId }) + .from(missingPart) + .where(eq(missingPart.id, missingPartId)) + .get(); + if (!row) return actionOk(); + + db.delete(missingPart).where(eq(missingPart.id, missingPartId)).run(); + + syncCompleteness(row.collectionItemId); + revalidateCollection(loadItemSetId(row.collectionItemId) ?? undefined); + return actionOk(); +} diff --git a/src/app/(app)/sammlung/page.tsx b/src/app/(app)/sammlung/page.tsx index fddc188..fe47006 100644 --- a/src/app/(app)/sammlung/page.tsx +++ b/src/app/(app)/sammlung/page.tsx @@ -10,6 +10,7 @@ import { completenessLabel, statusLabel } from '@/lib/labels'; import { formatEuro } from '@/lib/money'; import { countMissingSets, + countOpenMissingParts, getCollectionStats, listCollectionItems, type CollectionFilters as Filters, @@ -40,6 +41,7 @@ export default async function SammlungPage({ searchParams }: { searchParams: Sea }; const items = listCollectionItems(filters); + const openParts = countOpenMissingParts(items.map((item) => item.id)); const stats = getCollectionStats(); const missing = countMissingSets(); @@ -132,6 +134,9 @@ export default async function SammlungPage({ searchParams }: { searchParams: Sea {item.completeness !== 'unknown' ? ( {completenessLabel(item.completeness)} ) : null} + {openParts.get(item.id) ? ( + {openParts.get(item.id)} Fehlteile + ) : null} {item.acquiredAt ? ( seit {formatDate(item.acquiredAt)} diff --git a/src/app/(app)/verwaltung/daten/actions.ts b/src/app/(app)/verwaltung/daten/actions.ts new file mode 100644 index 0000000..7699fb5 --- /dev/null +++ b/src/app/(app)/verwaltung/daten/actions.ts @@ -0,0 +1,45 @@ +'use server'; + +import { revalidatePath } from 'next/cache'; +import { actionData, actionError, type ActionResult } from '@/lib/action-result'; +import { importArchive, ImportError, type ImportSummary } from '@/lib/export'; +import { requireAdmin } from '@/lib/session'; + +const MAX_ARCHIVE_BYTES = 512 * 1024 * 1024; + +/** Typed by hand before anything is deleted. */ +const CONFIRMATION = 'ERSETZEN'; + +export async function importArchiveAction( + formData: FormData, +): Promise> { + const admin = await requireAdmin(); + + if (formData.get('confirmation') !== CONFIRMATION) { + return actionError(`Bitte tipp ${CONFIRMATION} ein, um den Austausch zu bestätigen.`); + } + + const file = formData.get('archive'); + if (!(file instanceof File) || file.size === 0) { + return actionError('Bitte wähle ein Bricktrack-Archiv (.zip) aus.'); + } + if (file.size > MAX_ARCHIVE_BYTES) { + return actionError('Das Archiv ist größer als 512 MB und wird nicht eingelesen.'); + } + + try { + const summary = importArchive( + new Uint8Array(await file.arrayBuffer()), + admin.id, + ); + + revalidatePath('/', 'layout'); + return actionData(summary); + } catch (error) { + if (error instanceof ImportError) return actionError(error.message); + console.error('[import] Archiv konnte nicht eingelesen werden', error); + return actionError( + 'Das Archiv konnte nicht eingelesen werden. Die vorhandenen Daten wurden nicht verändert.', + ); + } +} diff --git a/src/app/(app)/verwaltung/daten/page.tsx b/src/app/(app)/verwaltung/daten/page.tsx new file mode 100644 index 0000000..ace9e61 --- /dev/null +++ b/src/app/(app)/verwaltung/daten/page.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from 'next'; +import { DataTransfer } from '@/components/verwaltung/data-transfer'; +import { requireAdmin } from '@/lib/session'; + +export const metadata: Metadata = { title: 'Daten' }; + +export default async function DatenPage() { + await requireAdmin(); + return ; +} diff --git a/src/app/(app)/verwaltung/einladungen/actions.ts b/src/app/(app)/verwaltung/einladungen/actions.ts new file mode 100644 index 0000000..ee9544a --- /dev/null +++ b/src/app/(app)/verwaltung/einladungen/actions.ts @@ -0,0 +1,44 @@ +'use server'; + +import { revalidatePath } from 'next/cache'; +import { z } from 'zod'; +import { actionData, actionOk, parseForm, type ActionResult } from '@/lib/action-result'; +import { createInvite, deleteInvite, INVITE_VALID_DAYS } from '@/lib/invites'; +import { requireAdmin } from '@/lib/session'; +import { optionalText } from '@/lib/validation'; + +const schema = z.object({ + // Optional: when set, the invitation only works for that address. + email: optionalText(160), + role: z.enum(['admin', 'member']).default('member'), + validDays: z.coerce.number().int().min(1).max(90).default(INVITE_VALID_DAYS), +}); + +export async function createInviteAction( + input: unknown, +): Promise> { + const admin = await requireAdmin(); + + const parsed = parseForm(schema, input); + if (!parsed.ok) return parsed; + + const created = createInvite({ + createdByUserId: admin.id, + email: parsed.data.email, + role: parsed.data.role, + validDays: parsed.data.validDays, + }); + + revalidatePath('/verwaltung/einladungen'); + // The plaintext token is returned once and never stored - only its hash is. + return actionData({ token: created.token }); +} + +export async function deleteInviteAction(id: string): Promise { + await requireAdmin(); + + deleteInvite(id); + + revalidatePath('/verwaltung/einladungen'); + return actionOk(); +} diff --git a/src/app/(app)/verwaltung/einladungen/page.tsx b/src/app/(app)/verwaltung/einladungen/page.tsx new file mode 100644 index 0000000..f342e44 --- /dev/null +++ b/src/app/(app)/verwaltung/einladungen/page.tsx @@ -0,0 +1,22 @@ +import type { Metadata } from 'next'; +import { InviteList, type InviteListRow } from '@/components/verwaltung/invite-list'; +import { inviteState, listInvites } from '@/lib/invites'; +import { requireAdmin } from '@/lib/session'; + +export const metadata: Metadata = { title: 'Einladungen' }; + +export default async function EinladungenPage() { + await requireAdmin(); + + const invites: InviteListRow[] = listInvites().map((row) => ({ + id: row.id, + email: row.email, + role: row.role, + expiresAt: row.expiresAt, + usedAt: row.usedAt, + usedByName: row.usedByName, + state: inviteState(row), + })); + + return ; +} diff --git a/src/app/(app)/verwaltung/layout.tsx b/src/app/(app)/verwaltung/layout.tsx index 7a57af8..ae01e31 100644 --- a/src/app/(app)/verwaltung/layout.tsx +++ b/src/app/(app)/verwaltung/layout.tsx @@ -5,7 +5,7 @@ import { requireUser } from '@/lib/session'; export const metadata: Metadata = { title: 'Verwaltung' }; export default async function VerwaltungLayout({ children }: { children: React.ReactNode }) { - await requireUser(); + const user = await requireUser(); return (
@@ -15,7 +15,7 @@ export default async function VerwaltungLayout({ children }: { children: React.R Stammdaten, die im Katalog und in der Sammlung ausgewählt werden.

- + {children} ); diff --git a/src/app/(auth)/einladung/[token]/actions.ts b/src/app/(auth)/einladung/[token]/actions.ts new file mode 100644 index 0000000..3f39573 --- /dev/null +++ b/src/app/(auth)/einladung/[token]/actions.ts @@ -0,0 +1,63 @@ +'use server'; + +import { headers } from 'next/headers'; +import { z } from 'zod'; +import { actionError, actionOk, parseForm, type ActionResult } from '@/lib/action-result'; +import { auth } from '@/lib/auth'; +import { findUsableInvite, markInviteUsed, withInviteGrant } from '@/lib/invites'; + +const schema = z.object({ + token: z.string().trim().min(1), + name: z.string().trim().min(1, 'Bitte gib deinen Namen an.').max(80), + email: z.email('Bitte gib eine gültige E-Mail-Adresse an.'), + password: z.string().min(10, 'Das Passwort braucht mindestens 10 Zeichen.'), +}); + +/** + * Registration through an invitation link. + * + * The token is checked here, on the server, and only then is the sign-up + * wrapped in a grant that the user-create hook can see. The role comes from + * the invitation, never from the submitted form. + */ +export async function registerWithInvite(input: unknown): Promise { + const parsed = parseForm(schema, input); + if (!parsed.ok) return parsed; + + const invitation = findUsableInvite(parsed.data.token); + if (!invitation) { + return actionError('Diese Einladung ist abgelaufen oder wurde bereits verwendet.'); + } + + if (invitation.email && invitation.email.toLowerCase() !== parsed.data.email.toLowerCase()) { + return actionError(`Diese Einladung gilt für ${invitation.email}.`); + } + + const requestHeaders = await headers(); + + try { + const result = await withInviteGrant( + { inviteId: invitation.id, role: invitation.role }, + () => + auth.api.signUpEmail({ + body: { + name: parsed.data.name, + email: parsed.data.email, + password: parsed.data.password, + }, + headers: requestHeaders, + }), + ); + + markInviteUsed(invitation.id, result.user.id); + } catch (error) { + const message = error instanceof Error ? error.message : ''; + if (message.toLowerCase().includes('exist')) { + return actionError('Zu dieser E-Mail-Adresse gibt es schon ein Konto.'); + } + console.error('[invite] Registrierung fehlgeschlagen', error); + return actionError('Die Registrierung hat nicht geklappt. Bitte versuch es noch einmal.'); + } + + return actionOk(); +} diff --git a/src/app/(auth)/einladung/[token]/page.tsx b/src/app/(auth)/einladung/[token]/page.tsx new file mode 100644 index 0000000..ef65089 --- /dev/null +++ b/src/app/(auth)/einladung/[token]/page.tsx @@ -0,0 +1,54 @@ +import type { Metadata } from 'next'; +import Link from 'next/link'; +import { redirect } from 'next/navigation'; +import { InviteForm } from '@/components/auth/invite-form'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { formatDate } from '@/lib/format'; +import { findUsableInvite } from '@/lib/invites'; +import { getSession } from '@/lib/session'; + +export const metadata: Metadata = { + title: 'Einladung', + robots: { index: false, follow: false }, +}; + +export default async function InvitePage({ params }: { params: Promise<{ token: string }> }) { + if (await getSession()) redirect('/'); + + const { token } = await params; + const invitation = findUsableInvite(token); + + if (!invitation) { + return ( + + + Einladung ungültig + + Dieser Link ist abgelaufen oder wurde schon benutzt. Lass dir einfach einen neuen + schicken. + + + + + Zur Anmeldung + + + + ); + } + + return ( + + + Konto anlegen + + Du wurdest zu Bricktrack eingeladen. Der Link gilt noch bis zum{' '} + {formatDate(invitation.expiresAt)}. + + + + + + + ); +} diff --git a/src/app/api/export/[kind]/route.ts b/src/app/api/export/[kind]/route.ts new file mode 100644 index 0000000..8207855 --- /dev/null +++ b/src/app/api/export/[kind]/route.ts @@ -0,0 +1,106 @@ +import { NextResponse } from 'next/server'; +import { + buildCollectionCsv, + buildExportPayload, + buildExportZip, + exportFileName, +} from '@/lib/export'; +import { formatDate } from '@/lib/format'; +import { conditionLabel, completenessLabel, statusLabel } from '@/lib/labels'; +import { centsToInput } from '@/lib/money'; +import { listCollectionItems } from '@/lib/queries/collection'; +import { getCurrentUser } from '@/lib/session'; + +export const dynamic = 'force-dynamic'; + +const CSV_COLUMNS = [ + { key: 'setNumber', label: 'Setnummer' }, + { key: 'setName', label: 'Name' }, + { key: 'manufacturer', label: 'Hersteller' }, + { key: 'theme', label: 'Thema' }, + { key: 'year', label: 'Jahr' }, + { key: 'pieceCount', label: 'Teile' }, + { key: 'status', label: 'Status' }, + { key: 'completeness', label: 'Vollständigkeit' }, + { key: 'condition', label: 'Zustand' }, + { key: 'location', label: 'Standort' }, + { key: 'acquiredAt', label: 'Im Besitz seit' }, + { key: 'acquiredPrice', label: 'Kaufpreis (EUR)' }, + { key: 'retailPrice', label: 'UVP (EUR)' }, + { key: 'acquiredFrom', label: 'Gekauft bei' }, + { key: 'giftedBy', label: 'Geschenkt von' }, + { key: 'notes', label: 'Notizen' }, +]; + +/** + * Downloads. + * + * The full archive is for admins - it contains the whole collection. The CSV + * of the collection is available to every signed-in member. + */ +export async function GET( + _request: Request, + context: { params: Promise<{ kind: string }> }, +) { + const user = await getCurrentUser(); + if (!user) return new NextResponse('Nicht angemeldet', { status: 401 }); + + const { kind } = await context.params; + + if (kind === 'csv') { + const rows = listCollectionItems({}).map((item) => ({ + setNumber: item.setNumber, + setName: item.setName, + manufacturer: item.manufacturerName, + theme: item.themeName, + year: item.year, + pieceCount: item.pieceCount, + status: statusLabel(item.status), + completeness: completenessLabel(item.completeness), + condition: conditionLabel(item.condition), + location: item.locationName, + acquiredAt: item.acquiredAt ? formatDate(item.acquiredAt) : '', + acquiredPrice: centsToInput(item.acquiredPriceCents), + retailPrice: centsToInput(item.retailPriceCents), + acquiredFrom: item.acquiredFrom, + giftedBy: item.giftedBy, + notes: item.notes, + })); + + return new NextResponse(buildCollectionCsv(rows, CSV_COLUMNS), { + headers: { + 'Content-Type': 'text/csv; charset=utf-8', + 'Content-Disposition': `attachment; filename="${exportFileName('csv')}"`, + 'Cache-Control': 'no-store', + }, + }); + } + + if (user.role !== 'admin') { + return new NextResponse('Nur Administratoren dürfen die Daten exportieren', { status: 403 }); + } + + if (kind === 'json') { + return new NextResponse(JSON.stringify(buildExportPayload(), null, 2), { + headers: { + 'Content-Type': 'application/json; charset=utf-8', + 'Content-Disposition': `attachment; filename="${exportFileName('json')}"`, + 'Cache-Control': 'no-store', + }, + }); + } + + if (kind === 'zip') { + const archive = buildExportZip(); + return new NextResponse(archive as unknown as BodyInit, { + headers: { + 'Content-Type': 'application/zip', + 'Content-Length': String(archive.byteLength), + 'Content-Disposition': `attachment; filename="${exportFileName('zip')}"`, + 'Cache-Control': 'no-store', + }, + }); + } + + return new NextResponse('Unbekanntes Format', { status: 404 }); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 2e04487..e8c1ba4 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,4 +1,5 @@ import type { Metadata, Viewport } from 'next'; +import { ServiceWorkerRegistration } from '@/components/layout/service-worker'; import { Toaster } from '@/components/ui/sonner'; import './globals.css'; @@ -9,6 +10,16 @@ export const metadata: Metadata = { }, description: 'Sammlung und Wunschlisten für Spielzeug-Sets', applicationName: 'Bricktrack', + manifest: '/manifest.webmanifest', + icons: { + icon: [{ url: '/favicon.png', type: 'image/png', sizes: '32x32' }], + apple: [{ url: '/icons/apple-touch-icon.png', sizes: '180x180' }], + }, + appleWebApp: { + capable: true, + title: 'Bricktrack', + statusBarStyle: 'default', + }, // 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 }, @@ -30,6 +41,7 @@ export default function RootLayout({ children }: Readonly<{ children: React.Reac {children} + ); diff --git a/src/app/offline/page.tsx b/src/app/offline/page.tsx new file mode 100644 index 0000000..cbf65fd --- /dev/null +++ b/src/app/offline/page.tsx @@ -0,0 +1,28 @@ +import type { Metadata } from 'next'; +import { CloudOff } from 'lucide-react'; + +export const metadata: Metadata = { + title: 'Offline', + robots: { index: false, follow: false }, +}; + +/** + * Shown when a page is requested that the device has never loaded before and + * there is no connection. Pages that were visited once come from the cache + * instead of landing here. + */ +export default function OfflinePage() { + return ( +
+ +

Gerade offline

+

+ Diese Seite war noch nie geladen und lässt sich ohne Verbindung nicht anzeigen. Seiten, + die du schon einmal geöffnet hast – zum Beispiel der Katalog – funktionieren weiter. +

+

+ Sobald wieder Netz da ist, aktualisiert sich alles von selbst. +

+
+ ); +} diff --git a/src/components/auth/invite-form.tsx b/src/components/auth/invite-form.tsx new file mode 100644 index 0000000..27a4321 --- /dev/null +++ b/src/components/auth/invite-form.tsx @@ -0,0 +1,83 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { useState, useTransition } from 'react'; +import { Loader2 } from 'lucide-react'; +import { toast } from 'sonner'; +import { registerWithInvite } from '@/app/(auth)/einladung/[token]/actions'; +import { Button } from '@/components/ui/button'; +import { Field } from '@/components/ui/field'; +import { Input } from '@/components/ui/input'; + +const MIN_PASSWORD_LENGTH = 10; + +export function InviteForm({ token, email }: { token: string; email: string | null }) { + const router = useRouter(); + const [pending, startTransition] = useTransition(); + const [password, setPassword] = useState(''); + + function onSubmit(event: React.FormEvent) { + event.preventDefault(); + const input = Object.fromEntries(new FormData(event.currentTarget).entries()); + + startTransition(async () => { + const result = await registerWithInvite({ ...input, token }); + if (!result.ok) { + toast.error(result.error); + return; + } + toast.success('Konto angelegt. Willkommen bei Bricktrack!'); + router.push('/'); + router.refresh(); + }); + } + + return ( +
+ + + + + + + + + + setPassword(event.target.value)} + /> + + + +
+ ); +} diff --git a/src/components/katalog/barcode-scanner.tsx b/src/components/katalog/barcode-scanner.tsx new file mode 100644 index 0000000..f5fa78f --- /dev/null +++ b/src/components/katalog/barcode-scanner.tsx @@ -0,0 +1,199 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Loader2, ScanLine } from 'lucide-react'; +import { toast } from 'sonner'; +import { lookupScannedEan } from '@/app/(app)/katalog/scan-actions'; +import { formatEan, isValidEan } from '@/lib/ean'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; + +type Controls = { stop: () => void }; + +/** + * Scans the barcode on a box with the phone camera. + * + * Decoding runs in the browser with zxing, which is bundled with the app and + * loaded only when the scanner is opened - nothing leaves the device, and the + * catalog pages stay light for everyone who never scans. + */ +export function BarcodeScannerButton() { + const router = useRouter(); + const videoRef = useRef(null); + const controlsRef = useRef(null); + const handledRef = useRef(false); + + const [open, setOpen] = useState(false); + const [status, setStatus] = useState<'starting' | 'scanning' | 'checking' | 'error'>('starting'); + const [message, setMessage] = useState(null); + + const stop = useCallback(() => { + controlsRef.current?.stop(); + controlsRef.current = null; + }, []); + + const onDetected = useCallback( + (raw: string) => { + if (handledRef.current) return; + if (!isValidEan(raw)) return; // keep scanning on a misread + + handledRef.current = true; + stop(); + setStatus('checking'); + + void lookupScannedEan(raw).then((result) => { + if (!result.ok) { + toast.error(result.error); + setOpen(false); + return; + } + + setOpen(false); + + if (result.data.found) { + toast.success(`${result.data.setNumber} · ${result.data.name}`); + router.push(`/katalog/${result.data.setId}`); + } else { + toast.info(`${formatEan(result.data.ean)} ist noch nicht im Katalog.`); + router.push(`/katalog/neu?ean=${encodeURIComponent(result.data.ean)}`); + } + router.refresh(); + }); + }, + [router, stop], + ); + + useEffect(() => { + if (!open) return; + + handledRef.current = false; + setStatus('starting'); + setMessage(null); + + // The camera needs a secure context - same rule as for passkeys. + if (!window.isSecureContext) { + setStatus('error'); + setMessage( + 'Die Kamera funktioniert nur über HTTPS. Ruf Bricktrack über deine Domain auf, nicht über die IP-Adresse.', + ); + return; + } + + let cancelled = false; + + void (async () => { + try { + const { BrowserMultiFormatReader } = await import('@zxing/browser'); + const reader = new BrowserMultiFormatReader(); + const video = videoRef.current; + if (!video || cancelled) return; + + const controls = await reader.decodeFromConstraints( + // The rear camera is the one pointing at the box. + { video: { facingMode: 'environment' } }, + video, + (result) => { + if (result) onDetected(result.getText()); + }, + ); + + if (cancelled) { + controls.stop(); + return; + } + + controlsRef.current = controls; + setStatus('scanning'); + } catch (error) { + if (cancelled) return; + console.error('[scan] Kamera konnte nicht gestartet werden', error); + setStatus('error'); + + const name = (error as { name?: string })?.name; + setMessage( + name === 'NotAllowedError' + ? 'Der Zugriff auf die Kamera wurde abgelehnt. Erlaube ihn in den Browser-Einstellungen und versuch es noch einmal.' + : name === 'NotFoundError' + ? 'Dieses Gerät hat keine nutzbare Kamera.' + : 'Die Kamera lässt sich nicht starten.', + ); + } + })(); + + return () => { + cancelled = true; + stop(); + }; + }, [open, onDetected, stop]); + + return ( + <> + + + { + if (!next) stop(); + setOpen(next); + }} + > + + + Barcode scannen + + Halte den Strichcode auf der Packung ins Bild. Kennt der Katalog das Set schon, + landest du direkt darauf – sonst im Formular mit ausgefüllter EAN. + + + + {status === 'error' ? ( +

+ {message} +

+ ) : ( +
+
+ + ); +} diff --git a/src/components/katalog/rebrickable-import.tsx b/src/components/katalog/rebrickable-import.tsx new file mode 100644 index 0000000..ff0ce18 --- /dev/null +++ b/src/components/katalog/rebrickable-import.tsx @@ -0,0 +1,95 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { useState, useTransition } from 'react'; +import { Download, Loader2 } from 'lucide-react'; +import { toast } from 'sonner'; +import { importSetFromRebrickable } from '@/app/(app)/katalog/import-actions'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Field } from '@/components/ui/field'; +import { Input } from '@/components/ui/input'; + +/** + * One-field Lego import: type the number from the box, get a finished catalog + * entry. Hidden entirely when no API key is configured. + */ +export function RebrickableImport({ enabled }: { enabled: boolean }) { + const router = useRouter(); + const [setNumber, setSetNumber] = useState(''); + const [pending, startTransition] = useTransition(); + + if (!enabled) { + return ( + + + Lego-Import + + Mit einem Rebrickable-API-Key füllt sich das Formular von selbst: Setnummer eingeben, + und Name, Jahr, Teile, Thema und Bild kommen automatisch. Dafür{' '} + REBRICKABLE_API_KEY in die{' '} + .env eintragen und den Container neu + starten. + + + + ); + } + + function submit(event: React.FormEvent) { + event.preventDefault(); + + startTransition(async () => { + const result = await importSetFromRebrickable(setNumber); + if (!result.ok) { + toast.error(result.error); + return; + } + + const missing = [ + result.data.imported.image ? null : 'Bild', + result.data.imported.theme ? null : 'Thema', + ].filter(Boolean); + + toast.success( + missing.length === 0 + ? `${result.data.setNumber} „${result.data.name}“ übernommen.` + : `${result.data.setNumber} übernommen – ohne ${missing.join(' und ')}.`, + ); + + router.push(`/katalog/${result.data.setId}`); + router.refresh(); + }); + } + + return ( + + + Lego-Import + + Setnummer von der Packung eintippen – Name, Jahr, Teile, Minifiguren, Thema und Bild + werden übernommen. Das Bild wird lokal gespeichert. + + + +
+ + setSetNumber(event.target.value)} + placeholder="60398" + inputMode="numeric" + maxLength={40} + className="font-mono" + /> + + +
+
+
+ ); +} diff --git a/src/components/layout/service-worker.tsx b/src/components/layout/service-worker.tsx new file mode 100644 index 0000000..cf402b2 --- /dev/null +++ b/src/components/layout/service-worker.tsx @@ -0,0 +1,38 @@ +'use client'; + +import { useEffect } from 'react'; + +/** + * Registers the service worker. + * + * Only in production: in development the worker would serve stale chunks and + * turn every edit into a puzzle. + */ +export function ServiceWorkerRegistration() { + useEffect(() => { + if (process.env.NODE_ENV !== 'production') return; + if (!('serviceWorker' in navigator)) return; + + const register = () => { + void navigator.serviceWorker.register('/sw.js').catch((error) => { + console.error('[pwa] Service Worker konnte nicht registriert werden', error); + }); + }; + + // Waiting for load keeps the worker from competing with the first render. + if (document.readyState === 'complete') register(); + else window.addEventListener('load', register, { once: true }); + + return () => window.removeEventListener('load', register); + }, []); + + return null; +} + +/** Drops every cached page - called on sign-out. */ +export async function clearOfflineCaches(): Promise { + if (!('serviceWorker' in navigator)) return; + + const registration = await navigator.serviceWorker.getRegistration(); + registration?.active?.postMessage('bricktrack:clear-cache'); +} diff --git a/src/components/layout/user-menu.tsx b/src/components/layout/user-menu.tsx index 8aa10df..14a6760 100644 --- a/src/components/layout/user-menu.tsx +++ b/src/components/layout/user-menu.tsx @@ -3,6 +3,7 @@ import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { LogOut, Settings, UserRound } from 'lucide-react'; +import { clearOfflineCaches } from '@/components/layout/service-worker'; import { authClient } from '@/lib/auth-client'; import { Button } from '@/components/ui/button'; import { @@ -20,6 +21,8 @@ export function UserMenu({ user }: { user: SessionUser }) { async function signOut() { await authClient.signOut(); + // Cached pages must not stay readable on a shared device. + await clearOfflineCaches(); router.push('/anmelden'); router.refresh(); } diff --git a/src/components/sammlung/collection-items-section.tsx b/src/components/sammlung/collection-items-section.tsx index 5a40f69..5128f31 100644 --- a/src/components/sammlung/collection-items-section.tsx +++ b/src/components/sammlung/collection-items-section.tsx @@ -2,9 +2,11 @@ import { useRouter } from 'next/navigation'; import { useState, useTransition } from 'react'; -import { Loader2, Pencil, Trash2 } from 'lucide-react'; +import { Loader2, Pencil, PuzzleIcon, Trash2 } from 'lucide-react'; import { toast } from 'sonner'; import { deleteCollectionItem, updateCollectionItem } from '@/app/(app)/sammlung/actions'; +import type { MissingPartRow } from '@/lib/queries/collection'; +import { MissingPartsDialog } from './missing-parts-dialog'; import type { CollectionStatus, Completeness, Condition } from '@/db/types'; import { formatDate, toDateInputValue } from '@/lib/format'; import { @@ -54,6 +56,8 @@ export type CollectionItemRow = { giftedBy: string | null; lentOutTo: string | null; notes: string | null; + setName: string; + missingParts: MissingPartRow[]; }; export function CollectionItemsSection({ @@ -67,6 +71,11 @@ export function CollectionItemsSection({ const [pending, startTransition] = useTransition(); const [editing, setEditing] = useState(null); const [deleting, setDeleting] = useState(null); + // Only the id, never a snapshot: the dialog shows a live list, and a copy + // taken when it opened would not show a part that was just added. + const [partsItemId, setPartsItemId] = useState(null); + + const partsItem = items.find((item) => item.id === partsItemId) ?? null; function submit(event: React.FormEvent) { event.preventDefault(); @@ -121,6 +130,11 @@ export function CollectionItemsSection({ {statusLabel(item.status)} {completenessLabel(item.completeness)} + {item.missingParts.some((part) => !part.resolved) ? ( + + {item.missingParts.filter((part) => !part.resolved).length} Fehlteile + + ) : null} {item.locationName ? ( {item.locationName} ) : null} @@ -140,6 +154,16 @@ export function CollectionItemsSection({

{item.notes ?

{item.notes}

: null} + + + ))} + + ) : ( +

+ Noch nichts vermerkt. Trag hier ein, was beim Aufbauen fehlt – dann weißt du beim + Nachbestellen, worum es geht. +

+ )} + +
+

Fehlteil hinzufügen

+ +
+ + + + + + + + + +
+ + + + + + +
+ + + + + + + ); +} diff --git a/src/components/verwaltung/data-transfer.tsx b/src/components/verwaltung/data-transfer.tsx new file mode 100644 index 0000000..47537d5 --- /dev/null +++ b/src/components/verwaltung/data-transfer.tsx @@ -0,0 +1,138 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { useState, useTransition } from 'react'; +import { AlertTriangle, Download, FileSpreadsheet, FileText, Loader2, Upload } from 'lucide-react'; +import { toast } from 'sonner'; +import { importArchiveAction } from '@/app/(app)/verwaltung/daten/actions'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Field } from '@/components/ui/field'; +import { Input } from '@/components/ui/input'; + +const CONFIRMATION = 'ERSETZEN'; + +export function DataTransfer() { + const router = useRouter(); + const [pending, startTransition] = useTransition(); + const [confirmation, setConfirmation] = useState(''); + + function submit(event: React.FormEvent) { + event.preventDefault(); + const form = event.currentTarget; + + startTransition(async () => { + const result = await importArchiveAction(new FormData(form)); + if (!result.ok) { + toast.error(result.error); + return; + } + + toast.success( + `Eingelesen: ${result.data.sets} Sets, ${result.data.collectionItems} Sammlungseinträge, ${result.data.wishlists} Wunschlisten, ${result.data.files} Dateien.`, + ); + form.reset(); + setConfirmation(''); + router.refresh(); + }); + } + + return ( +
+ + + Exportieren + + Zum Umziehen, zum Nachsehen außerhalb der App oder als zweites Netz. Das eigentliche + Backup bleibt das Verzeichnis ./data. + + + + + + + + + + + + Was im Export steckt + + +

+ Dabei: Hersteller, Themen, Sets, + Anleitungen, Standorte, Sammlungseinträge, Fehlteile, Fotos und Wunschlisten – im ZIP + zusätzlich alle hochgeladenen Dateien. +

+

+ Nicht dabei: Konten, Passwörter + und Passkeys. Ein Export voller Passwort-Hashes im Download-Ordner wäre ein Risiko, + kein Backup. Ebenfalls draußen: Reservierungen und Teilen-Links – die gehören zu der + Instanz, auf der sie entstanden sind. +

+
+
+ + + + + + Importieren + + + Ein Import ersetzt alle vorhandenen Daten – Katalog, Sammlung und + Wunschlisten. Zusammenführen ist nicht möglich. Wunschlisten gehören danach dir und + sind zunächst nicht geteilt. + + + +
+ + + + + + setConfirmation(event.target.value)} + placeholder={CONFIRMATION} + autoComplete="off" + /> + + + +
+
+
+
+ ); +} diff --git a/src/components/verwaltung/invite-list.tsx b/src/components/verwaltung/invite-list.tsx new file mode 100644 index 0000000..de7440a --- /dev/null +++ b/src/components/verwaltung/invite-list.tsx @@ -0,0 +1,212 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { useState, useTransition } from 'react'; +import { Check, Copy, Loader2, Mail, Plus, Trash2 } from 'lucide-react'; +import { toast } from 'sonner'; +import { + createInviteAction, + deleteInviteAction, +} from '@/app/(app)/verwaltung/einladungen/actions'; +import { formatDate } from '@/lib/format'; +import { Badge } from '@/components/ui/badge'; +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 InviteListRow = { + id: string; + email: string | null; + role: 'admin' | 'member'; + expiresAt: Date; + usedAt: Date | null; + usedByName: string | null; + state: 'open' | 'used' | 'expired'; +}; + +export function InviteList({ invites }: { invites: InviteListRow[] }) { + const router = useRouter(); + const [pending, startTransition] = useTransition(); + const [createOpen, setCreateOpen] = useState(false); + const [link, setLink] = useState(null); + const [copied, setCopied] = useState(false); + + function submit(event: React.FormEvent) { + event.preventDefault(); + const input = Object.fromEntries(new FormData(event.currentTarget).entries()); + + startTransition(async () => { + const result = await createInviteAction(input); + if (!result.ok) { + toast.error(result.error); + return; + } + setCreateOpen(false); + setLink(`${window.location.origin}/einladung/${result.data.token}`); + router.refresh(); + }); + } + + function remove(id: string) { + startTransition(async () => { + const result = await deleteInviteAction(id); + if (!result.ok) { + toast.error(result.error); + return; + } + toast.success('Einladung gelöscht.'); + router.refresh(); + }); + } + + async function copy() { + if (!link) return; + try { + await navigator.clipboard.writeText(link); + setCopied(true); + toast.success('Link kopiert.'); + setTimeout(() => setCopied(false), 2000); + } catch { + toast.error('Kopieren hat nicht geklappt. Markiere den Link und kopiere ihn von Hand.'); + } + } + + return ( +
+ + + {invites.length === 0 ? ( +

+ Noch keine Einladung. Damit bekommt zum Beispiel der zweite Elternteil ein eigenes + Konto – ohne dass die Registrierung für alle offen steht. +

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

    + {item.email ?? 'Für beliebige Adresse'} +

    +

    + {item.state === 'used' + ? `Verwendet von ${item.usedByName ?? 'jemandem'} am ${formatDate(item.usedAt)}` + : `Gültig bis ${formatDate(item.expiresAt)}`} +

    +
    + + + {item.state === 'open' ? 'Offen' : item.state === 'used' ? 'Verwendet' : 'Abgelaufen'} + + {item.role === 'admin' ? Admin : null} + + +
  • + ))} +
+ )} + + + + + Einladung erstellen + + Du bekommst einen Link, den du weitergibst. Er funktioniert genau einmal. + + + +
+ + + + +
+ + + + + + + + + + + + + + + +
+
+ + + + + +
+
+ + !open && setLink(null)}> + + + Einladungslink + + Der Link wird nur jetzt angezeigt – in der Datenbank liegt nur sein Hash. Kopiere + ihn und schick ihn weiter. + + + + event.currentTarget.select()} + className="font-mono text-xs" + /> + + + + + + + +
+ ); +} diff --git a/src/components/verwaltung/verwaltung-nav.tsx b/src/components/verwaltung/verwaltung-nav.tsx index fd83275..ecc9a6d 100644 --- a/src/components/verwaltung/verwaltung-nav.tsx +++ b/src/components/verwaltung/verwaltung-nav.tsx @@ -5,17 +5,20 @@ import { usePathname } from 'next/navigation'; import { cn } from 'cn'; const tabs = [ - { href: '/verwaltung/hersteller', label: 'Hersteller' }, - { href: '/verwaltung/themen', label: 'Themen' }, - { href: '/verwaltung/standorte', label: 'Standorte' }, + { href: '/verwaltung/hersteller', label: 'Hersteller', adminOnly: false }, + { href: '/verwaltung/themen', label: 'Themen', adminOnly: false }, + { href: '/verwaltung/standorte', label: 'Standorte', adminOnly: false }, + { href: '/verwaltung/einladungen', label: 'Einladungen', adminOnly: true }, + { href: '/verwaltung/daten', label: 'Daten', adminOnly: true }, ] as const; -export function VerwaltungNav() { +export function VerwaltungNav({ isAdmin }: { isAdmin: boolean }) { const pathname = usePathname(); + const visible = tabs.filter((tab) => !tab.adminOnly || isAdmin); return (