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
+
+
+
+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
+
+
+
+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
+
+
+
+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
+
+
+
+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
- 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.
Neues Set
+
+ {/* The Lego import creates its manufacturer itself, so it works even
+ on a completely empty instance. */}
+