9 Commits
Author SHA1 Message Date
unknownandClaude Opus 5 7265c03548 Merge: Phase 2 - Komfort (v0.2.0)
Lego-Import ueber Rebrickable, Barcode-Scanner, Fehlteile je
Sammlungseintrag, Einladungslinks, Export und Import des gesamten
Bestands sowie die PWA mit offline lesbarem Katalog.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 08:47:22 +02:00
unknownandClaude Opus 5 dcd076498a test: smoke-test gegen das docker-image, dazu die doku zu phase 2
Der Dev-Server verlor unter der Last eines ganzen Durchlaufs
reproduzierbar Chunks (ChunkLoadError beim Laden von app/layout), was
sich weder durch Aufwaermen noch durch laengere Zeitlimits stabilisieren
liess. Der Smoke-Test faehrt jetzt - wie der Screenshot-Lauf schon
vorher - das Produktions-Image hoch. Ergebnis: 29 Sekunden statt fuenf
Minuten, keine Aussetzer, und getestet wird genau das Artefakt, das
spaeter deployt wird.

Dabei behoben: Der Fehlteile-Dialog hielt eine Kopie der Liste aus dem
Moment des Oeffnens und zeigte ein gerade hinzugefuegtes Teil nicht an.
Er liest jetzt den aktuellen Eintrag aus den Props statt aus dem
Zustand. Gefunden hat das der erweiterte Screenshot-Lauf.

Dokumentation zu Phase 2: Changelog 0.2.0, Roadmap abgehakt, Devlog mit
Zeiten, CLAUDE.md um die neuen Module und Testkonventionen ergaenzt und
die Anleitung um fuenf Kapitel (Import, Scanner, Fehlteile, Einladungen,
Daten, Startbildschirm) - mit vier neuen, automatisch erzeugten Bildern.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 08:18:28 +02:00
unknownandClaude Opus 5 39271f8af7 feat(pwa): installierbar mit manifest, icons und offline-katalog
Manifest, Icons (auch maskable und Apple-Touch) und ein Service Worker.
Auf dem Handy laesst sich Bricktrack damit auf den Startbildschirm legen
und startet ohne Browserleiste; die Verknuepfungen springen direkt zu
"Fehlende Sets" und zur Sammlung.

Der Service Worker liefert Seiten nach dem Muster "erst Netz, dann
Cache": mit Verbindung ist alles aktuell, ohne Verbindung bleibt lesbar,
was schon einmal geladen war - genau der Katalog, den man im Keller oder
im Laden braucht. Statische Build-Dateien und Set-Bilder kommen aus dem
Cache, weil ihre Namen ohnehin unveraenderlich sind.

Bewusst nie aus dem Cache: /api/auth, /api/export und der Healthcheck,
und nichts ausser GET - eine offline "reservierte" Wunschliste waere
eine Luege. Beim Abmelden raeumt die App den Cache, damit auf einem
geteilten Geraet keine lesbaren Seiten zurueckbleiben.

Icons entstehen reproduzierbar aus einer SVG-Quelle
(node scripts/generate-icons.mjs).

Refs Phase 2

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 07:27:12 +02:00
unknownandClaude Opus 5 45aa8f13cf feat(daten): export als zip, json und csv sowie import eines archivs
Unter Verwaltung -> Daten laedt der Admin alles als ZIP (Daten plus
hochgeladene Dateien), nur die Daten als JSON oder die Sammlung als CSV
- letzteres mit Semikolon und BOM, damit deutsches Excel es ohne
Rueckfragen oeffnet. Die CSV steht auch Mitgliedern offen, das
vollstaendige Archiv nur Administratoren.

Bewusst nicht im Export: Konten, Passwoerter und Passkeys. Ein Export
voller Passwort-Hashes im Download-Ordner waere ein Risiko und kein
Backup - das bleibt ./data. Ebenfalls draussen: Reservierungen und
Teilen-Links, die zur jeweiligen Instanz gehoeren.

Der Import ersetzt alle Anwendungsdaten in einer Transaktion, statt zu
vermischen: Zusammenfuehren zweier Sammlungen braeuchte Konfliktregeln
fuer jede Tabelle und waere im Ergebnis nicht vorhersagbar. Deshalb ist
er Administratoren vorbehalten und verlangt ein getipptes ERSETZEN.
Wunschlisten gehoeren danach dem importierenden Admin und bekommen
frische, zunaechst nicht geteilte Links. Pfade aus dem Archiv werden
gegen Ausbrueche aus dem Dateiverzeichnis geprueft.

10 Tests decken den Rundweg ab: Export, Loeschen, Import, Ersetzen statt
Vermischen, und dass ein fremdes Archiv abgelehnt wird, ohne etwas zu
loeschen.

Refs Phase 2

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 07:23:45 +02:00
unknownandClaude Opus 5 5c5c5213bb feat(fehlteile): fehlende teile je sammlungseintrag pflegen
Jeder Sammlungseintrag hat jetzt eine Fehlteil-Liste: Teilenummer,
Farbe, Anzahl und Notiz, abhakbar wenn das Teil nachgekauft ist. Ein
Eintrag ohne Nummer geht auch - dann reicht die Notiz, denn beim
Aufbauen kennt man die Nummer selten.

Die Vollstaendigkeit pflegt sich dabei selbst: Der erste offene Eintrag
setzt den Sammlungseintrag auf "Teile fehlen", der letzte erledigte
zurueck auf "vollstaendig". Damit stimmt der Filter in der Sammlung,
ohne dass man daran denken muss.

Offene Fehlteile stehen als Badge an jedem Eintrag - in der Sammlung
und auf der Set-Detailseite.

Refs Phase 2

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 07:18:50 +02:00
unknownandClaude Opus 5 baa94bf7e5 feat(scanner): ean-barcode mit der handykamera scannen
Knopf im Katalog neben "Set anlegen": Strichcode ins Bild halten, und
entweder oeffnet sich das passende Set oder das Formular "Neues Set"
mit ausgefuellter EAN. Dekodiert wird im Browser mit zxing, das nur
beim Oeffnen des Scanners nachgeladen wird - nichts verlaesst das
Geraet, und die Katalogseite bleibt leicht fuer alle, die nie scannen.

Jeder gelesene Code wird gegen seine Pruefziffer geprueft, bevor er
verwendet wird. Ohne das wuerde eine falsch erkannte Ziffer stillschweigend
ein zweites Katalog-Set fuer ein Set anlegen, das es schon gibt; bei
einem Fehlversuch scannt die Kamera einfach weiter.

Die Kamera braucht wie Passkeys einen sicheren Kontext. Fehlt HTTPS oder
wird der Zugriff abgelehnt, steht das als deutscher Hinweis im Dialog
statt einer stummen schwarzen Flaeche.

8 Unit-Tests mit echten EAN-13-, EAN-8- und UPC-A-Beispielen.

Refs Phase 2

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 07:15:41 +02:00
unknownandClaude Opus 5 4e3cbb81ce feat(import): lego-sets ueber die rebrickable-api uebernehmen
Auf "Neues Set" reicht die Setnummer von der Packung: Name, Jahr,
Teile, Minifiguren und Thema kommen aus der API, das Bild wird lokal
gespeichert und nicht nur verlinkt. Hersteller und Thema werden bei
Bedarf angelegt, ein Oberthema inklusive. Alle Aufrufe laufen
serverseitig; der Browser spricht nie mit rebrickable.com.

Ohne REBRICKABLE_API_KEY bleibt der Import sichtbar, aber abgeschaltet,
mit dem Hinweis, was zu tun ist - von Hand anlegen geht unveraendert.

Rebrickable liefert keine Anleitungsdateien. Statt das zu verschleiern,
legt der Import einen externen Link auf die LEGO-Serviceseite zur
Setnummer an.

Nebenbei: src/env.ts behandelt leere Werte jetzt wie nicht gesetzt - ein
"BASE_URL=" in der .env fuehrte sonst zum Start-Abbruch mit "Invalid
URL" statt zum Standardwert.

14 neue Unit-Tests decken Antwortverarbeitung, Setnummern-Varianten und
alle Fehlerfaelle der API ab (404, 401, 429, Netzwerk).

Refs Phase 2

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 07:12:22 +02:00
unknownandClaude Opus 5 52d69e5654 feat(einladungen): admin erzeugt einmal gueltige registrierungslinks
Nach dem ersten Nutzer bleibt die Registrierung geschlossen; weitere
Konten entstehen nur ueber einen Einladungslink. Der Klartext-Token
wird genau einmal angezeigt, in der Datenbank liegt sein SHA-256-Hash.
Optional laesst sich eine Einladung an eine E-Mail-Adresse binden, die
Rolle und die Gueltigkeit sind waehlbar.

Die Freigabe zum Anlegen eines Kontos laeuft ueber AsyncLocalStorage:
Der Server-Action, der den Token geprueft hat, umschliesst damit den
signUp-Aufruf, und nur in diesem Kontext laesst der user-create-Hook von
better-auth die Registrierung zu. Die Rolle kommt aus der Einladung und
kann nicht ueber das Formular gesetzt werden.

Der Smoke-Test deckt den ganzen Weg ab: Admin erstellt die Einladung,
der zweite Elternteil registriert sich damit, bekommt die Rolle
"Mitglied" (und wird von der Einladungsverwaltung weggeleitet), und der
Link funktioniert kein zweites Mal.

Refs Phase 2

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 07:01:55 +02:00
unknownandClaude Opus 5 220a3303cd Merge: Phase 1 - MVP (v0.1.0)
Katalog, Sammlung und Wunschlisten inklusive oeffentlicher Ansicht,
Anmeldung mit Passwort und Passkeys, Upload-Pipeline und der komplette
Docker-Stack. Im Container verifiziert: Migrationen laufen beim Start,
Healthcheck greift, Registrierung des ersten Nutzers macht ihn zum
Administrator.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 06:47:46 +02:00
87 changed files with 4037 additions and 199 deletions
+4 -2
View File
@@ -27,6 +27,8 @@ TZ=Europe/Berlin
# BIND_ADDRESS=127.0.0.1 # BIND_ADDRESS=127.0.0.1
# HOST_PORT=3000 # HOST_PORT=3000
# Optional (ab Phase 2): Rebrickable-API-Key fuer den Lego-Import. # Optional: Rebrickable-API-Key fuer den Lego-Import. Damit reicht auf der
# Ohne Key funktioniert alles manuell weiter. # 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= # REBRICKABLE_API_KEY=
+2
View File
@@ -23,6 +23,8 @@ next-env.d.ts
/data-shots /data-shots
/data-docker /data-docker
/backups /backups
/data-test
/data-test-export
# production # production
*.tsbuildinfo *.tsbuildinfo
+61 -1
View File
@@ -7,6 +7,65 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/).
## [Unreleased] ## [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 ## [0.1.0] - 2026-09-07
Erste lauffaehige Fassung: Phase 1 vollstaendig, im Docker-Stack verifiziert. 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 - Unter Windows scheitert `pnpm build` an fehlenden Symlink-Rechten. Der
Docker-Build unter Linux ist davon nicht betroffen. 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 [0.1.0]: https://git.wilde-server.de/florian.wilde/bricktrack/releases/tag/v0.1.0
+39 -2
View File
@@ -19,6 +19,9 @@ teilbare Wunschlisten.
| Validierung | zod (serverseitig, immer) | | | Validierung | zod (serverseitig, immer) | |
| Bilder | sharp (nur serverseitig, Thumbnails) | | | Bilder | sharp (nur serverseitig, Thumbnails) | |
| Tests | Vitest (Unit), Playwright (E2E, Screenshots) | | | 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 | | | Paketmanager | pnpm 10 | |
Bewusst **kein** Prisma (Standalone-Build im Container), **keine** 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, - Commits: Conventional Commits, Betreff auf Deutsch, Imperativ,
erste Zeile <= 72 Zeichen. 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 ## Befehle
```bash ```bash
@@ -58,8 +75,22 @@ pnpm db:studio # Drizzle Studio
pnpm test # Vitest pnpm test # Vitest
pnpm test:e2e # Playwright pnpm test:e2e # Playwright
pnpm screenshots # Screenshots fuer docs/ANLEITUNG.md erzeugen 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 ## Datenmodell
Kernregel: Ein **`set`** ist ein *Katalogeintrag* - es existiert, Kernregel: Ein **`set`** ist ein *Katalogeintrag* - es existiert,
@@ -145,13 +176,19 @@ Session.
serverseitig. serverseitig.
5. Session-Cookies: `httpOnly`, `secure`, `sameSite=lax`. 5. Session-Cookies: `httpOnly`, `secure`, `sameSite=lax`.
Rate-Limiting auf den Login-Endpunkten. 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 ## Phasenplan
- **Phase 1 (MVP)** - Auth mit Passkeys, Stammdaten, Katalog, Sammlung, - **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, - **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, - **Phase 3 (Nice to have)** - mehrere Sammler, Preisbeobachtung,
E-Mail-Erinnerungen, Dublettenpruefung. E-Mail-Erinnerungen, Dublettenpruefung.
+7 -1
View File
@@ -172,10 +172,16 @@ pnpm dev # http://localhost:3000
| `pnpm build` | Produktionsbuild (standalone) | | `pnpm build` | Produktionsbuild (standalone) |
| `pnpm lint` / `pnpm typecheck` | ESLint / TypeScript – beide müssen sauber sein | | `pnpm lint` / `pnpm typecheck` | ESLint / TypeScript – beide müssen sauber sein |
| `pnpm test` | Unit-Tests (Vitest) | | `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:generate` | SQL-Migration aus dem Drizzle-Schema erzeugen |
| `pnpm db:migrate` | Migrationen anwenden | | `pnpm db:migrate` | Migrationen anwenden |
| `pnpm db:studio` | Drizzle Studio | | `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 > **Unter Windows** schlägt `pnpm build` mit `EPERM … symlink` fehl: der
> Standalone-Output legt Symlinks an, wofür Windows den Entwicklermodus oder > Standalone-Output legt Symlinks an, wofür Windows den Entwicklermodus oder
+132 -4
View File
@@ -22,7 +22,12 @@ Kinderzimmer, nicht für den Schreibtisch.
8. [Wunschlisten](#8-wunschlisten) 8. [Wunschlisten](#8-wunschlisten)
9. [Teilen – so sehen es Oma und Tante Anne](#9-teilen) 9. [Teilen – so sehen es Oma und Tante Anne](#9-teilen)
10. [Passkeys einrichten](#10-passkeys-einrichten) 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: 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 → Katalog, Filter „Fehlt noch". Von dort in zwei Handgriffen auf eine
Wunschliste und per Link zu Oma. Wunschliste und per Link zu Oma.
Im Laden vor dem Regal: Katalog öffnen, Setnummer von der Packung eintippen. Im Laden vor dem Regal: Scanner öffnen, Strichcode einlesen. Steht ein grüner
Steht ein grüner Haken dran, habt ihr es schon. Haken dran, habt ihr es schon.
--- ---
+34
View File
@@ -29,6 +29,22 @@ Commit-Spalte verweist auf den Stand am Ende des Schritts.
**Summe Phase 1: 2:44** **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 ## 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 Produktions-Image im Container – verlässlich und zugleich näher an dem, was
Nutzer wirklich sehen. 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 ### Beim Testen gefunden und behoben
- Eine spontan aus dem Katalog angelegte Wunschliste war **nicht teilbar**: - Eine spontan aus dem Katalog angelegte Wunschliste war **nicht teilbar**:
+7 -7
View File
@@ -34,17 +34,17 @@ Laeuft vollstaendig im Docker-Stack.
- [x] Dokumentation: README, QUICKSTART, bebilderte Anleitung, - [x] Dokumentation: README, QUICKSTART, bebilderte Anleitung,
update.sh, Nginx-Proxy-Manager-Konfiguration 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. 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" Unbekanntem direkt ins Formular "Neues Set"
- [ ] Fehlteile je Sammlungseintrag pflegen - [x] Fehlteile je Sammlungseintrag pflegen
- [ ] Einladungslinks durch den Admin - [x] Einladungslinks durch den Admin
- [ ] Export/Import: gesamte Datenbank als JSON, Dateien als ZIP, - [x] Export/Import: gesamte Datenbank als JSON, Dateien als ZIP,
Sammlung zusaetzlich als CSV 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) ## Phase 3 - Nice to have (Ziel: v0.3.0)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 285 KiB

After

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 202 KiB

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 212 KiB

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 252 KiB

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 183 KiB

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 292 KiB

After

Width:  |  Height:  |  Size: 293 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 187 KiB

After

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 294 KiB

After

Width:  |  Height:  |  Size: 294 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

-13
View File
@@ -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' },
});
}
+33
View File
@@ -127,4 +127,37 @@ test('Screenshots fuer die Anleitung', async ({ page, browser }) => {
await visit(page, '/konto'); await visit(page, '/konto');
await expect(page.getByText('Passkeys', { exact: true })).toBeVisible(); await expect(page.getByText('Passkeys', { exact: true })).toBeVisible();
await shoot(page, '16-konto-passkeys'); 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');
}); });
+47
View File
@@ -81,6 +81,53 @@ test('Registrieren, Set anlegen, Wunschliste teilen und reservieren', async ({ p
await visitorContext.close(); 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 }) => { test('Ohne Anmeldung fuehrt jeder geschuetzte Pfad zur Anmeldung', async ({ page }) => {
await page.goto('/sammlung'); await page.goto('/sammlung');
await expect(page).toHaveURL(/\/anmelden|\/registrieren/); await expect(page).toHaveURL(/\/anmelden|\/registrieren/);
+11
View File
@@ -11,6 +11,17 @@ const compat = new FlatCompat({
const eslintConfig = [ const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"), ...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: [ ignores: [
"node_modules/**", "node_modules/**",
+4 -1
View File
@@ -19,17 +19,20 @@
"db:studio": "drizzle-kit studio", "db:studio": "drizzle-kit studio",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"test:e2e": "node scripts/reset-e2e.mjs && playwright test", "test:e2e": "node scripts/e2e-prepare.mjs && playwright test",
"screenshots": "node scripts/screenshots-prepare.mjs && playwright test --config=playwright.screenshots.config.ts" "screenshots": "node scripts/screenshots-prepare.mjs && playwright test --config=playwright.screenshots.config.ts"
}, },
"dependencies": { "dependencies": {
"@better-auth/passkey": "^1.7.3", "@better-auth/passkey": "^1.7.3",
"@zxing/browser": "^0.2.1",
"@zxing/library": "^0.23.0",
"better-auth": "^1.7.3", "better-auth": "^1.7.3",
"better-sqlite3": "^13.0.3", "better-sqlite3": "^13.0.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"cn": "^0.2.6", "cn": "^0.2.6",
"date-fns": "^4.4.0", "date-fns": "^4.4.0",
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
"fflate": "^0.8.3",
"lucide-react": "^1.41.0", "lucide-react": "^1.41.0",
"next": "15.5.25", "next": "15.5.25",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
+19 -16
View File
@@ -1,28 +1,29 @@
import path from 'node:path';
import { defineConfig, devices } from '@playwright/test'; import { defineConfig, devices } from '@playwright/test';
/** /**
* Smoke test setup. * Smoke test setup.
* *
* Runs against its own data directory (./data-e2e) so a test never touches the * Runs against the production container with its own data directory
* real database. The global setup wipes and migrates it before each run. * (./data-e2e), so a test never touches the real database and never depends on
* the dev server compiling fast enough.
*/ */
const PORT = 3210; const PORT = 3210;
const BASE_URL = `http://localhost:${PORT}`; 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({ export default defineConfig({
testDir: './e2e', testDir: './e2e',
// The screenshot run has its own config, its own server and its own data // The screenshot run has its own config, its own server and its own data
// directory - it must never join the smoke run. // directory - it must never join the smoke run.
testMatch: 'smoke.spec.ts', testMatch: 'smoke.spec.ts',
globalSetup: './e2e/global-setup.ts',
fullyParallel: false, fullyParallel: false,
workers: 1, workers: 1,
retries: 0, retries: 0,
// Generous: the dev server compiles each route on first visit. timeout: 90_000,
timeout: 240_000, expect: { timeout: 15_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', reporter: process.env.CI ? 'github' : 'list',
use: { use: {
baseURL: BASE_URL, baseURL: BASE_URL,
@@ -38,18 +39,20 @@ export default defineConfig({
}, },
], ],
webServer: { webServer: {
command: `pnpm exec next dev -p ${PORT}`, 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`, url: `${BASE_URL}/api/health`,
reuseExistingServer: false, reuseExistingServer: false,
timeout: 180_000, timeout: 180_000,
stdout: 'pipe', stdout: 'pipe',
stderr: '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',
},
}, },
}); });
+3
View File
@@ -41,6 +41,9 @@ export default defineConfig({
`-e BASE_URL=${BASE_URL}`, `-e BASE_URL=${BASE_URL}`,
'-e BETTER_AUTH_SECRET=screenshot-run-secret-0123456789abcdefgh', '-e BETTER_AUTH_SECRET=screenshot-run-secret-0123456789abcdefgh',
'-e TZ=Europe/Berlin', '-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"`, `-v "${dataDir}:/data"`,
'bricktrack:shots', 'bricktrack:shots',
].join(' '), ].join(' '),
+47
View File
@@ -11,6 +11,12 @@ importers:
'@better-auth/passkey': '@better-auth/passkey':
specifier: ^1.7.3 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) 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: better-auth:
specifier: ^1.7.3 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))) 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: drizzle-orm:
specifier: ^0.45.2 specifier: ^0.45.2
version: 0.45.2(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3)(kysely@0.29.5) 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: lucide-react:
specifier: ^1.41.0 specifier: ^1.41.0
version: 1.41.0(react@19.1.0) version: 1.41.0(react@19.1.0)
@@ -2457,6 +2466,18 @@ packages:
'@vitest/spy@5.0.0': '@vitest/spy@5.0.0':
resolution: {integrity: sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==} 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: accepts@2.0.0:
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
@@ -3330,6 +3351,9 @@ packages:
picomatch: picomatch:
optional: true optional: true
fflate@0.8.3:
resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==}
figures@6.1.0: figures@6.1.0:
resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -4745,6 +4769,10 @@ packages:
peerDependencies: peerDependencies:
typescript: '>=4.8.4' 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: ts-morph@26.0.0:
resolution: {integrity: sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug==} resolution: {integrity: sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug==}
@@ -7104,6 +7132,21 @@ snapshots:
'@vitest/spy@5.0.0': {} '@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: accepts@2.0.0:
dependencies: dependencies:
mime-types: 3.0.2 mime-types: 3.0.2
@@ -8094,6 +8137,8 @@ snapshots:
optionalDependencies: optionalDependencies:
picomatch: 4.0.7 picomatch: 4.0.7
fflate@0.8.3: {}
figures@6.1.0: figures@6.1.0:
dependencies: dependencies:
is-unicode-supported: 2.1.0 is-unicode-supported: 2.1.0
@@ -9555,6 +9600,8 @@ snapshots:
dependencies: dependencies:
typescript: 5.9.3 typescript: 5.9.3
ts-custom-error@3.3.1: {}
ts-morph@26.0.0: ts-morph@26.0.0:
dependencies: dependencies:
'@ts-morph/common': 0.27.0 '@ts-morph/common': 0.27.0
Binary file not shown.

After

Width:  |  Height:  |  Size: 332 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

+45
View File
@@ -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"
}
]
}
+122
View File
@@ -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));
}
});
+22
View File
@@ -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.');
+67
View File
@@ -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) => `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="112" fill="${background}"/>
<g fill="${foreground}">
<rect x="112" y="112" width="128" height="128" rx="28"/>
<rect x="272" y="112" width="128" height="128" rx="28"/>
<rect x="112" y="272" width="128" height="128" rx="28"/>
<rect x="272" y="272" width="128" height="128" rx="28"/>
</g>
</svg>`;
/**
* 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) => `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" fill="${background}"/>
<g fill="${foreground}">
<rect x="146" y="146" width="100" height="100" rx="22"/>
<rect x="266" y="146" width="100" height="100" rx="22"/>
<rect x="146" y="266" width="100" height="100" rx="22"/>
<rect x="266" y="266" width="100" height="100" rx="22"/>
</g>
</svg>`;
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)');
-13
View File
@@ -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.`);
+3
View File
@@ -18,6 +18,7 @@ import {
listInstructionsForSet, listInstructionsForSet,
listPhotosForSet, listPhotosForSet,
} from '@/lib/queries/catalog'; } from '@/lib/queries/catalog';
import { listMissingParts } from '@/lib/queries/collection';
import { listLocations } from '@/lib/queries/master-data'; import { listLocations } from '@/lib/queries/master-data';
import { listWishlistsForSet, listWishlistTargets } from '@/lib/queries/wishlists'; import { listWishlistsForSet, listWishlistTargets } from '@/lib/queries/wishlists';
import { requireUser } from '@/lib/session'; import { requireUser } from '@/lib/session';
@@ -45,6 +46,8 @@ export default async function SetDetailPage({ params }: Props) {
const collectionItems = listCollectionItemsForSet(set.id).map((item) => ({ const collectionItems = listCollectionItemsForSet(set.id).map((item) => ({
...item, ...item,
locationName: item.locationId ? (locationNames.get(item.locationId) ?? null) : null, locationName: item.locationId ? (locationNames.get(item.locationId) ?? null) : null,
setName: `${set.setNumber} · ${set.name}`,
missingParts: listMissingParts(item.id),
})); }));
const facts: Array<[string, string]> = [ const facts: Array<[string, string]> = [
+237
View File
@@ -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<string | null> {
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<ActionResult<ImportResult>> {
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,
},
});
}
+19 -2
View File
@@ -1,8 +1,10 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import Link from 'next/link'; import Link from 'next/link';
import { RebrickableImport } from '@/components/katalog/rebrickable-import';
import { SetForm } from '@/components/katalog/set-form'; import { SetForm } from '@/components/katalog/set-form';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { listManufacturers, listThemes } from '@/lib/queries/master-data'; import { listManufacturers, listThemes } from '@/lib/queries/master-data';
import { isRebrickableEnabled } from '@/lib/rebrickable';
import { requireUser } from '@/lib/session'; import { requireUser } from '@/lib/session';
export const metadata: Metadata = { title: 'Neues Set' }; export const metadata: Metadata = { title: 'Neues Set' };
@@ -21,12 +23,19 @@ export default async function NeuesSetPage({ searchParams }: { searchParams: Sea
manufacturerId: row.manufacturerId, manufacturerId: row.manufacturerId,
})); }));
const importEnabled = isRebrickableEnabled();
if (manufacturers.length === 0) { if (manufacturers.length === 0) {
return ( return (
<div className="space-y-4"> <div className="space-y-6">
<h1 className="text-2xl font-semibold tracking-tight">Neues Set</h1> <h1 className="text-2xl font-semibold tracking-tight">Neues Set</h1>
{/* The Lego import creates its manufacturer itself, so it works even
on a completely empty instance. */}
<RebrickableImport enabled={importEnabled} />
<p className="rounded-lg border border-dashed border-border p-6 text-sm text-muted-foreground"> <p className="rounded-lg border border-dashed border-border p-6 text-sm text-muted-foreground">
Jedes Set gehört zu einem Hersteller. Leg zuerst einen an – Lego, Playmobil, Cobi oder Für ein Set von Hand brauchst du zuerst einen Hersteller – Lego, Playmobil, Cobi oder
was auch immer im Kinderzimmer steht. was auch immer im Kinderzimmer steht.
</p> </p>
<Button asChild size="lg"> <Button asChild size="lg">
@@ -45,6 +54,14 @@ export default async function NeuesSetPage({ searchParams }: { searchParams: Sea
Sammlung hinzufügen“ fest. Sammlung hinzufügen“ fest.
</p> </p>
</div> </div>
<RebrickableImport enabled={importEnabled} />
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span className="h-px flex-1 bg-border" />
oder von Hand
<span className="h-px flex-1 bg-border" />
</div>
<SetForm manufacturers={manufacturers} themes={themes} defaultEan={ean} /> <SetForm manufacturers={manufacturers} themes={themes} defaultEan={ean} />
</div> </div>
); );
+10 -6
View File
@@ -1,6 +1,7 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import Link from 'next/link'; import Link from 'next/link';
import { Plus } from 'lucide-react'; import { Plus } from 'lucide-react';
import { BarcodeScannerButton } from '@/components/katalog/barcode-scanner';
import { CatalogFilters } from '@/components/katalog/catalog-filters'; import { CatalogFilters } from '@/components/katalog/catalog-filters';
import { CatalogGrid } from '@/components/katalog/catalog-grid'; import { CatalogGrid } from '@/components/katalog/catalog-grid';
import { Pagination } from '@/components/katalog/pagination'; 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.'} : 'Alle bekannten Sets – unabhängig davon, ob sie schon da sind.'}
</p> </p>
</div> </div>
<Button asChild size="lg" className="shrink-0"> <div className="flex shrink-0 gap-2">
<Link href="/katalog/neu"> <BarcodeScannerButton />
<Plus /> <Button asChild size="lg">
<span className="hidden sm:inline">Set anlegen</span> <Link href="/katalog/neu">
</Link> <Plus />
</Button> <span className="hidden sm:inline">Set anlegen</span>
</Link>
</Button>
</div>
</div> </div>
<CatalogFilters options={{ manufacturers, themes, years: listSetYears() }} /> <CatalogFilters options={{ manufacturers, themes, years: listSetYears() }} />
+37
View File
@@ -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<ActionResult<ScanLookup>> {
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 });
}
+123 -3
View File
@@ -1,9 +1,9 @@
'use server'; 'use server';
import { revalidatePath } from 'next/cache'; import { revalidatePath } from 'next/cache';
import { eq } from 'drizzle-orm'; import { and, eq } from 'drizzle-orm';
import { db } from '@/db'; import { db } from '@/db';
import { collectionItem, set } from '@/db/schema'; import { collectionItem, missingPart, set } from '@/db/schema';
import { import {
actionData, actionData,
actionError, actionError,
@@ -13,7 +13,7 @@ import {
} from '@/lib/action-result'; } from '@/lib/action-result';
import { reconcileFulfilledWishlistItems } from '@/lib/queries/wishlists'; import { reconcileFulfilledWishlistItems } from '@/lib/queries/wishlists';
import { requireUser } from '@/lib/session'; import { requireUser } from '@/lib/session';
import { collectionItemSchema } from '@/lib/validation'; import { collectionItemSchema, missingPartSchema } from '@/lib/validation';
import type { Condition } from '@/db/types'; import type { Condition } from '@/db/types';
function now() { function now() {
@@ -103,3 +103,123 @@ export async function deleteCollectionItem(id: string): Promise<ActionResult> {
revalidateCollection(existing.setId); revalidateCollection(existing.setId);
return actionOk(); 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<ActionResult> {
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<ActionResult> {
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<ActionResult> {
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();
}
+5
View File
@@ -10,6 +10,7 @@ import { completenessLabel, statusLabel } from '@/lib/labels';
import { formatEuro } from '@/lib/money'; import { formatEuro } from '@/lib/money';
import { import {
countMissingSets, countMissingSets,
countOpenMissingParts,
getCollectionStats, getCollectionStats,
listCollectionItems, listCollectionItems,
type CollectionFilters as Filters, type CollectionFilters as Filters,
@@ -40,6 +41,7 @@ export default async function SammlungPage({ searchParams }: { searchParams: Sea
}; };
const items = listCollectionItems(filters); const items = listCollectionItems(filters);
const openParts = countOpenMissingParts(items.map((item) => item.id));
const stats = getCollectionStats(); const stats = getCollectionStats();
const missing = countMissingSets(); const missing = countMissingSets();
@@ -132,6 +134,9 @@ export default async function SammlungPage({ searchParams }: { searchParams: Sea
{item.completeness !== 'unknown' ? ( {item.completeness !== 'unknown' ? (
<Badge variant="secondary">{completenessLabel(item.completeness)}</Badge> <Badge variant="secondary">{completenessLabel(item.completeness)}</Badge>
) : null} ) : null}
{openParts.get(item.id) ? (
<Badge variant="outline">{openParts.get(item.id)} Fehlteile</Badge>
) : null}
{item.acquiredAt ? ( {item.acquiredAt ? (
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
seit {formatDate(item.acquiredAt)} seit {formatDate(item.acquiredAt)}
+45
View File
@@ -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<ActionResult<ImportSummary>> {
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.',
);
}
}
+10
View File
@@ -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 <DataTransfer />;
}
@@ -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<ActionResult<{ token: string }>> {
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<ActionResult> {
await requireAdmin();
deleteInvite(id);
revalidatePath('/verwaltung/einladungen');
return actionOk();
}
@@ -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 <InviteList invites={invites} />;
}
+2 -2
View File
@@ -5,7 +5,7 @@ import { requireUser } from '@/lib/session';
export const metadata: Metadata = { title: 'Verwaltung' }; export const metadata: Metadata = { title: 'Verwaltung' };
export default async function VerwaltungLayout({ children }: { children: React.ReactNode }) { export default async function VerwaltungLayout({ children }: { children: React.ReactNode }) {
await requireUser(); const user = await requireUser();
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -15,7 +15,7 @@ export default async function VerwaltungLayout({ children }: { children: React.R
Stammdaten, die im Katalog und in der Sammlung ausgewählt werden. Stammdaten, die im Katalog und in der Sammlung ausgewählt werden.
</p> </p>
</div> </div>
<VerwaltungNav /> <VerwaltungNav isAdmin={user.role === 'admin'} />
{children} {children}
</div> </div>
); );
@@ -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<ActionResult> {
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();
}
+54
View File
@@ -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 (
<Card>
<CardHeader>
<CardTitle>Einladung ungültig</CardTitle>
<CardDescription>
Dieser Link ist abgelaufen oder wurde schon benutzt. Lass dir einfach einen neuen
schicken.
</CardDescription>
</CardHeader>
<CardContent>
<Link href="/anmelden" className="text-sm underline underline-offset-4">
Zur Anmeldung
</Link>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle>Konto anlegen</CardTitle>
<CardDescription>
Du wurdest zu Bricktrack eingeladen. Der Link gilt noch bis zum{' '}
{formatDate(invitation.expiresAt)}.
</CardDescription>
</CardHeader>
<CardContent>
<InviteForm token={token} email={invitation.email} />
</CardContent>
</Card>
);
}
+106
View File
@@ -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 });
}
+12
View File
@@ -1,4 +1,5 @@
import type { Metadata, Viewport } from 'next'; import type { Metadata, Viewport } from 'next';
import { ServiceWorkerRegistration } from '@/components/layout/service-worker';
import { Toaster } from '@/components/ui/sonner'; import { Toaster } from '@/components/ui/sonner';
import './globals.css'; import './globals.css';
@@ -9,6 +10,16 @@ export const metadata: Metadata = {
}, },
description: 'Sammlung und Wunschlisten für Spielzeug-Sets', description: 'Sammlung und Wunschlisten für Spielzeug-Sets',
applicationName: 'Bricktrack', 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 // A private, self-hosted instance never belongs in a search index - and the
// public wishlist links are meant to be shared, not found. // public wishlist links are meant to be shared, not found.
robots: { index: false, follow: false }, robots: { index: false, follow: false },
@@ -30,6 +41,7 @@ export default function RootLayout({ children }: Readonly<{ children: React.Reac
<body className="min-h-dvh bg-background text-foreground antialiased"> <body className="min-h-dvh bg-background text-foreground antialiased">
{children} {children}
<Toaster position="top-center" richColors closeButton /> <Toaster position="top-center" richColors closeButton />
<ServiceWorkerRegistration />
</body> </body>
</html> </html>
); );
+28
View File
@@ -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 (
<div className="mx-auto flex min-h-dvh max-w-sm flex-col items-center justify-center gap-4 px-4 text-center">
<CloudOff className="size-10 text-muted-foreground" aria-hidden />
<h1 className="text-xl font-semibold tracking-tight">Gerade offline</h1>
<p className="text-sm text-muted-foreground">
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.
</p>
<p className="text-xs text-muted-foreground">
Sobald wieder Netz da ist, aktualisiert sich alles von selbst.
</p>
</div>
);
}
+83
View File
@@ -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<HTMLFormElement>) {
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 (
<form onSubmit={onSubmit} className="space-y-4">
<Field label="Name" htmlFor="name" required>
<Input id="name" name="name" autoComplete="name" required maxLength={80} />
</Field>
<Field
label="E-Mail"
htmlFor="email"
required
hint={email ? 'Diese Einladung gilt für genau diese Adresse.' : undefined}
>
<Input
id="email"
name="email"
type="email"
inputMode="email"
autoComplete="username"
required
defaultValue={email ?? ''}
readOnly={Boolean(email)}
/>
</Field>
<Field
label="Passwort"
htmlFor="password"
required
hint={`Mindestens ${MIN_PASSWORD_LENGTH} Zeichen. Einen Passkey kannst du danach unter „Konto“ hinterlegen.`}
>
<Input
id="password"
name="password"
type="password"
autoComplete="new-password"
required
minLength={MIN_PASSWORD_LENGTH}
value={password}
onChange={(event) => setPassword(event.target.value)}
/>
</Field>
<Button type="submit" size="lg" className="w-full" disabled={pending}>
{pending ? <Loader2 className="animate-spin" /> : null}
Konto anlegen
</Button>
</form>
);
}
+199
View File
@@ -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<HTMLVideoElement>(null);
const controlsRef = useRef<Controls | null>(null);
const handledRef = useRef(false);
const [open, setOpen] = useState(false);
const [status, setStatus] = useState<'starting' | 'scanning' | 'checking' | 'error'>('starting');
const [message, setMessage] = useState<string | null>(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 (
<>
<Button
variant="outline"
size="icon-lg"
aria-label="Barcode scannen"
title="Barcode scannen"
onClick={() => setOpen(true)}
>
<ScanLine />
</Button>
<Dialog
open={open}
onOpenChange={(next) => {
if (!next) stop();
setOpen(next);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Barcode scannen</DialogTitle>
<DialogDescription>
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.
</DialogDescription>
</DialogHeader>
{status === 'error' ? (
<p className="rounded-lg border border-dashed border-border p-4 text-sm text-muted-foreground">
{message}
</p>
) : (
<div className="relative overflow-hidden rounded-lg bg-black">
<video
ref={videoRef}
className="aspect-[3/4] w-full object-cover"
muted
playsInline
aria-label="Kamerabild"
/>
{/* A guide line makes people hold the box the right way round. */}
<div className="pointer-events-none absolute inset-x-6 top-1/2 h-0.5 -translate-y-1/2 bg-red-500/80" />
{status !== 'scanning' ? (
<div className="absolute inset-0 flex items-center justify-center gap-2 bg-black/60 text-sm text-white">
<Loader2 className="size-4 animate-spin" />
{status === 'checking' ? 'Im Katalog nachsehen …' : 'Kamera startet …'}
</div>
) : null}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
Abbrechen
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
@@ -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 (
<Card className="border-dashed">
<CardHeader>
<CardTitle>Lego-Import</CardTitle>
<CardDescription>
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{' '}
<code className="font-mono text-xs">REBRICKABLE_API_KEY</code> in die{' '}
<code className="font-mono text-xs">.env</code> eintragen und den Container neu
starten.
</CardDescription>
</CardHeader>
</Card>
);
}
function submit(event: React.FormEvent<HTMLFormElement>) {
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 (
<Card>
<CardHeader>
<CardTitle>Lego-Import</CardTitle>
<CardDescription>
Setnummer von der Packung eintippen – Name, Jahr, Teile, Minifiguren, Thema und Bild
werden übernommen. Das Bild wird lokal gespeichert.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={submit} className="flex items-end gap-2">
<Field label="Setnummer" htmlFor="import-number" className="flex-1">
<Input
id="import-number"
value={setNumber}
onChange={(event) => setSetNumber(event.target.value)}
placeholder="60398"
inputMode="numeric"
maxLength={40}
className="font-mono"
/>
</Field>
<Button type="submit" size="lg" disabled={pending || setNumber.trim().length === 0}>
{pending ? <Loader2 className="animate-spin" /> : <Download />}
Holen
</Button>
</form>
</CardContent>
</Card>
);
}
+38
View File
@@ -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<void> {
if (!('serviceWorker' in navigator)) return;
const registration = await navigator.serviceWorker.getRegistration();
registration?.active?.postMessage('bricktrack:clear-cache');
}
+3
View File
@@ -3,6 +3,7 @@
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { LogOut, Settings, UserRound } from 'lucide-react'; import { LogOut, Settings, UserRound } from 'lucide-react';
import { clearOfflineCaches } from '@/components/layout/service-worker';
import { authClient } from '@/lib/auth-client'; import { authClient } from '@/lib/auth-client';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
@@ -20,6 +21,8 @@ export function UserMenu({ user }: { user: SessionUser }) {
async function signOut() { async function signOut() {
await authClient.signOut(); await authClient.signOut();
// Cached pages must not stay readable on a shared device.
await clearOfflineCaches();
router.push('/anmelden'); router.push('/anmelden');
router.refresh(); router.refresh();
} }
@@ -2,9 +2,11 @@
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useState, useTransition } from 'react'; 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 { toast } from 'sonner';
import { deleteCollectionItem, updateCollectionItem } from '@/app/(app)/sammlung/actions'; 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 type { CollectionStatus, Completeness, Condition } from '@/db/types';
import { formatDate, toDateInputValue } from '@/lib/format'; import { formatDate, toDateInputValue } from '@/lib/format';
import { import {
@@ -54,6 +56,8 @@ export type CollectionItemRow = {
giftedBy: string | null; giftedBy: string | null;
lentOutTo: string | null; lentOutTo: string | null;
notes: string | null; notes: string | null;
setName: string;
missingParts: MissingPartRow[];
}; };
export function CollectionItemsSection({ export function CollectionItemsSection({
@@ -67,6 +71,11 @@ export function CollectionItemsSection({
const [pending, startTransition] = useTransition(); const [pending, startTransition] = useTransition();
const [editing, setEditing] = useState<CollectionItemRow | null>(null); const [editing, setEditing] = useState<CollectionItemRow | null>(null);
const [deleting, setDeleting] = useState<CollectionItemRow | null>(null); const [deleting, setDeleting] = useState<CollectionItemRow | null>(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<string | null>(null);
const partsItem = items.find((item) => item.id === partsItemId) ?? null;
function submit(event: React.FormEvent<HTMLFormElement>) { function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
@@ -121,6 +130,11 @@ export function CollectionItemsSection({
{statusLabel(item.status)} {statusLabel(item.status)}
</Badge> </Badge>
<Badge variant="secondary">{completenessLabel(item.completeness)}</Badge> <Badge variant="secondary">{completenessLabel(item.completeness)}</Badge>
{item.missingParts.some((part) => !part.resolved) ? (
<Badge variant="outline">
{item.missingParts.filter((part) => !part.resolved).length} Fehlteile
</Badge>
) : null}
{item.locationName ? ( {item.locationName ? (
<span className="text-xs text-muted-foreground">{item.locationName}</span> <span className="text-xs text-muted-foreground">{item.locationName}</span>
) : null} ) : null}
@@ -140,6 +154,16 @@ export function CollectionItemsSection({
</p> </p>
{item.notes ? <p className="text-xs">{item.notes}</p> : null} {item.notes ? <p className="text-xs">{item.notes}</p> : null}
</div> </div>
<Button
variant="ghost"
size="icon-sm"
aria-label="Fehlteile"
title="Fehlteile"
onClick={() => setPartsItemId(item.id)}
disabled={pending}
>
<PuzzleIcon />
</Button>
<Button <Button
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
@@ -283,6 +307,16 @@ export function CollectionItemsSection({
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{partsItem ? (
<MissingPartsDialog
collectionItemId={partsItem.id}
setName={partsItem.setName}
parts={partsItem.missingParts}
open
onOpenChange={(open) => !open && setPartsItemId(null)}
/>
) : null}
<AlertDialog open={deleting !== null} onOpenChange={(open) => !open && setDeleting(null)}> <AlertDialog open={deleting !== null} onOpenChange={(open) => !open && setDeleting(null)}>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
@@ -0,0 +1,186 @@
'use client';
import { useRouter } from 'next/navigation';
import { useTransition } from 'react';
import { Loader2, Plus, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import {
addMissingPart,
deleteMissingPart,
setMissingPartResolved,
} from '@/app/(app)/sammlung/actions';
import type { MissingPartRow } from '@/lib/queries/collection';
import { cn } from 'cn';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Field } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
/**
* Missing parts of one owned copy.
*
* Adding an entry marks the copy as incomplete, resolving the last one marks
* it complete again - so the collection filter stays truthful without anyone
* having to remember to update it.
*/
export function MissingPartsDialog({
collectionItemId,
setName,
parts,
open,
onOpenChange,
}: {
collectionItemId: string;
setName: string;
parts: MissingPartRow[];
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const router = useRouter();
const [pending, startTransition] = useTransition();
function add(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const form = event.currentTarget;
const input = Object.fromEntries(new FormData(form).entries());
startTransition(async () => {
const result = await addMissingPart(collectionItemId, input);
if (!result.ok) {
toast.error(result.error);
return;
}
form.reset();
toast.success('Fehlteil vermerkt.');
router.refresh();
});
}
function toggle(part: MissingPartRow) {
startTransition(async () => {
const result = await setMissingPartResolved(part.id, !part.resolved);
if (!result.ok) {
toast.error(result.error);
return;
}
router.refresh();
});
}
function remove(part: MissingPartRow) {
startTransition(async () => {
const result = await deleteMissingPart(part.id);
if (!result.ok) {
toast.error(result.error);
return;
}
toast.success('Eintrag gelöscht.');
router.refresh();
});
}
const open_ = parts.filter((part) => !part.resolved).length;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[85dvh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Fehlteile</DialogTitle>
<DialogDescription>
{setName} –{' '}
{open_ === 0
? 'aktuell fehlt nichts.'
: open_ === 1
? 'ein Teil fehlt noch.'
: `${open_} Teile fehlen noch.`}
</DialogDescription>
</DialogHeader>
{parts.length > 0 ? (
<ul className="divide-y divide-border rounded-lg border border-border">
{parts.map((part) => (
<li key={part.id} className="flex items-center gap-3 p-3">
<Checkbox
checked={part.resolved}
onCheckedChange={() => toggle(part)}
disabled={pending}
aria-label={part.resolved ? 'Wieder als fehlend markieren' : 'Als erledigt markieren'}
/>
<div className={cn('min-w-0 flex-1', part.resolved && 'opacity-50')}>
<p className={cn('truncate text-sm font-medium', part.resolved && 'line-through')}>
{part.quantity}× {part.partNumber ?? 'Teil'}
{part.colorName ? ` · ${part.colorName}` : ''}
</p>
{part.notes ? (
<p className="truncate text-xs text-muted-foreground">{part.notes}</p>
) : null}
</div>
<Button
variant="ghost"
size="icon-sm"
aria-label="Eintrag löschen"
onClick={() => remove(part)}
disabled={pending}
>
<Trash2 className="text-destructive" />
</Button>
</li>
))}
</ul>
) : (
<p className="rounded-lg border border-dashed border-border p-4 text-sm text-muted-foreground">
Noch nichts vermerkt. Trag hier ein, was beim Aufbauen fehlt – dann weißt du beim
Nachbestellen, worum es geht.
</p>
)}
<form onSubmit={add} className="space-y-3 rounded-lg border border-border p-3">
<p className="text-sm font-medium">Fehlteil hinzufügen</p>
<div className="grid gap-3 sm:grid-cols-3">
<Field label="Teilenummer" htmlFor="partNumber" className="sm:col-span-1">
<Input id="partNumber" name="partNumber" maxLength={40} placeholder="3001" className="font-mono" />
</Field>
<Field label="Farbe" htmlFor="colorName">
<Input id="colorName" name="colorName" maxLength={40} placeholder="Rot" />
</Field>
<Field label="Anzahl" htmlFor="quantity">
<Input
id="quantity"
name="quantity"
type="number"
inputMode="numeric"
min={1}
max={999}
defaultValue={1}
/>
</Field>
</div>
<Field label="Notiz" htmlFor="part-notes" hint="Reicht auch allein, wenn du die Nummer nicht kennst.">
<Input id="part-notes" name="notes" maxLength={300} placeholder="Dachstein vorne links" />
</Field>
<Button type="submit" disabled={pending}>
{pending ? <Loader2 className="animate-spin" /> : <Plus />}
Hinzufügen
</Button>
</form>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Fertig
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+138
View File
@@ -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<HTMLFormElement>) {
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 (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Exportieren</CardTitle>
<CardDescription>
Zum Umziehen, zum Nachsehen außerhalb der App oder als zweites Netz. Das eigentliche
Backup bleibt das Verzeichnis <code className="font-mono text-xs">./data</code>.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-wrap gap-2">
<Button asChild size="lg">
<a href="/api/export/zip" download>
<Download />
Alles als ZIP
</a>
</Button>
<Button asChild variant="outline" size="lg">
<a href="/api/export/json" download>
<FileText />
Nur Daten (JSON)
</a>
</Button>
<Button asChild variant="outline" size="lg">
<a href="/api/export/csv" download>
<FileSpreadsheet />
Sammlung als CSV
</a>
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Was im Export steckt</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm text-muted-foreground">
<p>
<span className="font-medium text-foreground">Dabei:</span> Hersteller, Themen, Sets,
Anleitungen, Standorte, Sammlungseinträge, Fehlteile, Fotos und Wunschlisten – im ZIP
zusätzlich alle hochgeladenen Dateien.
</p>
<p>
<span className="font-medium text-foreground">Nicht dabei:</span> 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.
</p>
</CardContent>
</Card>
<Card className="border-destructive/40">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<AlertTriangle className="size-5 text-destructive" aria-hidden />
Importieren
</CardTitle>
<CardDescription>
Ein Import <strong>ersetzt</strong> alle vorhandenen Daten – Katalog, Sammlung und
Wunschlisten. Zusammenführen ist nicht möglich. Wunschlisten gehören danach dir und
sind zunächst nicht geteilt.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={submit} className="space-y-4">
<Field label="Archiv" htmlFor="archive" required>
<Input id="archive" name="archive" type="file" accept=".zip,application/zip" required />
</Field>
<Field
label={`Zum Bestätigen ${CONFIRMATION} eintippen`}
htmlFor="confirmation"
required
>
<Input
id="confirmation"
name="confirmation"
value={confirmation}
onChange={(event) => setConfirmation(event.target.value)}
placeholder={CONFIRMATION}
autoComplete="off"
/>
</Field>
<Button
type="submit"
size="lg"
variant="destructive"
disabled={pending || confirmation !== CONFIRMATION}
>
{pending ? <Loader2 className="animate-spin" /> : <Upload />}
Daten ersetzen
</Button>
</form>
</CardContent>
</Card>
</div>
);
}
+212
View File
@@ -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<string | null>(null);
const [copied, setCopied] = useState(false);
function submit(event: React.FormEvent<HTMLFormElement>) {
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 (
<div className="space-y-4">
<Button size="lg" className="w-full sm:w-auto" onClick={() => setCreateOpen(true)}>
<Plus />
Einladung erstellen
</Button>
{invites.length === 0 ? (
<p className="rounded-lg border border-dashed border-border p-6 text-center text-sm text-muted-foreground">
Noch keine Einladung. Damit bekommt zum Beispiel der zweite Elternteil ein eigenes
Konto – ohne dass die Registrierung für alle offen steht.
</p>
) : (
<ul className="divide-y divide-border rounded-lg border border-border">
{invites.map((item) => (
<li key={item.id} className="flex items-center gap-3 p-3">
<Mail className="size-5 shrink-0 text-muted-foreground" aria-hidden />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">
{item.email ?? 'Für beliebige Adresse'}
</p>
<p className="text-xs text-muted-foreground">
{item.state === 'used'
? `Verwendet von ${item.usedByName ?? 'jemandem'} am ${formatDate(item.usedAt)}`
: `Gültig bis ${formatDate(item.expiresAt)}`}
</p>
</div>
<Badge
variant={
item.state === 'open' ? 'default' : item.state === 'used' ? 'secondary' : 'outline'
}
>
{item.state === 'open' ? 'Offen' : item.state === 'used' ? 'Verwendet' : 'Abgelaufen'}
</Badge>
{item.role === 'admin' ? <Badge variant="outline">Admin</Badge> : null}
<Button
variant="ghost"
size="icon-sm"
aria-label="Einladung löschen"
onClick={() => remove(item.id)}
disabled={pending}
>
<Trash2 className="text-destructive" />
</Button>
</li>
))}
</ul>
)}
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Einladung erstellen</DialogTitle>
<DialogDescription>
Du bekommst einen Link, den du weitergibst. Er funktioniert genau einmal.
</DialogDescription>
</DialogHeader>
<form id="invite-form" onSubmit={submit} className="space-y-4">
<Field
label="E-Mail"
htmlFor="invite-email"
hint="Optional. Wenn gesetzt, funktioniert der Link nur mit dieser Adresse."
>
<Input id="invite-email" name="email" type="email" inputMode="email" maxLength={160} />
</Field>
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Rolle" htmlFor="invite-role">
<NativeSelect id="invite-role" name="role" defaultValue="member">
<option value="member">Mitglied</option>
<option value="admin">Administrator</option>
</NativeSelect>
</Field>
<Field label="Gültig für" htmlFor="invite-days">
<NativeSelect id="invite-days" name="validDays" defaultValue="14">
<option value="1">1 Tag</option>
<option value="7">7 Tage</option>
<option value="14">14 Tage</option>
<option value="30">30 Tage</option>
</NativeSelect>
</Field>
</div>
</form>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)} disabled={pending}>
Abbrechen
</Button>
<Button type="submit" form="invite-form" disabled={pending}>
{pending ? <Loader2 className="animate-spin" /> : <Plus />}
Erstellen
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={link !== null} onOpenChange={(open) => !open && setLink(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Einladungslink</DialogTitle>
<DialogDescription>
Der Link wird nur jetzt angezeigt – in der Datenbank liegt nur sein Hash. Kopiere
ihn und schick ihn weiter.
</DialogDescription>
</DialogHeader>
<Input
readOnly
aria-label="Einladungslink"
value={link ?? ''}
onFocus={(event) => event.currentTarget.select()}
className="font-mono text-xs"
/>
<DialogFooter>
<Button variant="outline" onClick={copy}>
{copied ? <Check className="text-emerald-600" /> : <Copy />}
Link kopieren
</Button>
<Button onClick={() => setLink(null)}>Fertig</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+8 -5
View File
@@ -5,17 +5,20 @@ import { usePathname } from 'next/navigation';
import { cn } from 'cn'; import { cn } from 'cn';
const tabs = [ const tabs = [
{ href: '/verwaltung/hersteller', label: 'Hersteller' }, { href: '/verwaltung/hersteller', label: 'Hersteller', adminOnly: false },
{ href: '/verwaltung/themen', label: 'Themen' }, { href: '/verwaltung/themen', label: 'Themen', adminOnly: false },
{ href: '/verwaltung/standorte', label: 'Standorte' }, { href: '/verwaltung/standorte', label: 'Standorte', adminOnly: false },
{ href: '/verwaltung/einladungen', label: 'Einladungen', adminOnly: true },
{ href: '/verwaltung/daten', label: 'Daten', adminOnly: true },
] as const; ] as const;
export function VerwaltungNav() { export function VerwaltungNav({ isAdmin }: { isAdmin: boolean }) {
const pathname = usePathname(); const pathname = usePathname();
const visible = tabs.filter((tab) => !tab.adminOnly || isAdmin);
return ( return (
<nav className="flex gap-1 overflow-x-auto rounded-lg bg-muted p-1" aria-label="Stammdaten"> <nav className="flex gap-1 overflow-x-auto rounded-lg bg-muted p-1" aria-label="Stammdaten">
{tabs.map((tab) => { {visible.map((tab) => {
const active = pathname === tab.href; const active = pathname === tab.href;
return ( return (
<Link <Link
+16 -1
View File
@@ -31,8 +31,23 @@ const schema = z.object({
REBRICKABLE_API_KEY: z.string().min(1).optional(), REBRICKABLE_API_KEY: z.string().min(1).optional(),
}); });
/**
* An empty value counts as "not set".
*
* A line like `BASE_URL=` in a .env file is a common slip, and so is an empty
* variable injected by a container runtime. Falling back to the default beats
* failing to start with "Invalid URL".
*/
function withoutEmptyValues(source: NodeJS.ProcessEnv): Record<string, string> {
const result: Record<string, string> = {};
for (const [key, value] of Object.entries(source)) {
if (typeof value === 'string' && value.trim() !== '') result[key] = value;
}
return result;
}
function parseEnv() { function parseEnv() {
const parsed = schema.safeParse(process.env); const parsed = schema.safeParse(withoutEmptyValues(process.env));
if (!parsed.success) { if (!parsed.success) {
const issues = parsed.error.issues const issues = parsed.error.issues
.map((issue) => ` - ${issue.path.join('.')}: ${issue.message}`) .map((issue) => ` - ${issue.path.join('.')}: ${issue.message}`)
+12 -4
View File
@@ -6,6 +6,7 @@ import { nextCookies } from 'better-auth/next-js';
import { count } from 'drizzle-orm'; import { count } from 'drizzle-orm';
import { db, schema } from '@/db'; import { db, schema } from '@/db';
import { env } from '@/env'; import { env } from '@/env';
import { currentInviteGrant } from './invites';
/** /**
* Returns true while the instance has no user at all. * Returns true while the instance has no user at all.
@@ -90,15 +91,22 @@ export const auth = betterAuth({
user: { user: {
create: { create: {
before: async (user) => { before: async (user) => {
const firstRun = isFirstRun(); // The very first account owns the instance.
if (!firstRun) { if (isFirstRun()) {
// Phase 2 opens this up again for invited users. return { data: { ...user, role: 'admin' as const } };
}
// Everyone after that needs an invitation. The grant is set by the
// server action that verified the link token, never by the client.
const grant = currentInviteGrant();
if (!grant) {
throw new APIError('FORBIDDEN', { throw new APIError('FORBIDDEN', {
message: message:
'Die Registrierung ist geschlossen. Bitte lass dir vom Administrator einen Einladungslink schicken.', 'Die Registrierung ist geschlossen. Bitte lass dir vom Administrator einen Einladungslink schicken.',
}); });
} }
return { data: { ...user, role: 'admin' as const } };
return { data: { ...user, role: grant.role } };
}, },
}, },
}, },
+41
View File
@@ -0,0 +1,41 @@
/**
* Barcode helpers.
*
* Kept free of server-only imports so both the scanner in the browser and the
* lookup on the server use the very same rules.
*/
/** Digits only - scanners and humans disagree about spaces and dashes. */
export function normalizeEan(input: string): string {
return input.replace(/\D/g, '');
}
/**
* Checks an EAN-8, EAN-13 or UPC-A against its check digit.
*
* Worth doing: a camera occasionally reads a digit wrong, and without this a
* misread would silently create a second catalog entry for a set that is
* already there.
*/
export function isValidEan(input: string): boolean {
const digits = normalizeEan(input);
if (![8, 12, 13].includes(digits.length)) return false;
// From the right: every second digit counts three times.
const checkDigit = Number(digits[digits.length - 1]);
const body = digits.slice(0, -1);
let sum = 0;
for (let index = body.length - 1, weight = 3; index >= 0; index -= 1, weight = weight === 3 ? 1 : 3) {
sum += Number(body[index]) * weight;
}
return (10 - (sum % 10)) % 10 === checkDigit;
}
/** "4005556603985" -> "4 005556 603985" for readable display. */
export function formatEan(input: string): string {
const digits = normalizeEan(input);
if (digits.length !== 13) return digits;
return `${digits.slice(0, 1)} ${digits.slice(1, 7)} ${digits.slice(7)}`;
}
+304
View File
@@ -0,0 +1,304 @@
import 'server-only';
import fs from 'node:fs';
import path from 'node:path';
import { zipSync, unzipSync, strToU8, strFromU8 } from 'fflate';
import { db } from '@/db';
import * as schema from '@/db/schema';
import { env } from '@/env';
import { formatDate } from './format';
/**
* Export and import of the whole collection.
*
* What this is: a portable copy of the data - to move to another machine, to
* look at outside the app, or as a second safety net.
*
* What this is not: a replacement for backing up ./data. Accounts, passwords
* and passkeys are deliberately left out, because a JSON file full of password
* hashes in someone's download folder is a liability, not a backup.
*/
export const EXPORT_FORMAT_VERSION = 1;
export type ExportPayload = {
formatVersion: number;
exportedAt: string;
app: string;
data: {
manufacturers: unknown[];
themes: unknown[];
sets: unknown[];
tags: unknown[];
setTags: unknown[];
instructions: unknown[];
locations: unknown[];
collectionItems: unknown[];
missingParts: unknown[];
photos: unknown[];
wishlists: unknown[];
wishlistItems: unknown[];
};
};
/** Everything except accounts and sessions. */
export function buildExportPayload(): ExportPayload {
return {
formatVersion: EXPORT_FORMAT_VERSION,
exportedAt: new Date().toISOString(),
app: 'bricktrack',
data: {
manufacturers: db.select().from(schema.manufacturer).all(),
themes: db.select().from(schema.theme).all(),
sets: db.select().from(schema.set).all(),
tags: db.select().from(schema.tag).all(),
setTags: db.select().from(schema.setTag).all(),
instructions: db.select().from(schema.instruction).all(),
locations: db.select().from(schema.location).all(),
collectionItems: db.select().from(schema.collectionItem).all(),
missingParts: db.select().from(schema.missingPart).all(),
photos: db.select().from(schema.photo).all(),
// Reservations are personal data of people without an account and have
// no meaning outside the running instance - so they stay behind.
wishlists: db
.select()
.from(schema.wishlist)
.all()
.map(({ shareToken, ...rest }) => ({ ...rest, shareToken: null })),
wishlistItems: db
.select()
.from(schema.wishlistItem)
.all()
.map(({ reservationTokenHash, reservedByName, reservedAt, ...rest }) => rest),
},
};
}
/** Collects every uploaded file, keyed by its path relative to FILES_DIR. */
function collectFiles(): Record<string, Uint8Array> {
const files: Record<string, Uint8Array> = {};
const root = env.FILES_DIR;
if (!fs.existsSync(root)) return files;
const walk = (dir: string) => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const absolute = path.join(dir, entry.name);
if (entry.isDirectory()) {
walk(absolute);
continue;
}
const relative = path.relative(root, absolute).split(path.sep).join('/');
files[`files/${relative}`] = new Uint8Array(fs.readFileSync(absolute));
}
};
walk(root);
return files;
}
/** One archive with the data and every uploaded file. */
export function buildExportZip(): Uint8Array {
const payload = buildExportPayload();
return zipSync(
{
'bricktrack.json': strToU8(JSON.stringify(payload, null, 2)),
...collectFiles(),
},
// Level 6 is the sweet spot: JPEGs and PDFs barely compress anyway.
{ level: 6 },
);
}
export function exportFileName(extension: string): string {
const today = formatDate(new Date()).split('.').reverse().join('-');
return `bricktrack-${today}.${extension}`;
}
// ---------------------------------------------------------------------------
// CSV
// ---------------------------------------------------------------------------
function csvCell(value: unknown): string {
if (value === null || value === undefined) return '';
const text = String(value);
return /[";\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
}
/**
* The collection as CSV, semicolon separated with a BOM - that is what German
* Excel opens without asking questions.
*/
export function buildCollectionCsv(
rows: Array<Record<string, string | number | null>>,
columns: Array<{ key: string; label: string }>,
): string {
const lines = [columns.map((column) => csvCell(column.label)).join(';')];
for (const row of rows) {
lines.push(columns.map((column) => csvCell(row[column.key])).join(';'));
}
return `${lines.join('\r\n')}\r\n`;
}
// ---------------------------------------------------------------------------
// Import
// ---------------------------------------------------------------------------
export class ImportError extends Error {}
export type ImportSummary = {
sets: number;
collectionItems: number;
wishlists: number;
files: number;
};
type ArchiveContents = {
payload: ExportPayload;
files: Record<string, Uint8Array>;
};
function readArchive(bytes: Uint8Array): ArchiveContents {
let entries: Record<string, Uint8Array>;
try {
entries = unzipSync(bytes);
} catch {
throw new ImportError('Die Datei ist kein gültiges Bricktrack-Archiv (ZIP erwartet).');
}
const json = entries['bricktrack.json'];
if (!json) {
throw new ImportError('Im Archiv fehlt bricktrack.json. Stammt es wirklich aus Bricktrack?');
}
let payload: ExportPayload;
try {
payload = JSON.parse(strFromU8(json)) as ExportPayload;
} catch {
throw new ImportError('bricktrack.json lässt sich nicht lesen.');
}
if (payload.app !== 'bricktrack' || typeof payload.data !== 'object') {
throw new ImportError('Das Archiv stammt nicht aus Bricktrack.');
}
if (payload.formatVersion > EXPORT_FORMAT_VERSION) {
throw new ImportError(
`Das Archiv wurde mit einer neueren Version erstellt (Format ${payload.formatVersion}). Bitte zuerst Bricktrack aktualisieren.`,
);
}
const files: Record<string, Uint8Array> = {};
for (const [name, content] of Object.entries(entries)) {
if (name.startsWith('files/') && !name.endsWith('/')) {
files[name.slice('files/'.length)] = content;
}
}
return { payload, files };
}
/** Guards against a path escaping FILES_DIR through the archive. */
function safeTarget(relative: string): string | null {
const absolute = path.resolve(env.FILES_DIR, relative);
const root = path.resolve(env.FILES_DIR);
return absolute.startsWith(root + path.sep) ? absolute : null;
}
/**
* Replaces all application data with the contents of the archive.
*
* Deliberately a replacement and not a merge: merging two collections would
* need conflict rules for every table, and the result would be impossible to
* predict. Wishlists are handed to the importing admin, since the original
* owner's account does not travel with the archive.
*/
export function importArchive(bytes: Uint8Array, ownerUserId: string): ImportSummary {
const { payload, files } = readArchive(bytes);
const data = payload.data;
db.transaction((tx) => {
// Order matters only for readability - the foreign keys cascade anyway.
tx.delete(schema.wishlistItem).run();
tx.delete(schema.wishlist).run();
tx.delete(schema.missingPart).run();
tx.delete(schema.photo).run();
tx.delete(schema.instruction).run();
tx.delete(schema.collectionItem).run();
tx.delete(schema.setTag).run();
tx.delete(schema.set).run();
tx.delete(schema.tag).run();
tx.delete(schema.theme).run();
tx.delete(schema.manufacturer).run();
tx.delete(schema.location).run();
const insertAll = (table: never, rows: unknown[]) => {
if (!Array.isArray(rows) || rows.length === 0) return;
// Insert in batches: SQLite caps the number of bound variables.
for (let index = 0; index < rows.length; index += 100) {
tx.insert(table)
.values(rows.slice(index, index + 100) as never)
.run();
}
};
insertAll(schema.manufacturer as never, revive(data.manufacturers));
insertAll(schema.location as never, revive(data.locations));
insertAll(schema.theme as never, revive(data.themes));
insertAll(schema.set as never, revive(data.sets));
insertAll(schema.tag as never, revive(data.tags));
insertAll(schema.setTag as never, revive(data.setTags));
insertAll(schema.instruction as never, revive(data.instructions));
insertAll(schema.collectionItem as never, revive(data.collectionItems));
insertAll(schema.missingPart as never, revive(data.missingParts));
insertAll(schema.photo as never, revive(data.photos));
const wishlists = revive(data.wishlists).map((row) => ({
...(row as Record<string, unknown>),
ownerUserId,
// Fresh tokens: links from the exporting instance must not resurrect.
shareToken: crypto.randomUUID().replace(/-/g, ''),
isPublic: false,
}));
insertAll(schema.wishlist as never, wishlists);
insertAll(schema.wishlistItem as never, revive(data.wishlistItems));
});
let restored = 0;
for (const [relative, content] of Object.entries(files)) {
const target = safeTarget(relative);
if (!target) continue;
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, content);
restored += 1;
}
return {
sets: data.sets?.length ?? 0,
collectionItems: data.collectionItems?.length ?? 0,
wishlists: data.wishlists?.length ?? 0,
files: restored,
};
}
/**
* JSON has no date type, so timestamps come back as strings. Drizzle expects
* Date objects for its timestamp columns.
*/
function revive(rows: unknown[]): unknown[] {
if (!Array.isArray(rows)) return [];
return rows.map((row) => {
if (typeof row !== 'object' || row === null) return row;
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(row as Record<string, unknown>)) {
const looksLikeDate =
typeof value === 'string' &&
/(At|Date)$/.test(key) &&
/^\d{4}-\d{2}-\d{2}T/.test(value);
result[key] = looksLikeDate ? new Date(value) : value;
}
return result;
});
}
+140
View File
@@ -0,0 +1,140 @@
import 'server-only';
import { AsyncLocalStorage } from 'node:async_hooks';
import { and, desc, eq, isNull, gt } from 'drizzle-orm';
import { db } from '@/db';
import { invite, user } from '@/db/schema';
import type { UserRole } from '@/db/types';
import { createToken, hashToken } from './tokens';
/**
* Invitations.
*
* After the first user, sign-up is closed. An admin issues a link; the
* plaintext token only ever lives in that link, the database keeps its
* SHA-256 hash.
*/
export type InviteGrant = { inviteId: string; role: UserRole };
/**
* Marks the current call stack as "covered by a valid invitation".
*
* better-auth's user-create hook has no way of receiving extra arguments, and
* the sign-up payload must not be trusted with a role. AsyncLocalStorage keeps
* the grant tied to the one server action that verified the token - it can
* neither leak into another request nor be forged from the client.
*/
const grantStorage = new AsyncLocalStorage<InviteGrant>();
export function withInviteGrant<T>(grant: InviteGrant, fn: () => Promise<T>): Promise<T> {
return grantStorage.run(grant, fn);
}
export function currentInviteGrant(): InviteGrant | undefined {
return grantStorage.getStore();
}
export const INVITE_VALID_DAYS = 14;
export function createInvite(input: {
createdByUserId: string;
email: string | null;
role: UserRole;
validDays?: number;
}): { id: string; token: string; expiresAt: Date } {
const token = createToken();
const expiresAt = new Date(
Date.now() + (input.validDays ?? INVITE_VALID_DAYS) * 24 * 60 * 60 * 1000,
);
const id = crypto.randomUUID();
db.insert(invite)
.values({
id,
tokenHash: hashToken(token),
email: input.email,
role: input.role,
createdByUserId: input.createdByUserId,
expiresAt,
createdAt: new Date(),
})
.run();
return { id, token, expiresAt };
}
export type UsableInvite = {
id: string;
email: string | null;
role: UserRole;
expiresAt: Date;
};
/** Returns the invite behind a link token, or null if it cannot be used. */
export function findUsableInvite(token: string): UsableInvite | null {
if (!token) return null;
const row = db
.select({
id: invite.id,
email: invite.email,
role: invite.role,
expiresAt: invite.expiresAt,
})
.from(invite)
.where(
and(
eq(invite.tokenHash, hashToken(token)),
isNull(invite.usedAt),
gt(invite.expiresAt, new Date()),
),
)
.get();
return row ?? null;
}
export function markInviteUsed(inviteId: string, userId: string): void {
db.update(invite)
.set({ usedAt: new Date(), usedByUserId: userId })
.where(eq(invite.id, inviteId))
.run();
}
export function deleteInvite(inviteId: string): void {
db.delete(invite).where(eq(invite.id, inviteId)).run();
}
export type InviteRow = {
id: string;
email: string | null;
role: UserRole;
expiresAt: Date;
usedAt: Date | null;
usedByName: string | null;
createdAt: Date;
};
export function listInvites(): InviteRow[] {
return db
.select({
id: invite.id,
email: invite.email,
role: invite.role,
expiresAt: invite.expiresAt,
usedAt: invite.usedAt,
usedByName: user.name,
createdAt: invite.createdAt,
})
.from(invite)
.leftJoin(user, eq(user.id, invite.usedByUserId))
.orderBy(desc(invite.createdAt))
.all();
}
/** An invite is only interesting while it can still be used. */
export function inviteState(row: InviteRow): 'used' | 'expired' | 'open' {
if (row.usedAt) return 'used';
if (row.expiresAt.getTime() <= Date.now()) return 'expired';
return 'open';
}
+18
View File
@@ -160,6 +160,24 @@ export function getSetById(id: string) {
return row ?? null; return row ?? null;
} }
/**
* Lookup by barcode. EANs are stored as typed, so the comparison strips
* everything but digits on both sides - a scanner never produces the spaces
* and dashes people write by hand.
*/
export function findSetByEan(ean: string) {
const digits = ean.replace(/\D/g, '');
if (digits.length === 0) return null;
return (
db
.select({ id: set.id, setNumber: set.setNumber, name: set.name })
.from(set)
.where(sql`replace(replace(coalesce(${set.ean}, ''), '-', ''), ' ', '') = ${digits}`)
.get() ?? null
);
}
export function getSetByNumber(manufacturerId: string, setNumber: string) { export function getSetByNumber(manufacturerId: string, setNumber: string) {
return ( return (
db db
+50 -2
View File
@@ -1,7 +1,7 @@
import 'server-only'; import 'server-only';
import { and, asc, countDistinct, eq, sql, type SQL } from 'drizzle-orm'; import { and, asc, countDistinct, eq, inArray, sql, type SQL } from 'drizzle-orm';
import { db } from '@/db'; import { db } from '@/db';
import { collectionItem, location, manufacturer, set, theme } from '@/db/schema'; import { collectionItem, location, manufacturer, missingPart, set, theme } from '@/db/schema';
import { SOLD_STATUS } from '@/lib/ownership'; import { SOLD_STATUS } from '@/lib/ownership';
import type { CollectionStatus, Completeness, Condition } from '@/db/types'; import type { CollectionStatus, Completeness, Condition } from '@/db/types';
@@ -158,3 +158,51 @@ export function countMissingSets(): number {
return row?.total ?? 0; return row?.total ?? 0;
} }
export type MissingPartRow = {
id: string;
partNumber: string | null;
colorName: string | null;
quantity: number;
resolved: boolean;
notes: string | null;
};
export function listMissingParts(collectionItemId: string): MissingPartRow[] {
return db
.select({
id: missingPart.id,
partNumber: missingPart.partNumber,
colorName: missingPart.colorName,
quantity: missingPart.quantity,
resolved: missingPart.resolved,
notes: missingPart.notes,
})
.from(missingPart)
.where(eq(missingPart.collectionItemId, collectionItemId))
// Open entries first: those are the ones you act on.
.orderBy(asc(missingPart.resolved), asc(missingPart.createdAt))
.all();
}
/** Open missing parts per collection item, for the badges in the lists. */
export function countOpenMissingParts(collectionItemIds: string[]): Map<string, number> {
if (collectionItemIds.length === 0) return new Map();
const rows = db
.select({
collectionItemId: missingPart.collectionItemId,
open: sql<number>`count(*)`.mapWith(Number),
})
.from(missingPart)
.where(
and(
inArray(missingPart.collectionItemId, collectionItemIds),
eq(missingPart.resolved, false),
),
)
.groupBy(missingPart.collectionItemId)
.all();
return new Map(rows.map((row) => [row.collectionItemId, row.open]));
}
+140
View File
@@ -0,0 +1,140 @@
import 'server-only';
import { env } from '@/env';
import { displaySetNumber, normalizeSetNumber } from './set-number';
export { displaySetNumber, legoInstructionsUrl, normalizeSetNumber } from './set-number';
/**
* Rebrickable lookup for Lego sets.
*
* Entirely optional: without REBRICKABLE_API_KEY the import stays switched
* off and everything keeps working by hand. Every request runs here on the
* server - the browser never talks to rebrickable.com.
*/
const API_BASE = 'https://rebrickable.com/api/v3/lego';
const REQUEST_TIMEOUT_MS = 10_000;
export function isRebrickableEnabled(): boolean {
return Boolean(env.REBRICKABLE_API_KEY);
}
export class RebrickableError extends Error {}
export type RebrickableSet = {
setNumber: string;
name: string;
year: number | null;
pieceCount: number | null;
themeId: number | null;
imageUrl: string | null;
externalUrl: string | null;
};
export type RebrickableTheme = {
id: number;
name: string;
parentId: number | null;
};
async function request<T>(path: string): Promise<T | null> {
const key = env.REBRICKABLE_API_KEY;
if (!key) throw new RebrickableError('Für den Import fehlt der Rebrickable-API-Key.');
let response: Response;
try {
response = await fetch(`${API_BASE}${path}`, {
headers: {
Authorization: `key ${key}`,
Accept: 'application/json',
},
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
// The catalog is not a moving target; a day of caching is plenty.
next: { revalidate: 86_400 },
});
} catch (error) {
console.error('[rebrickable] Anfrage fehlgeschlagen', path, error);
throw new RebrickableError('Rebrickable ist gerade nicht erreichbar.');
}
if (response.status === 404) return null;
if (response.status === 401 || response.status === 403) {
throw new RebrickableError('Der Rebrickable-API-Key wird abgelehnt. Bitte prüf ihn.');
}
if (response.status === 429) {
throw new RebrickableError('Rebrickable drosselt gerade. Bitte in einer Minute noch einmal.');
}
if (!response.ok) {
throw new RebrickableError(`Rebrickable antwortet mit Status ${response.status}.`);
}
return (await response.json()) as T;
}
type SetResponse = {
set_num: string;
name: string;
year: number | null;
theme_id: number | null;
num_parts: number | null;
set_img_url: string | null;
set_url: string | null;
};
export async function fetchSet(setNumber: string): Promise<RebrickableSet | null> {
const data = await request<SetResponse>(`/sets/${encodeURIComponent(normalizeSetNumber(setNumber))}/`);
if (!data) return null;
return {
setNumber: displaySetNumber(data.set_num),
name: data.name,
year: data.year ?? null,
pieceCount: data.num_parts ?? null,
themeId: data.theme_id ?? null,
imageUrl: data.set_img_url ?? null,
externalUrl: data.set_url ?? null,
};
}
type ThemeResponse = { id: number; name: string; parent_id: number | null };
export async function fetchTheme(themeId: number): Promise<RebrickableTheme | null> {
const data = await request<ThemeResponse>(`/themes/${themeId}/`);
if (!data) return null;
return { id: data.id, name: data.name, parentId: data.parent_id ?? null };
}
type MinifigsResponse = { results: Array<{ quantity: number | null }> };
/** Number of minifigures, or null when the lookup does not work out. */
export async function fetchMinifigCount(setNumber: string): Promise<number | null> {
try {
const data = await request<MinifigsResponse>(
`/sets/${encodeURIComponent(normalizeSetNumber(setNumber))}/minifigs/`,
);
if (!data) return null;
return data.results.reduce((total, row) => total + (row.quantity ?? 0), 0);
} catch {
// A missing minifig list must not fail the whole import.
return null;
}
}
/**
* Downloads the set image so it keeps working when Rebrickable changes or
* removes it. Returns null instead of throwing: an import without a picture
* is still a good import.
*/
export async function fetchImageBytes(url: string): Promise<Buffer | null> {
try {
const response = await fetch(url, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
if (!response.ok) return null;
const buffer = Buffer.from(await response.arrayBuffer());
return buffer.byteLength > 0 ? buffer : null;
} catch (error) {
console.error('[rebrickable] Bild konnte nicht geladen werden', url, error);
return null;
}
}
+32
View File
@@ -0,0 +1,32 @@
/**
* Set-number handling for the Lego import.
*
* Deliberately free of server-only imports so it can be unit tested and, if
* ever needed, used on the client.
*/
/**
* Rebrickable numbers a set's variant explicitly: the box says "60398", the
* API knows "60398-1". People type what is printed on the box, so the suffix
* is added when it is missing.
*/
export function normalizeSetNumber(input: string): string {
const trimmed = input.trim().toUpperCase();
return /-\d+$/.test(trimmed) ? trimmed : `${trimmed}-1`;
}
/** The number as it should be stored and shown: without the "-1" variant. */
export function displaySetNumber(setNum: string): string {
return setNum.trim().replace(/-1$/, '');
}
/**
* Lego publishes building instructions per set number. Rebrickable's API does
* not expose instruction files, so the import links to Lego's own service page
* rather than pretending to have the PDF.
*/
export function legoInstructionsUrl(setNumber: string): string {
return `https://www.lego.com/de-de/service/building-instructions/${encodeURIComponent(
displaySetNumber(setNumber),
)}`;
}
+18 -1
View File
@@ -88,8 +88,25 @@ export type StoredImage = {
*/ */
export async function storeImage(file: File, kind: UploadKind, ownerId: string): Promise<StoredImage> { export async function storeImage(file: File, kind: UploadKind, ownerId: string): Promise<StoredImage> {
assertSize(file); assertSize(file);
return storeImageBytes(Buffer.from(await file.arrayBuffer()), kind, ownerId);
}
/**
* Same as `storeImage`, but for bytes we already hold - for instance an image
* the server fetched from an import source. The size limit still applies.
*/
export async function storeImageBytes(
bytes: Buffer,
kind: UploadKind,
ownerId: string,
): Promise<StoredImage> {
if (bytes.byteLength === 0) throw new UploadError('Die Datei ist leer.');
if (bytes.byteLength > env.MAX_UPLOAD_BYTES) {
throw new UploadError(
`Das Bild ist größer als ${env.MAX_UPLOAD_MB} MB und wurde nicht übernommen.`,
);
}
const bytes = Buffer.from(await file.arrayBuffer());
const mime = detectMimeType(bytes); const mime = detectMimeType(bytes);
if (!mime || !IMAGE_MIME_TYPES.includes(mime as (typeof IMAGE_MIME_TYPES)[number])) { if (!mime || !IMAGE_MIME_TYPES.includes(mime as (typeof IMAGE_MIME_TYPES)[number])) {
throw new UploadError('Erlaubt sind nur JPEG-, PNG- und WebP-Bilder.'); throw new UploadError('Erlaubt sind nur JPEG-, PNG- und WebP-Bilder.');
+16
View File
@@ -147,6 +147,22 @@ export const collectionItemSchema = z.object({
notes: optionalText(2000), notes: optionalText(2000),
}); });
/**
* A missing part needs at least something identifying: a part number or a
* note. An entry with neither says nothing and would only clutter the list.
*/
export const missingPartSchema = z
.object({
partNumber: optionalText(40),
colorName: optionalText(40),
quantity: z.coerce.number().int().min(1).max(999).default(1),
notes: optionalText(300),
})
.refine(
(value) => value.partNumber !== null || value.notes !== null,
'Bitte gib mindestens die Teilenummer oder eine Notiz an.',
);
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Wishlists // Wishlists
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+3 -1
View File
@@ -24,9 +24,11 @@ export const config = {
* Everything except the public surface: * Everything except the public surface:
* - /anmelden, /registrieren, /einladung (auth pages) * - /anmelden, /registrieren, /einladung (auth pages)
* - /w/... (public wishlists) * - /w/... (public wishlists)
* - /offline (shown by the service worker)
* - /api/auth, /api/health, /api/files (handled by their own guards) * - /api/auth, /api/health, /api/files (handled by their own guards)
* - sw.js, manifest, icons (must load before a session exists)
* - Next internals and static assets * - Next internals and static assets
*/ */
'/((?!anmelden|registrieren|einladung|w/|api/auth|api/health|api/files|_next/static|_next/image|favicon.ico|icons/|manifest.webmanifest).*)', '/((?!anmelden|registrieren|einladung|offline|w/|api/auth|api/health|api/files|_next/static|_next/image|favicon.ico|favicon.png|icons/|sw.js|manifest.webmanifest).*)',
], ],
}; };
+17
View File
@@ -0,0 +1,17 @@
/**
* Baseline environment for unit tests.
*
* src/env.ts validates on import and refuses to start on a bad configuration -
* which is the point in production, but in tests every module that reads the
* environment would fail before its first assertion. These are placeholders,
* never anything a test asserts on.
*
* BASE_URL is overwritten unconditionally: Vite uses that very name for its
* public base path and sets it to "/", which is not a URL. The clash only
* exists under Vite-based tooling, never in the app itself.
*/
// NODE_ENV is typed read-only; vitest already sets it to 'test'.
process.env.BASE_URL = 'http://localhost:3000';
process.env.BETTER_AUTH_SECRET =
process.env.BETTER_AUTH_SECRET || 'test-secret-mindestens-32-zeichen-lang-0123';
process.env.DATA_DIR = process.env.DATA_DIR || './data-test';
+8
View File
@@ -0,0 +1,8 @@
/**
* Stub for the `server-only` marker package.
*
* The real module throws on import outside a React Server Component, which
* would make every server module untestable. Importing it is a build-time
* guard, not behaviour, so an empty module is the honest replacement here.
*/
export {};
+48
View File
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest';
import { formatEan, isValidEan, normalizeEan } from '@/lib/ean';
describe('EAN normalisieren', () => {
it('behaelt nur Ziffern', () => {
expect(normalizeEan('4 005556 603985')).toBe('4005556603985');
expect(normalizeEan('4005556-603985')).toBe('4005556603985');
});
});
describe('EAN pruefen', () => {
it('akzeptiert gueltige EAN-13', () => {
// Playmobil 70392 und ein Lego-Beispiel.
expect(isValidEan('4008789703927')).toBe(true);
expect(isValidEan('5702017152677')).toBe(true);
});
it('akzeptiert gueltige EAN-8 und UPC-A', () => {
expect(isValidEan('96385074')).toBe(true);
expect(isValidEan('036000291452')).toBe(true);
});
it('lehnt eine falsche Pruefziffer ab', () => {
// Letzte Ziffer verdreht - genau der Fall, den eine Kamera produziert.
expect(isValidEan('4008789703928')).toBe(false);
expect(isValidEan('5702017152678')).toBe(false);
});
it('lehnt falsche Laengen ab', () => {
expect(isValidEan('')).toBe(false);
expect(isValidEan('123')).toBe(false);
expect(isValidEan('40087897039271')).toBe(false);
});
it('ignoriert Trennzeichen bei der Pruefung', () => {
expect(isValidEan('4 008789 703927')).toBe(true);
});
});
describe('EAN anzeigen', () => {
it('gruppiert eine EAN-13 lesbar', () => {
expect(formatEan('4008789703927')).toBe('4 008789 703927');
});
it('laesst andere Laengen unveraendert', () => {
expect(formatEan('96385074')).toBe('96385074');
});
});
+313
View File
@@ -0,0 +1,313 @@
import fs from 'node:fs';
import path from 'node:path';
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
// A throwaway database for this file alone, so the round trip can delete
// everything without touching the development data.
const DATA_DIR = path.resolve(process.cwd(), 'data-test-export');
process.env.DATA_DIR = DATA_DIR;
fs.rmSync(DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(path.join(DATA_DIR, 'db'), { recursive: true });
fs.mkdirSync(path.join(DATA_DIR, 'files', 'images', 'sets'), { recursive: true });
{
const sqlite = new Database(path.join(DATA_DIR, 'db', 'bricktrack.db'));
migrate(drizzle(sqlite), { migrationsFolder: path.resolve(process.cwd(), 'drizzle') });
sqlite.close();
}
const { db } = await import('@/db');
const schema = await import('@/db/schema');
const { buildExportPayload, buildExportZip, importArchive, ImportError, buildCollectionCsv } =
await import('@/lib/export');
const now = new Date('2026-09-07T10:00:00.000Z');
const USER_ID = 'user-1';
const OTHER_USER_ID = 'user-2';
function seed() {
db.insert(schema.user)
.values([
{
id: USER_ID,
name: 'Florian',
email: 'florian@example.test',
emailVerified: false,
role: 'admin',
createdAt: now,
updatedAt: now,
},
{
id: OTHER_USER_ID,
name: 'Mama',
email: 'mama@example.test',
emailVerified: false,
role: 'member',
createdAt: now,
updatedAt: now,
},
])
.run();
db.insert(schema.manufacturer)
.values({ id: 'm-1', name: 'Lego', slug: 'lego', createdAt: now, updatedAt: now })
.run();
db.insert(schema.theme)
.values({ id: 't-1', manufacturerId: 'm-1', name: 'City', createdAt: now, updatedAt: now })
.run();
db.insert(schema.set)
.values({
id: 's-1',
manufacturerId: 'm-1',
themeId: 't-1',
setNumber: '60398',
name: 'Familienhaus und Elektroauto',
year: 2023,
pieceCount: 462,
retailPriceCents: 4999,
imagePath: 'images/sets/s-1/bild.webp',
createdAt: now,
updatedAt: now,
})
.run();
db.insert(schema.collectionItem)
.values({
id: 'c-1',
setId: 's-1',
status: 'built',
completeness: 'missing_parts',
acquiredPriceCents: 4500,
createdAt: now,
updatedAt: now,
})
.run();
db.insert(schema.missingPart)
.values({
id: 'p-1',
collectionItemId: 'c-1',
partNumber: '3001',
colorName: 'Rot',
quantity: 2,
resolved: false,
createdAt: now,
})
.run();
db.insert(schema.wishlist)
.values({
id: 'w-1',
ownerUserId: OTHER_USER_ID,
title: 'Weihnachten 2026',
occasion: 'christmas',
shareToken: 'geheimer-token-der-nicht-wandern-darf',
isPublic: true,
surpriseMode: false,
createdAt: now,
updatedAt: now,
})
.run();
db.insert(schema.wishlistItem)
.values({
id: 'wi-1',
wishlistId: 'w-1',
setId: 's-1',
priority: 1,
sortOrder: 0,
reservedByName: 'Tante Anne',
reservedAt: now,
reservationTokenHash: 'a'.repeat(64),
createdAt: now,
})
.run();
fs.mkdirSync(path.join(DATA_DIR, 'files', 'images', 'sets', 's-1'), { recursive: true });
fs.writeFileSync(
path.join(DATA_DIR, 'files', 'images', 'sets', 's-1', 'bild.webp'),
Buffer.from([1, 2, 3, 4, 5]),
);
}
function clearAll() {
db.delete(schema.wishlistItem).run();
db.delete(schema.wishlist).run();
db.delete(schema.missingPart).run();
db.delete(schema.collectionItem).run();
db.delete(schema.set).run();
db.delete(schema.theme).run();
db.delete(schema.manufacturer).run();
db.delete(schema.user).run();
}
beforeAll(() => {
clearAll();
});
beforeEach(() => {
clearAll();
seed();
});
describe('Export', () => {
it('nimmt Katalog, Sammlung und Wunschlisten mit', () => {
const payload = buildExportPayload();
expect(payload.app).toBe('bricktrack');
expect(payload.data.manufacturers).toHaveLength(1);
expect(payload.data.sets).toHaveLength(1);
expect(payload.data.collectionItems).toHaveLength(1);
expect(payload.data.missingParts).toHaveLength(1);
expect(payload.data.wishlists).toHaveLength(1);
expect(payload.data.wishlistItems).toHaveLength(1);
});
it('nimmt weder Teilen-Token noch Reservierungen mit', () => {
const payload = buildExportPayload();
const serialized = JSON.stringify(payload);
expect(serialized).not.toContain('geheimer-token-der-nicht-wandern-darf');
expect(serialized).not.toContain('Tante Anne');
expect(serialized).not.toContain('a'.repeat(64));
});
it('nimmt keine Konten mit', () => {
const serialized = JSON.stringify(buildExportPayload());
expect(serialized).not.toContain('florian@example.test');
expect(serialized).not.toContain('mama@example.test');
});
it('packt die hochgeladenen Dateien ins Archiv', () => {
const archive = buildExportZip();
expect(archive.byteLength).toBeGreaterThan(0);
// Der Dateiname steht im ZIP-Verzeichnis im Klartext.
expect(Buffer.from(archive).toString('latin1')).toContain('files/images/sets/s-1/bild.webp');
});
});
describe('Import', () => {
it('stellt einen vollstaendigen Rundweg wieder her', () => {
const archive = buildExportZip();
clearAll();
// Der importierende Admin muss existieren - die Konten reisen nicht mit.
db.insert(schema.user)
.values({
id: USER_ID,
name: 'Florian',
email: 'florian@example.test',
emailVerified: false,
role: 'admin',
createdAt: now,
updatedAt: now,
})
.run();
const summary = importArchive(archive, USER_ID);
expect(summary.sets).toBe(1);
expect(summary.collectionItems).toBe(1);
expect(summary.wishlists).toBe(1);
expect(summary.files).toBeGreaterThan(0);
const sets = db.select().from(schema.set).all();
expect(sets).toHaveLength(1);
expect(sets[0]).toMatchObject({
setNumber: '60398',
name: 'Familienhaus und Elektroauto',
pieceCount: 462,
retailPriceCents: 4999,
});
// Zeitstempel muessen als Date zurueckkommen, nicht als String.
expect(sets[0]!.createdAt).toBeInstanceOf(Date);
expect(db.select().from(schema.missingPart).all()).toHaveLength(1);
});
it('uebergibt die Wunschlisten dem importierenden Admin und teilt sie nicht', () => {
const archive = buildExportZip();
clearAll();
db.insert(schema.user)
.values({
id: USER_ID,
name: 'Florian',
email: 'florian@example.test',
emailVerified: false,
role: 'admin',
createdAt: now,
updatedAt: now,
})
.run();
importArchive(archive, USER_ID);
const [list] = db.select().from(schema.wishlist).all();
expect(list?.ownerUserId).toBe(USER_ID);
expect(list?.isPublic).toBe(false);
expect(list?.shareToken).not.toBe('geheimer-token-der-nicht-wandern-darf');
expect(list?.shareToken.length).toBeGreaterThan(16);
});
it('ersetzt vorhandene Daten, statt sie zu vermischen', () => {
const archive = buildExportZip();
// Zweites Set, das im Archiv nicht vorkommt.
db.insert(schema.set)
.values({
id: 's-2',
manufacturerId: 'm-1',
setNumber: '99999',
name: 'Wird ersetzt',
createdAt: now,
updatedAt: now,
})
.run();
importArchive(archive, USER_ID);
const numbers = db
.select({ setNumber: schema.set.setNumber })
.from(schema.set)
.all()
.map((row) => row.setNumber);
expect(numbers).toEqual(['60398']);
});
it('lehnt ein fremdes Archiv ab, ohne etwas zu loeschen', () => {
const before = db.select().from(schema.set).all().length;
expect(() => importArchive(new Uint8Array([1, 2, 3]), USER_ID)).toThrow(ImportError);
expect(db.select().from(schema.set).all()).toHaveLength(before);
});
});
describe('CSV', () => {
it('trennt mit Semikolon und beginnt mit einer BOM', () => {
const csv = buildCollectionCsv([{ a: 'x', b: 1 }], [
{ key: 'a', label: 'Spalte A' },
{ key: 'b', label: 'Spalte B' },
]);
expect(csv.startsWith('')).toBe(true);
expect(csv).toContain('Spalte A;Spalte B');
expect(csv).toContain('x;1');
});
it('maskiert Semikolon, Anfuehrungszeichen und Zeilenumbrueche', () => {
const csv = buildCollectionCsv(
[{ note: 'a;b "c"\nd' }],
[{ key: 'note', label: 'Notiz' }],
);
expect(csv).toContain('"a;b ""c""\nd"');
});
});
+179
View File
@@ -0,0 +1,179 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// The module reads the key through src/env, which validates on import - so
// the variable has to be in place before the dynamic import below.
process.env.REBRICKABLE_API_KEY = 'test-key';
const {
fetchImageBytes,
fetchMinifigCount,
fetchSet,
fetchTheme,
isRebrickableEnabled,
RebrickableError,
} = await import('@/lib/rebrickable');
type FetchArgs = Parameters<typeof fetch>;
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
const originalFetch = globalThis.fetch;
beforeEach(() => {
vi.restoreAllMocks();
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
function stubFetch(handler: (url: string, init?: FetchArgs[1]) => Response | Promise<Response>) {
const spy = vi.fn(async (input: FetchArgs[0], init?: FetchArgs[1]) =>
handler(String(input), init),
);
globalThis.fetch = spy as unknown as typeof fetch;
return spy;
}
const SET_RESPONSE = {
set_num: '60398-1',
name: 'Familienhaus und Elektroauto',
year: 2023,
theme_id: 52,
num_parts: 462,
set_img_url: 'https://cdn.rebrickable.com/media/sets/60398-1.jpg',
set_url: 'https://rebrickable.com/sets/60398-1/',
};
describe('Verfuegbarkeit', () => {
it('ist mit gesetztem API-Key aktiv', () => {
expect(isRebrickableEnabled()).toBe(true);
});
});
describe('Set abrufen', () => {
it('uebersetzt die Antwort in unser Format', async () => {
stubFetch(() => jsonResponse(SET_RESPONSE));
const result = await fetchSet('60398');
expect(result).toEqual({
setNumber: '60398',
name: 'Familienhaus und Elektroauto',
year: 2023,
pieceCount: 462,
themeId: 52,
imageUrl: 'https://cdn.rebrickable.com/media/sets/60398-1.jpg',
externalUrl: 'https://rebrickable.com/sets/60398-1/',
});
});
it('fragt die Nummer mit Variante ab und schickt den Key im Header', async () => {
const spy = stubFetch(() => jsonResponse(SET_RESPONSE));
await fetchSet('60398');
const [url, init] = spy.mock.calls[0]!;
expect(String(url)).toContain('/sets/60398-1/');
// Der Key gehoert in den Header, nicht in die URL - sonst steht er in
// jedem Proxy-Log.
expect(String(url)).not.toContain('test-key');
expect((init?.headers as Record<string, string>).Authorization).toBe('key test-key');
});
it('liefert null, wenn Rebrickable die Nummer nicht kennt', async () => {
stubFetch(() => new Response('', { status: 404 }));
await expect(fetchSet('99999')).resolves.toBeNull();
});
it('meldet einen abgelehnten API-Key verstaendlich', async () => {
stubFetch(() => new Response('', { status: 401 }));
await expect(fetchSet('60398')).rejects.toBeInstanceOf(RebrickableError);
await expect(fetchSet('60398')).rejects.toThrow(/API-Key/);
});
it('meldet eine Drosselung als eigenen Fall', async () => {
stubFetch(() => new Response('', { status: 429 }));
await expect(fetchSet('60398')).rejects.toThrow(/drosselt/);
});
it('macht aus einem Netzwerkfehler keine Ausnahme mit Innenleben', async () => {
stubFetch(() => {
throw new Error('ECONNREFUSED');
});
await expect(fetchSet('60398')).rejects.toThrow(/nicht erreichbar/);
});
it('kommt mit fehlenden Feldern klar', async () => {
stubFetch(() =>
jsonResponse({ set_num: '1234-1', name: 'Ohne alles', year: null, theme_id: null }),
);
const result = await fetchSet('1234');
expect(result).toMatchObject({
setNumber: '1234',
pieceCount: null,
themeId: null,
imageUrl: null,
});
});
});
describe('Thema abrufen', () => {
it('liefert Name und Oberthema', async () => {
stubFetch(() => jsonResponse({ id: 52, name: 'City', parent_id: 50 }));
await expect(fetchTheme(52)).resolves.toEqual({ id: 52, name: 'City', parentId: 50 });
});
it('behandelt ein Thema ohne Oberthema', async () => {
stubFetch(() => jsonResponse({ id: 1, name: 'Technic', parent_id: null }));
await expect(fetchTheme(1)).resolves.toEqual({ id: 1, name: 'Technic', parentId: null });
});
});
describe('Minifiguren zaehlen', () => {
it('summiert die Mengen', async () => {
stubFetch(() => jsonResponse({ results: [{ quantity: 2 }, { quantity: 3 }] }));
await expect(fetchMinifigCount('60398')).resolves.toBe(5);
});
it('laesst den Import nicht scheitern, wenn die Liste fehlt', async () => {
stubFetch(() => new Response('', { status: 500 }));
// null statt Ausnahme: ein Set ohne Minifiguren-Angabe ist kein Fehler.
await expect(fetchMinifigCount('60398')).resolves.toBeNull();
});
});
describe('Bild laden', () => {
it('gibt die Bytes zurueck', async () => {
stubFetch(() => new Response(new Uint8Array([1, 2, 3])));
const bytes = await fetchImageBytes('https://example.test/bild.jpg');
expect(bytes?.byteLength).toBe(3);
});
it('gibt null zurueck, statt den Import scheitern zu lassen', async () => {
stubFetch(() => new Response('', { status: 404 }));
await expect(fetchImageBytes('https://example.test/weg.jpg')).resolves.toBeNull();
stubFetch(() => {
throw new Error('timeout');
});
await expect(fetchImageBytes('https://example.test/lahm.jpg')).resolves.toBeNull();
});
});
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest';
import { displaySetNumber, legoInstructionsUrl, normalizeSetNumber } from '@/lib/set-number';
describe('Setnummern fuer den Rebrickable-Import', () => {
it('haengt die Variante an, wenn nur die Packungsnummer eingetippt wurde', () => {
expect(normalizeSetNumber('60398')).toBe('60398-1');
expect(normalizeSetNumber('71427')).toBe('71427-1');
});
it('laesst eine bereits vollstaendige Nummer unveraendert', () => {
expect(normalizeSetNumber('60398-1')).toBe('60398-1');
expect(normalizeSetNumber('10265-2')).toBe('10265-2');
});
it('ignoriert Leerzeichen und vereinheitlicht Grossschreibung', () => {
expect(normalizeSetNumber(' 60398 ')).toBe('60398-1');
expect(normalizeSetNumber('k8672')).toBe('K8672-1');
});
it('entfernt fuer die Anzeige nur die erste Variante', () => {
expect(displaySetNumber('60398-1')).toBe('60398');
// -2 ist eine echte zweite Variante und muss sichtbar bleiben.
expect(displaySetNumber('10265-2')).toBe('10265-2');
expect(displaySetNumber('60398')).toBe('60398');
});
it('ueberlebt den Rundweg Anzeige -> API -> Anzeige', () => {
for (const number of ['60398', '71427', '42172']) {
expect(displaySetNumber(normalizeSetNumber(number))).toBe(number);
}
});
});
describe('Link auf die LEGO-Bauanleitung', () => {
it('nutzt die Nummer ohne Variante', () => {
expect(legoInstructionsUrl('60398-1')).toBe(
'https://www.lego.com/de-de/service/building-instructions/60398',
);
});
it('maskiert Sonderzeichen in der Nummer', () => {
expect(legoInstructionsUrl('2 30500')).toContain('2%2030500');
});
});
+4
View File
@@ -8,10 +8,14 @@ export default defineConfig({
resolve: { resolve: {
alias: { alias: {
'@': path.resolve(root, './src'), '@': path.resolve(root, './src'),
// `server-only` is a build-time marker that throws when imported outside
// a server component. Stubbing it keeps server modules testable.
'server-only': path.resolve(root, './tests/stubs/server-only.ts'),
}, },
}, },
test: { test: {
environment: 'node', environment: 'node',
setupFiles: ['./tests/setup-env.ts'],
include: ['tests/unit/**/*.test.ts'], include: ['tests/unit/**/*.test.ts'],
globals: false, globals: false,
}, },