Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dcd076498a | ||
|
|
39271f8af7 | ||
|
|
45aa8f13cf | ||
|
|
5c5c5213bb | ||
|
|
baa94bf7e5 | ||
|
|
4e3cbb81ce | ||
|
|
52d69e5654 | ||
|
|
220a3303cd |
@@ -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=
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Auf **Katalog → Set anlegen** steht oben das Feld für den Import. Setnummer
|
||||||
|
von der Packung eintippen, „Holen" – und Name, Jahr, Teile, Minifiguren und
|
||||||
|
Thema stehen drin. Das Bild wird **lokal gespeichert**, funktioniert also auch
|
||||||
|
weiter, wenn die Quelle es irgendwann austauscht. Fehlen Hersteller oder
|
||||||
|
Thema noch, legt der Import sie an.
|
||||||
|
|
||||||
|
Dafür braucht es einen kostenlosen API-Key von
|
||||||
|
[rebrickable.com/api](https://rebrickable.com/api/), eingetragen als
|
||||||
|
`REBRICKABLE_API_KEY` in der `.env`. Ohne Key steht an derselben Stelle ein
|
||||||
|
Hinweis, und alles andere funktioniert unverändert weiter.
|
||||||
|
|
||||||
|
> Anleitungsdateien liefert Rebrickable nicht. Der Import legt stattdessen
|
||||||
|
> einen Link auf die LEGO-Serviceseite zur Setnummer an – von dort kommst du
|
||||||
|
> zum PDF, das du dann hochladen kannst.
|
||||||
|
|
||||||
|
### Barcode scannen
|
||||||
|
|
||||||
|
Im Katalog sitzt neben „Set anlegen" ein Scanner-Symbol. Strichcode der
|
||||||
|
Packung ins Bild halten:
|
||||||
|
|
||||||
|
- **Set schon im Katalog** → es öffnet sich direkt. Genau das, was man im
|
||||||
|
Laden vor dem Regal wissen will.
|
||||||
|
- **Noch unbekannt** → das Formular „Neues Set" öffnet sich mit
|
||||||
|
ausgefüllter EAN.
|
||||||
|
|
||||||
|
Gelesen wird ausschließlich im Browser, es geht nichts an einen fremden
|
||||||
|
Dienst. Jeder Code wird gegen seine Prüfziffer geprüft – verlesene Ziffern
|
||||||
|
landen so gar nicht erst im Katalog. Die Kamera braucht wie Passkeys HTTPS.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Fehlteile
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Beim Aufbauen fehlt ein Stein. Auf der Set-Detailseite hat jeder
|
||||||
|
Sammlungseintrag ein Puzzle-Symbol: dort trägst du ein, was fehlt – mit
|
||||||
|
Teilenummer und Farbe, wenn du sie kennst, sonst reicht eine Notiz wie
|
||||||
|
„Dachstein vorne links".
|
||||||
|
|
||||||
|
Ist das Teil nachgekauft, hakst du es ab. Die **Vollständigkeit pflegt sich
|
||||||
|
dabei selbst**: Der erste offene Eintrag setzt den Sammlungseintrag auf
|
||||||
|
„Teile fehlen", der letzte erledigte zurück auf „vollständig". Der Filter in
|
||||||
|
der Sammlung stimmt damit, ohne dass du daran denken musst.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Einladungen
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Die Registrierung ist nach dem ersten Konto geschlossen – sonst könnte sich
|
||||||
|
jeder, der die Adresse kennt, ein Konto anlegen. Für den zweiten Elternteil
|
||||||
|
gibt es deshalb **Verwaltung → Einladungen**:
|
||||||
|
|
||||||
|
1. „Einladung erstellen", optional an eine E-Mail-Adresse gebunden, mit Rolle
|
||||||
|
und Gültigkeit.
|
||||||
|
2. Den Link kopieren und weitergeben. Er wird **nur einmal angezeigt** – in
|
||||||
|
der Datenbank liegt nur sein Hash.
|
||||||
|
3. Die eingeladene Person legt darüber ihr Konto an. Danach ist der Link
|
||||||
|
verbraucht.
|
||||||
|
|
||||||
|
„Mitglied" darf die Sammlung pflegen und Wunschlisten anlegen; „Administrator"
|
||||||
|
zusätzlich einladen und Daten exportieren.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Daten
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Unter **Verwaltung → Daten** gibt es drei Downloads:
|
||||||
|
|
||||||
|
| | Wofür |
|
||||||
|
|---|---|
|
||||||
|
| **Alles als ZIP** | Umziehen auf einen anderen Server, zweites Netz |
|
||||||
|
| **Nur Daten (JSON)** | Zum Reinschauen oder Weiterverarbeiten |
|
||||||
|
| **Sammlung als CSV** | Für Excel oder LibreOffice, Semikolon-getrennt |
|
||||||
|
|
||||||
|
**Nicht im Export:** Konten, Passwörter und Passkeys. Ein Export voller
|
||||||
|
Passwort-Hashes im Download-Ordner wäre ein Risiko, kein Backup. Ebenso außen
|
||||||
|
vor: Reservierungen und Teilen-Links – die gehören zu der Instanz, auf der sie
|
||||||
|
entstanden sind.
|
||||||
|
|
||||||
|
> **Das richtige Backup bleibt `./data`.** Ein Verzeichnis, Datenbank und
|
||||||
|
> Dateien darin. Siehe [README](../README.md#backup-und-restore).
|
||||||
|
|
||||||
|
Der **Import** liest ein solches ZIP wieder ein – und **ersetzt dabei alle
|
||||||
|
vorhandenen Daten**. Zusammenführen zweier Sammlungen ist bewusst nicht
|
||||||
|
möglich: Es bräuchte Konfliktregeln für jede Tabelle, und das Ergebnis wäre
|
||||||
|
nicht vorhersagbar. Deshalb muss man zur Bestätigung `ERSETZEN` eintippen.
|
||||||
|
Wunschlisten gehören danach dir und sind zunächst nicht geteilt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Startbildschirm
|
||||||
|
|
||||||
|
Bricktrack lässt sich wie eine App installieren:
|
||||||
|
|
||||||
|
- **iPhone/iPad (Safari):** Teilen-Symbol → „Zum Home-Bildschirm".
|
||||||
|
- **Android (Chrome):** Menü → „App installieren" bzw. „Zum Startbildschirm
|
||||||
|
hinzufügen".
|
||||||
|
|
||||||
|
Danach startet Bricktrack ohne Browserleiste, und langes Drücken auf das Symbol
|
||||||
|
springt direkt zu „Fehlende Sets" oder zur Sammlung.
|
||||||
|
|
||||||
|
**Ohne Verbindung** bleibt lesbar, was du schon einmal geöffnet hast – der
|
||||||
|
Katalog also, wenn du ihn vorher im WLAN durchgeblättert hast. Ändern lässt
|
||||||
|
sich offline nichts: Eine Reservierung, die nur auf deinem Gerät liegt, wäre
|
||||||
|
für alle anderen unsichtbar und damit eine Lüge. Beim Abmelden räumt die App
|
||||||
|
den Zwischenspeicher, damit auf einem geteilten Gerät nichts lesbar bleibt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. Die zwei Fragen
|
||||||
|
|
||||||
Dafür ist die ganze App gebaut:
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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**:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 285 KiB After Width: | Height: | Size: 288 KiB |
|
Before Width: | Height: | Size: 202 KiB After Width: | Height: | Size: 201 KiB |
|
Before Width: | Height: | Size: 212 KiB After Width: | Height: | Size: 209 KiB |
|
Before Width: | Height: | Size: 252 KiB After Width: | Height: | Size: 236 KiB |
|
Before Width: | Height: | Size: 183 KiB After Width: | Height: | Size: 185 KiB |
|
Before Width: | Height: | Size: 292 KiB After Width: | Height: | Size: 293 KiB |
|
Before Width: | Height: | Size: 187 KiB After Width: | Height: | Size: 301 KiB |
|
Before Width: | Height: | Size: 294 KiB After Width: | Height: | Size: 294 KiB |
|
After Width: | Height: | Size: 211 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 257 KiB |
@@ -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' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,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/**",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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(' '),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
|
After Width: | Height: | Size: 332 B |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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.');
|
||||||
@@ -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)');
|
||||||
@@ -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.`);
|
|
||||||
@@ -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]> = [
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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() }} />
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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} />;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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}`)
|
||||||
|
|||||||
@@ -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 } };
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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)}`;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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]));
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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),
|
||||||
|
)}`;
|
||||||
|
}
|
||||||
@@ -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.');
|
||||||
|
|||||||
@@ -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
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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).*)',
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -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 {};
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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"');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||