15 Commits
Author SHA1 Message Date
unknownandClaude Opus 5 e9093d0fad Merge: Phase 3 - Nice to have (v0.3.0)
Mehrere Sammler mit gemeinsamem Katalog und getrennter Sammlung,
Preisbeobachtung mit Wertentwicklung, E-Mail-Erinnerungen vor
Wunschlisten-Terminen und die Dublettenpruefung beim Anlegen von Sets.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 14:50:43 +02:00
unknownandClaude Opus 5 e52557b4bf docs: anleitung, changelog 0.3.0 und roadmap zu phase 3
Vier neue Kapitel in der Anleitung (mehrere Sammler, Preise,
Erinnerungen, Dubletten) mit vier automatisch erzeugten Bildern,
Changelog 0.3.0 samt der bewusst weggelassenen BrickLink-Anbindung,
Roadmap abgehakt und um einen offenen Ausblick ergaenzt, Devlog mit
Zeiten und Stolpersteinen.

Dazu zwei Korrekturen aus dem Testlauf:

Der Zeitplan fuer Erinnerungen liess sich nicht aus Next'
instrumentation.ts starten - die Datei wird auch fuer die Edge-Runtime
uebersetzt, und das native SQLite-Modul bricht dort den Build. Er
startet jetzt aus dem Healthcheck heraus, den der Container ohnehin alle
30 Sekunden aufruft, und zusaetzlich aus dem App-Layout; der Aufruf ist
idempotent.

Der Screenshot-Lauf waehlte ploetzlich die falschen Kacheln aus: Der
neue Sammler-Umschalter benutzt ebenfalls aria-pressed. Die
Auswahlkacheln tragen jetzt ein eigenes data-set-card.

Ausserdem raeumen die Testskripte einen Container weg, den ein
abgebrochener Lauf zurueckgelassen hat - sonst blockiert er den Port.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 10:11:40 +02:00
unknownandClaude Opus 5 0d08df0bff feat(erinnerungen): e-mail, wenn ein wunschlisten-termin naeher rueckt
Ruecken Termin und offene Wuensche zusammen, geht eine Nachricht an den
Besitzer der Liste - standardmaessig 14 Tage vorher, hoechstens einmal
pro Woche je Liste. Ist alles reserviert, bleibt es still: eine
Erinnerung ohne Handlungsbedarf ist nur Laerm.

Der Zeitplan haengt an Next' instrumentation-Hook und prueft stuendlich,
nicht taeglich - so kann ein Neustart keinen Tag ueberspringen, und die
Sperrfrist je Liste verhindert trotzdem Mehrfachversand. Der Zeitstempel
wird erst nach erfolgreichem Versand gesetzt, damit ein Fehlversuch
wiederholt wird.

Ohne SMTP_HOST und SMTP_FROM ist die Funktion komplett aus: kein
Zeitplan, kein Versand, und die Verwaltungsseite erklaert stattdessen,
was in die .env gehoert. Mit Konfiguration gibt es dort Knoepfe fuer
Verbindungstest, Testnachricht und sofortige Pruefung.

12 Tests fuer die Entscheidungsregeln. Einer davon hat einen echten
Fehler gefunden: die Sperrfrist rechnete mit umgedrehtem Vorzeichen und
haette jede Erinnerung dauerhaft unterdrueckt.

Refs Phase 3

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 09:22:29 +02:00
unknownandClaude Opus 5 91fb66b13b feat(preise): beobachtete preise und wertentwicklung je set
Auf der Set-Detailseite lassen sich Preise notieren: Betrag, Zustand,
Datum, wo gesehen und eine Notiz. Daraus entsteht eine Reihe statt
eines einzelnen Feldes, denn die interessante Frage ist nicht "was
kostet es", sondern "was hat es seit dem Kauf gemacht".

Als Ausgangswert dient die UVP, sonst die aelteste Beobachtung; der
aktuelle Wert ist die neueste. Fallen am selben Tag zwei Beobachtungen
an, gewinnt der Neupreis - das ist die Zahl, die man zum Ersetzen
zahlen wuerde. Die Statistik-Kachel in der Sammlung zeigt entsprechend
"Aktueller Wert", sobald irgendwo ein Preis notiert ist, sonst
unveraendert "Wert nach UVP".

Bewusst nicht dabei: die Anbindung des BrickLink-Preisfuehrers. Sie war
im Plan als optional vermerkt und braucht OAuth1 mit einer pro Nutzer
registrierten Anwendung - viel Apparat fuer eine Zahl, die sich in zehn
Sekunden eintippen laesst. Die Datenstruktur ist dafuer offen: das Feld
"Wo" nimmt jede Quelle auf.

13 Tests fuer die Rechenwege, inklusive Division durch null, fehlender
UVP und Rundung.

Refs Phase 3

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 09:17:03 +02:00
unknownandClaude Opus 5 87131b8e70 feat(katalog): dublettenpruefung beim anlegen eines sets
Vor dem Speichern eines neuen Sets sucht die App nach Eintraegen, die
dasselbe sein koennten: gleiche Setnummer beim gleichen Hersteller,
sehr aehnlicher Name, oder dieselbe Nummer bei einem anderen Hersteller
- letzteres meist ein Zeichen dafuer, dass der falsche Hersteller
ausgewaehlt wurde. Die Kandidaten stehen als Liste mit Link im Dialog;
"Trotzdem anlegen" bleibt einen Klick entfernt.

Die Aehnlichkeit ist bewusst konservativ eingestellt: Tippfehler und
abweichende Schreibweisen schlagen an, zwei echte Sets desselben Themas
nicht. Die Schwelle und die Sonderfaelle (ein Name steckt im anderen,
sehr kurze Namen) sind mit Beispielen aus dem echten Katalog getestet.

Beim Bearbeiten laeuft die Pruefung nicht - dort waere der Eintrag, vor
dem gewarnt wuerde, der eigene.

Refs Phase 3

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 09:13:17 +02:00
unknownandClaude Opus 5 c23b754515 feat(sammler): mehrere sammler mit eigener sammlung und wunschliste
Der Katalog bleibt gemeinsam - ein Set existiert einmal, unabhaengig
davon, wer es hat. Besitz und Wunschlisten gehoeren dagegen je einem
Sammler. Damit beantwortet "hat er das schon?" jetzt die Frage "hat *er*
das schon?": ein Set, das nur die Schwester besitzt, faellt fuer den
Bruder wieder unter "fehlt noch" und darf auf seine Wunschliste.

Wichtig fuer den Alltag: Solange es nur einen Sammler gibt, taucht die
Unterscheidung nirgends in der Oberflaeche auf. Kein Umschalter, keine
Abfrage beim Hinzufuegen, keine Badges - die App bleibt exakt so
einfach wie vorher. Erst der zweite Sammler schaltet all das frei.

Die Besitzlogik liegt weiterhin an genau einer Stelle
(src/lib/ownership.ts) und hat nur eine Dimension dazubekommen. Das
automatische "erfuellt" auf Wunschlisten fragt jetzt ebenfalls je Liste:
ein Wunsch auf Max' Liste ist erfuellt, wenn Max das Set bekommt - nicht
wenn Mia es hat. Listen ohne Sammler verhalten sich wie bisher.

Die Migration legt einen Sammler "Sammlung" an und ordnet ihm alles
Vorhandene zu; umbenennen laesst er sich unter Verwaltung -> Sammler.
Der Seed uebernimmt ihn statt einen dritten anzulegen.

Refs Phase 3

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 09:00:35 +02:00
unknownandClaude Opus 5 7265c03548 Merge: Phase 2 - Komfort (v0.2.0)
Lego-Import ueber Rebrickable, Barcode-Scanner, Fehlteile je
Sammlungseintrag, Einladungslinks, Export und Import des gesamten
Bestands sowie die PWA mit offline lesbarem Katalog.

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

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

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

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

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

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

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

Refs Phase 2

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

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

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

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

Refs Phase 2

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

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

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

Refs Phase 2

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

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

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

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

Refs Phase 2

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

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

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

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

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

Refs Phase 2

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

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

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

Refs Phase 2

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 06:47:46 +02:00
143 changed files with 13400 additions and 298 deletions
+17 -2
View File
@@ -27,6 +27,21 @@ TZ=Europe/Berlin
# BIND_ADDRESS=127.0.0.1
# HOST_PORT=3000
# Optional (ab Phase 2): Rebrickable-API-Key fuer den Lego-Import.
# Ohne Key funktioniert alles manuell weiter.
# Optional: Rebrickable-API-Key fuer den Lego-Import. Damit reicht auf der
# Seite "Neues Set" die Setnummer, und Name, Jahr, Teile, Minifiguren, Thema
# und Bild werden uebernommen. Ohne Key funktioniert alles manuell weiter.
# Kostenlos anlegen: https://rebrickable.com/api/ (Konto -> Settings -> API)
# REBRICKABLE_API_KEY=
# Optional: Mailversand fuer Erinnerungen an Wunschlisten-Termine. Ohne
# SMTP_HOST und SMTP_FROM sind Erinnerungen komplett aus - alles andere
# funktioniert unveraendert.
# SMTP_HOST=mail.example.de
# SMTP_PORT=587
# SMTP_SECURE=false
# SMTP_USER=bricktrack@example.de
# SMTP_PASSWORD=
# SMTP_FROM=Bricktrack <bricktrack@example.de>
# Wie viele Tage vor dem Termin erinnert wird (Standard: 14).
# REMINDER_DAYS_BEFORE=14
+2
View File
@@ -23,6 +23,8 @@ next-env.d.ts
/data-shots
/data-docker
/backups
/data-test
/data-test-export
# production
*.tsbuildinfo
+109 -1
View File
@@ -7,6 +7,112 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/).
## [Unreleased]
## [0.3.0] - 2026-09-07
Phase 3: mehrere Kinder, Wertentwicklung, Erinnerungen und ein Schutz vor
doppelten Katalogeintraegen.
### Hinzugefuegt
**Mehrere Sammler**
- Der Katalog bleibt gemeinsam, Sammlung und Wunschlisten gehoeren je einem
Sammler. "Hat er das schon?" beantwortet damit die Frage fuer *ihn*: ein Set,
das nur die Schwester besitzt, faellt fuer den Bruder unter "fehlt noch".
- Umschalter in Katalog und Sammlung, Farbmarkierung je Sammler, Zuordnung im
Sammlungseintrag und je Wunschliste.
- Solange es nur einen Sammler gibt, ist davon nichts zu sehen - die App
bleibt genauso einfach wie vorher.
**Preisbeobachtung**
- Notierte Preise je Set mit Betrag, Zustand, Datum, Quelle und Notiz.
- Wertentwicklung gegen die UVP, in der Sammlung als Kachel "Aktueller Wert".
**Erinnerungen**
- E-Mail an den Besitzer, wenn der Termin einer Wunschliste naeher rueckt und
noch Wuensche offen sind - standardmaessig 14 Tage vorher, hoechstens einmal
pro Woche je Liste.
- Verwaltungsseite mit Verbindungstest, Testnachricht und sofortiger Pruefung.
- Ohne SMTP-Konfiguration bleibt die Funktion vollstaendig aus.
**Dublettenpruefung**
- Vor dem Anlegen eines Sets warnt die App vor gleicher Setnummer beim
gleichen Hersteller, sehr aehnlichem Namen und gleicher Nummer bei einem
anderen Hersteller - mit Link zum Verdaechtigen und der Moeglichkeit,
trotzdem anzulegen.
### Geaendert
- Die Migration legt einen Sammler "Sammlung" an und ordnet ihm alle
vorhandenen Sammlungseintraege und Wunschlisten zu. Umbenennen unter
Verwaltung -> Sammler.
- Der Smoke-Test laeuft gegen das Docker-Image statt gegen den Dev-Server.
### Nicht umgesetzt
- Der BrickLink-Preisfuehrer war im Plan als optional vermerkt und wurde
bewusst weggelassen: OAuth1 mit einer pro Nutzer registrierten Anwendung ist
viel Apparat fuer eine Zahl, die sich in zehn Sekunden eintippen laesst. Das
Feld "Wo" nimmt jede Quelle auf, die Datenstruktur bleibt offen.
## [0.2.0] - 2026-09-07
Phase 2: die Handgriffe, die den Alltag schneller machen.
### Hinzugefuegt
**Lego-Import (Rebrickable)**
- Auf "Neues Set" reicht die Setnummer von der Packung: Name, Jahr, Teile,
Minifiguren und Thema werden uebernommen, das Bild lokal gespeichert.
- Hersteller und Thema werden bei Bedarf angelegt, ein Oberthema inklusive.
- Alle Aufrufe laufen serverseitig. Ohne `REBRICKABLE_API_KEY` bleibt der
Import sichtbar, aber abgeschaltet - von Hand anlegen geht unveraendert.
**Barcode-Scanner**
- Knopf im Katalog: Strichcode mit der Handykamera scannen. Ist das Set
bekannt, oeffnet es sich; sonst das Formular mit ausgefuellter EAN.
- Jeder Code wird gegen seine Pruefziffer geprueft; dekodiert wird im
Browser, nichts verlaesst das Geraet.
**Fehlteile**
- Fehlende Teile je Sammlungseintrag mit Teilenummer, Farbe, Anzahl und
Notiz, abhakbar wenn nachgekauft.
- Die Vollstaendigkeit pflegt sich daraus selbst: der erste offene Eintrag
setzt "Teile fehlen", der letzte erledigte zurueck auf "vollstaendig".
**Einladungen**
- Der Admin erzeugt Registrierungslinks, die genau einmal funktionieren,
optional an eine E-Mail-Adresse gebunden, mit waehlbarer Rolle und
Gueltigkeit. In der Datenbank liegt nur der Hash des Tokens.
**Export und Import**
- Alles als ZIP (Daten plus hochgeladene Dateien), nur die Daten als JSON,
die Sammlung als CSV mit Semikolon und BOM fuer deutsches Excel.
- Import ersetzt die Anwendungsdaten in einer Transaktion, nur fuer
Administratoren und nur nach getipptem ERSETZEN.
- Nicht im Export: Konten, Passwoerter, Passkeys, Reservierungen und
Teilen-Links.
**PWA**
- Manifest, Icons und Service Worker: installierbar auf dem Startbildschirm,
mit Verknuepfungen zu "Fehlende Sets" und zur Sammlung.
- Einmal geladene Seiten bleiben ohne Verbindung lesbar; Set-Bilder und
Build-Dateien kommen aus dem Cache. Beim Abmelden wird der Cache geraeumt.
### Geaendert
- `src/env.ts` behandelt leere Werte wie nicht gesetzt: ein `BASE_URL=` in der
`.env` fuehrte bisher zum Start-Abbruch statt zum Standardwert.
- Unit-Tests koennen jetzt auch Server-Module laden (`server-only` wird in der
Testumgebung durch ein leeres Modul ersetzt).
### Bekannte Einschraenkungen
- Rebrickable liefert keine Anleitungsdateien. Der Import legt stattdessen
einen Link auf die LEGO-Serviceseite zur Setnummer an.
- Der Barcode-Scanner braucht wie Passkeys HTTPS.
- Beim Import wandern Wunschlisten an den importierenden Admin und sind
zunaechst nicht geteilt.
## [0.1.0] - 2026-09-07
Erste lauffaehige Fassung: Phase 1 vollstaendig, im Docker-Stack verifiziert.
@@ -88,5 +194,7 @@ Erste lauffaehige Fassung: Phase 1 vollstaendig, im Docker-Stack verifiziert.
- Unter Windows scheitert `pnpm build` an fehlenden Symlink-Rechten. Der
Docker-Build unter Linux ist davon nicht betroffen.
[Unreleased]: https://git.wilde-server.de/florian.wilde/bricktrack/compare/v0.1.0...HEAD
[Unreleased]: https://git.wilde-server.de/florian.wilde/bricktrack/compare/v0.3.0...HEAD
[0.3.0]: https://git.wilde-server.de/florian.wilde/bricktrack/releases/tag/v0.3.0
[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
+52 -3
View File
@@ -19,6 +19,9 @@ teilbare Wunschlisten.
| Validierung | zod (serverseitig, immer) | |
| Bilder | sharp (nur serverseitig, Thumbnails) | |
| Tests | Vitest (Unit), Playwright (E2E, Screenshots) | |
| PDF | pdfjs-dist, lokal gebuendelt | kein CDN |
| Barcode | @zxing/browser, lazy geladen | nur beim Scannen |
| Archive | fflate | Export/Import als ZIP |
| Paketmanager | pnpm 10 | |
Bewusst **kein** Prisma (Standalone-Build im Container), **keine**
@@ -43,6 +46,24 @@ Bild-URLs, die der Nutzer selbst hinterlegt hat.
- Commits: Conventional Commits, Betreff auf Deutsch, Imperativ,
erste Zeile <= 72 Zeichen.
## Wichtige Module
| Datei | Zustaendig fuer |
|---|---|
| `src/lib/ownership.ts` | die eine Definition von "hat er" / "hat er nicht" |
| `src/lib/wishlist-logic.ts` | Mehrfachauswahl, Dubletten, Prioritaeten (rein, testbar) |
| `src/lib/tokens.ts` | Share-, Storno- und Einladungs-Token |
| `src/lib/invites.ts` | Einladungen inklusive der Freigabe fuer die Registrierung |
| `src/lib/rebrickable.ts` | Lego-Import, ausschliesslich serverseitig |
| `src/lib/set-number.ts`, `src/lib/ean.ts` | Setnummern und Barcodes (rein, testbar) |
| `src/lib/uploads.ts` | Datei-Uploads, Magic Bytes, Thumbnails |
| `src/lib/similarity.ts` | Namensvergleich fuer die Dublettenpruefung (rein) |
| `src/lib/prices.ts` | Wertentwicklung aus notierten Preisen (rein) |
| `src/lib/reminders.ts` | Regeln, wann eine Erinnerung faellig ist (rein) |
| `src/lib/reminder-job.ts`, `src/lib/mailer.ts` | Versand und Zeitplan |
| `src/lib/export.ts` | Export und Import des gesamten Bestands |
| `src/lib/money.ts`, `src/lib/format.ts` | Cent-Umrechnung, dd.MM.yyyy |
## Befehle
```bash
@@ -58,8 +79,22 @@ pnpm db:studio # Drizzle Studio
pnpm test # Vitest
pnpm test:e2e # Playwright
pnpm screenshots # Screenshots fuer docs/ANLEITUNG.md erzeugen
node scripts/generate-icons.mjs # PWA-Icons aus der SVG-Quelle rendern
```
### Testkonventionen
- Unit-Tests liegen in `tests/unit`, laufen unter Node und duerfen auch
Server-Module laden: `server-only` ist in `vitest.config.mts` auf einen
leeren Stub gemappt.
- `tests/setup-env.ts` setzt Platzhalter fuer die Umgebung. `BASE_URL` wird
dort **immer** ueberschrieben, weil Vite denselben Namen fuer seinen
Public-Base-Path benutzt und ihn auf `/` setzt.
- Der Playwright-Smoke-Test nutzt `./data-e2e`, der Screenshot-Lauf
`./data-shots` gegen das Produktions-Image. Beide Verzeichnisse werden vor
jedem Lauf geleert und liegen nie im Git.
## Datenmodell
Kernregel: Ein **`set`** ist ein *Katalogeintrag* - es existiert,
@@ -83,8 +118,16 @@ Diese Bedingung steht an genau einer Stelle: `src/lib/ownership.ts`.
### Besitz
Mehrere **Sammler** teilen sich den Katalog, nicht den Besitz: `collector`
haelt Name und Farbe, `collection_item.collector_id` und
`wishlist.collector_id` ordnen zu. Solange nur ein Sammler existiert, blendet
die Oberflaeche das Thema vollstaendig aus - Umschalter, Abfragen und Badges
erscheinen erst ab dem zweiten.
| Tabelle | Zweck | Wichtige Felder |
|---|---|---|
| `collector` | ein Kind | `name`, `color`, `birthday`, `sort_order` |
| `price_point` | beobachteter Preis je Set | `price_cents`, `condition`, `observed_at`, `source` |
| `collection_item` | ein besessenes Exemplar | `status` = `owned` / `built` / `disassembled` / `lent_out` / `sold`, `completeness`, `condition`, `location_id`, `acquired_*`, `gifted_by` |
| `location` | Aufbewahrungsort, hierarchisch | `parent_location_id` |
| `missing_part` | Fehlteile je Exemplar | `part_number`, `color_name`, `quantity`, `resolved` |
@@ -145,15 +188,21 @@ Session.
serverseitig.
5. Session-Cookies: `httpOnly`, `secure`, `sameSite=lax`.
Rate-Limiting auf den Login-Endpunkten.
6. Der Export enthaelt **keine** Konten, Passwoerter oder Passkeys - und
keine Reservierungen oder Teilen-Token. Das Backup ist `./data`, nicht der
Export.
7. Der Service Worker beantwortet nur GET-Anfragen aus dem Cache und niemals
`/api/auth`, `/api/export` oder den Healthcheck. Beim Abmelden wird der
Cache geleert.
## Phasenplan
- **Phase 1 (MVP)** - Auth mit Passkeys, Stammdaten, Katalog, Sammlung,
Wunschlisten inkl. oeffentlicher Ansicht, Docker-Stack.
Wunschlisten inkl. oeffentlicher Ansicht, Docker-Stack. *Fertig in 0.1.0.*
- **Phase 2 (Komfort)** - Rebrickable-Import, Barcode-Scanner, Fehlteile,
Einladungen, Export/Import, PWA.
Einladungen, Export/Import, PWA. *Fertig in 0.2.0.*
- **Phase 3 (Nice to have)** - mehrere Sammler, Preisbeobachtung,
E-Mail-Erinnerungen, Dublettenpruefung.
E-Mail-Erinnerungen, Dublettenpruefung. *Fertig in 0.3.0.*
Details und Stand: `docs/ROADMAP.md`. Zeitaufwaende: `docs/DEVLOG.md`.
+17 -2
View File
@@ -29,6 +29,9 @@ du per Link mit Familie und Freunden teilst. Die reservieren dort ihr Geschenk
| **Fotos** | Eigene Bilder je Set – direkt aus der Handykamera |
| **Wunschlisten** | Mehrfachauswahl aus den fehlenden Sets, Priorität, Reihenfolge, Termin |
| **Teilen** | Öffentlicher Link ohne Anmeldung, Reservierung mit Namen, Storno-Link, Überraschungsmodus |
| **Mehrere Kinder** | Gemeinsamer Katalog, getrennte Sammlungen und Wunschlisten – sichtbar erst ab dem zweiten Sammler |
| **Preise** | Notierte Preise je Set, Wertentwicklung gegen die UVP |
| **Erinnerungen** | E-Mail, wenn ein Termin näher rückt und noch Wünsche offen sind (SMTP optional) |
| **Anmeldung** | E-Mail und Passwort oder Passkey (WebAuthn), mehrere Geräte je Konto |
Mobil zuerst gebaut: Die App wird im Laden und im Kinderzimmer benutzt, nicht
@@ -81,7 +84,9 @@ Alle Werte kommen aus `.env` (Vorlage: `.env.example`).
| `TZ` | nein | `Europe/Berlin` | Zeitzone des Containers. |
| `BIND_ADDRESS` | nein | `127.0.0.1` | Auf welcher Host-Adresse der Container lauscht. `0.0.0.0`, wenn der Proxy auf einem anderen Host läuft. |
| `HOST_PORT` | nein | `3000` | Port auf dem Host, falls 3000 belegt ist. |
| `REBRICKABLE_API_KEY` | nein | – | Ab Version 0.2.0: Lego-Import. Ohne Key läuft alles manuell. |
| `REBRICKABLE_API_KEY` | nein | – | Lego-Import. Ohne Key läuft alles manuell. |
| `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`, `SMTP_FROM` | nein | – | Mailversand für Wunschlisten-Erinnerungen. Ohne `SMTP_HOST` und `SMTP_FROM` ist die Funktion aus. |
| `REMINDER_DAYS_BEFORE` | nein | `14` | Wie viele Tage vor dem Termin erinnert wird. |
### Reverse Proxy
@@ -172,10 +177,16 @@ pnpm dev # http://localhost:3000
| `pnpm build` | Produktionsbuild (standalone) |
| `pnpm lint` / `pnpm typecheck` | ESLint / TypeScript – beide müssen sauber sein |
| `pnpm test` | Unit-Tests (Vitest) |
| `pnpm test:e2e` | Playwright-Smoke-Test gegen ein eigenes `./data-e2e` |
| `pnpm test:e2e` | Playwright-Smoke-Test gegen das Docker-Image (eigenes `./data-e2e`) |
| `pnpm db:generate` | SQL-Migration aus dem Drizzle-Schema erzeugen |
| `pnpm db:migrate` | Migrationen anwenden |
| `pnpm db:studio` | Drizzle Studio |
| `pnpm screenshots` | Bilder für `docs/ANLEITUNG.md` neu erzeugen |
`pnpm test:e2e` und `pnpm screenshots` bauen jeweils das Docker-Image und
fahren es hoch. Sie brauchen also einen laufenden Docker-Daemon, testen dafür
aber genau das Artefakt, das später auch deployt wird – der Dev-Server hat
unter der Last eines ganzen Durchlaufs reproduzierbar Chunks verloren.
> **Unter Windows** schlägt `pnpm build` mit `EPERM … symlink` fehl: der
> Standalone-Output legt Symlinks an, wofür Windows den Entwicklermodus oder
@@ -202,6 +213,10 @@ Ein verkauftes Exemplar zählt also nicht mehr als Besitz – das Set taucht
wieder unter „fehlt noch" auf und darf zurück auf eine Wunschliste. Diese
Regel steht an genau einer Stelle im Code: `src/lib/ownership.ts`.
Gibt es mehrere **Sammler**, bekommt die Frage ein „für wen": Der Katalog ist
gemeinsam, der Besitz nicht. Ein Set, das nur das Geschwisterkind hat, fehlt
dem anderen weiterhin.
Das vollständige Modell inklusive aller Tabellen: [CLAUDE.md](CLAUDE.md#datenmodell).
## Sicherheit
+246 -5
View File
@@ -22,7 +22,16 @@ Kinderzimmer, nicht für den Schreibtisch.
8. [Wunschlisten](#8-wunschlisten)
9. [Teilen – so sehen es Oma und Tante Anne](#9-teilen)
10. [Passkeys einrichten](#10-passkeys-einrichten)
11. [Antworten auf die zwei wichtigen Fragen](#11-die-zwei-fragen)
11. [Sets im Schnellverfahren: Import und Scanner](#11-import-und-scanner)
12. [Fehlteile](#12-fehlteile)
13. [Noch ein Konto für die Familie](#13-einladungen)
14. [Daten sichern, umziehen, auswerten](#14-daten)
15. [Auf den Startbildschirm legen](#15-startbildschirm)
16. [Zwei Kinder, ein Katalog](#16-mehrere-sammler)
17. [Was ein Set heute wert ist](#17-preise)
18. [Erinnerung vor dem Termin](#18-erinnerungen)
19. [Schutz vor doppelten Einträgen](#19-dubletten)
20. [Antworten auf die zwei wichtigen Fragen](#20-die-zwei-fragen)
---
@@ -291,7 +300,238 @@ gib ihnen sprechende Namen.
---
## 11. Die zwei Fragen
## 11. Import und Scanner
Sets von Hand einzutippen ist der langsame Weg. Für Lego gibt es zwei
schnellere.
### Lego-Import über die Setnummer
![Lego-Import](bilder/17-lego-import.png)
Auf **Katalog → Set anlegen** steht oben das Feld für den Import. Setnummer
von der Packung eintippen, „Holen" – und Name, Jahr, Teile, Minifiguren und
Thema stehen drin. Das Bild wird **lokal gespeichert**, funktioniert also auch
weiter, wenn die Quelle es irgendwann austauscht. Fehlen Hersteller oder
Thema noch, legt der Import sie an.
Dafür braucht es einen kostenlosen API-Key von
[rebrickable.com/api](https://rebrickable.com/api/), eingetragen als
`REBRICKABLE_API_KEY` in der `.env`. Ohne Key steht an derselben Stelle ein
Hinweis, und alles andere funktioniert unverändert weiter.
> Anleitungsdateien liefert Rebrickable nicht. Der Import legt stattdessen
> einen Link auf die LEGO-Serviceseite zur Setnummer an – von dort kommst du
> zum PDF, das du dann hochladen kannst.
### Barcode scannen
Im Katalog sitzt neben „Set anlegen" ein Scanner-Symbol. Strichcode der
Packung ins Bild halten:
- **Set schon im Katalog** → es öffnet sich direkt. Genau das, was man im
Laden vor dem Regal wissen will.
- **Noch unbekannt** → das Formular „Neues Set" öffnet sich mit
ausgefüllter EAN.
Gelesen wird ausschließlich im Browser, es geht nichts an einen fremden
Dienst. Jeder Code wird gegen seine Prüfziffer geprüft – verlesene Ziffern
landen so gar nicht erst im Katalog. Die Kamera braucht wie Passkeys HTTPS.
---
## 12. Fehlteile
![Fehlteile](bilder/18-fehlteile.png)
Beim Aufbauen fehlt ein Stein. Auf der Set-Detailseite hat jeder
Sammlungseintrag ein Puzzle-Symbol: dort trägst du ein, was fehlt – mit
Teilenummer und Farbe, wenn du sie kennst, sonst reicht eine Notiz wie
„Dachstein vorne links".
Ist das Teil nachgekauft, hakst du es ab. Die **Vollständigkeit pflegt sich
dabei selbst**: Der erste offene Eintrag setzt den Sammlungseintrag auf
„Teile fehlen", der letzte erledigte zurück auf „vollständig". Der Filter in
der Sammlung stimmt damit, ohne dass du daran denken musst.
---
## 13. Einladungen
![Einladungen](bilder/19-einladungen.png)
Die Registrierung ist nach dem ersten Konto geschlossen – sonst könnte sich
jeder, der die Adresse kennt, ein Konto anlegen. Für den zweiten Elternteil
gibt es deshalb **Verwaltung → Einladungen**:
1. „Einladung erstellen", optional an eine E-Mail-Adresse gebunden, mit Rolle
und Gültigkeit.
2. Den Link kopieren und weitergeben. Er wird **nur einmal angezeigt** – in
der Datenbank liegt nur sein Hash.
3. Die eingeladene Person legt darüber ihr Konto an. Danach ist der Link
verbraucht.
„Mitglied" darf die Sammlung pflegen und Wunschlisten anlegen; „Administrator"
zusätzlich einladen und Daten exportieren.
---
## 14. Daten
![Daten](bilder/20-daten.png)
Unter **Verwaltung → Daten** gibt es drei Downloads:
| | Wofür |
|---|---|
| **Alles als ZIP** | Umziehen auf einen anderen Server, zweites Netz |
| **Nur Daten (JSON)** | Zum Reinschauen oder Weiterverarbeiten |
| **Sammlung als CSV** | Für Excel oder LibreOffice, Semikolon-getrennt |
**Nicht im Export:** Konten, Passwörter und Passkeys. Ein Export voller
Passwort-Hashes im Download-Ordner wäre ein Risiko, kein Backup. Ebenso außen
vor: Reservierungen und Teilen-Links – die gehören zu der Instanz, auf der sie
entstanden sind.
> **Das richtige Backup bleibt `./data`.** Ein Verzeichnis, Datenbank und
> Dateien darin. Siehe [README](../README.md#backup-und-restore).
Der **Import** liest ein solches ZIP wieder ein – und **ersetzt dabei alle
vorhandenen Daten**. Zusammenführen zweier Sammlungen ist bewusst nicht
möglich: Es bräuchte Konfliktregeln für jede Tabelle, und das Ergebnis wäre
nicht vorhersagbar. Deshalb muss man zur Bestätigung `ERSETZEN` eintippen.
Wunschlisten gehören danach dir und sind zunächst nicht geteilt.
---
## 15. Startbildschirm
Bricktrack lässt sich wie eine App installieren:
- **iPhone/iPad (Safari):** Teilen-Symbol → „Zum Home-Bildschirm".
- **Android (Chrome):** Menü → „App installieren" bzw. „Zum Startbildschirm
hinzufügen".
Danach startet Bricktrack ohne Browserleiste, und langes Drücken auf das Symbol
springt direkt zu „Fehlende Sets" oder zur Sammlung.
**Ohne Verbindung** bleibt lesbar, was du schon einmal geöffnet hast – der
Katalog also, wenn du ihn vorher im WLAN durchgeblättert hast. Ändern lässt
sich offline nichts: Eine Reservierung, die nur auf deinem Gerät liegt, wäre
für alle anderen unsichtbar und damit eine Lüge. Beim Abmelden räumt die App
den Zwischenspeicher, damit auf einem geteilten Gerät nichts lesbar bleibt.
---
## 16. Mehrere Sammler
![Sammler verwalten](bilder/21-sammler.png)
Kommt ein zweites Kind dazu, wird aus „hat er das schon?" die Frage „hat
**er** das schon?". Genau dafür gibt es Sammler.
Der **Katalog bleibt gemeinsam**: Ein Set existiert einmal, unabhängig davon,
wer es besitzt. **Sammlung und Wunschlisten** gehören dagegen je einem
Sammler. Ein Set, das nur die Schwester hat, fällt für den Bruder wieder unter
„fehlt noch" – und darf auf seine Wunschliste.
> **Solange es nur einen Sammler gibt, siehst du davon nichts.** Kein
> Umschalter, keine Abfrage, keine Farben. Die App bleibt genauso einfach wie
> vorher. Erst der zweite Sammler schaltet all das frei – und dann willst du
> es auch.
Anlegen unter **Verwaltung → Sammler**. Jeder bekommt einen Namen und eine
Farbe zur Unterscheidung.
![Nach Sammler filtern](bilder/22-sammler-filter.png)
Danach steht in Katalog und Sammlung oben ein Umschalter. „Alle zusammen"
zeigt den Haushalt als Ganzes, ein Name zeigt genau dessen Sicht – inklusive
der Frage, was **diesem** Kind noch fehlt.
Beim Hinzufügen zur Sammlung fragt die App jetzt, wer das Set bekommt. Und
beim Anlegen einer Wunschliste, für wen sie ist: Das bestimmt, wessen Sammlung
als „hat er schon" zählt und wann ein Wunsch automatisch als erfüllt gilt.
> Die Umstellung einer bestehenden Instanz macht nichts kaputt: Beim Update
> entsteht ein Sammler namens „Sammlung", dem alles Vorhandene gehört.
> Umbenennen unter **Verwaltung → Sammler**, fertig.
---
## 17. Preise
![Preis und Wertentwicklung](bilder/23-preise.png)
Auf jeder Set-Seite gibt es **„Preis notieren"**: Betrag, Zustand (neu oder
gebraucht), wann und wo gesehen. Daraus wird eine Reihe – und damit eine
Antwort auf die eigentlich interessante Frage: Was hat das Set seit dem Kauf
gemacht?
- **Ursprünglich** ist die UVP des Sets, sonst der älteste notierte Preis.
- **Aktuell** ist der neueste notierte Preis.
- **Entwicklung** ist die Differenz in Prozent.
In der Sammlung wird die Kachel „Wert nach UVP" automatisch zu **„Aktueller
Wert"**, sobald irgendwo ein Preis notiert ist.
> Es wird **kein** Preisdienst abgefragt. Was hier steht, hast du selbst
> eingetragen – das ist ehrlicher als eine Zahl, deren Herkunft niemand kennt,
> und es geht nichts nach draußen.
---
## 18. Erinnerungen
![Erinnerungen](bilder/24-erinnerungen.png)
Wenn ein Wunschlisten-Termin näher rückt und **noch Wünsche offen sind**,
schickt Bricktrack dir eine E-Mail – standardmäßig 14 Tage vorher, höchstens
einmal pro Woche je Liste. Ist alles reserviert, bleibt es still: eine
Erinnerung ohne Handlungsbedarf ist nur Lärm.
Dafür braucht es einen SMTP-Zugang in der `.env`:
```bash
SMTP_HOST=mail.example.de
SMTP_PORT=587
SMTP_USER=bricktrack@example.de
SMTP_PASSWORD=…
SMTP_FROM=Bricktrack <bricktrack@example.de>
# Optional, Standard ist 14:
REMINDER_DAYS_BEFORE=14
```
Unter **Verwaltung → Erinnerungen** kannst du die Verbindung prüfen, dir eine
Testnachricht schicken und die Prüfung sofort auslösen, statt auf die nächste
Stunde zu warten. Dort steht auch, welche Listen einen Termin haben und wie
viel dort noch offen ist.
Ohne SMTP-Zugang ist die Funktion vollständig aus – alles andere funktioniert
unverändert.
---
## 19. Dubletten
Beim Anlegen eines neuen Sets schaut Bricktrack nach, ob es das schon gibt:
- **gleiche Setnummer beim gleichen Hersteller** – dann ist es dasselbe Set,
- **sehr ähnlicher Name** – meist ein Tippfehler oder eine andere Schreibweise,
- **gleiche Nummer bei einem anderen Hersteller** – meist der falsche
Hersteller im Formular.
Passt etwas, erscheint ein Hinweis mit Link zu den Verdächtigen. Von dort
kommst du direkt zum vorhandenen Eintrag – oder legst mit „Trotzdem anlegen"
weiter an, wenn es wirklich ein anderes Set ist.
Die Erkennung ist bewusst zurückhaltend eingestellt: Tippfehler schlagen an,
zwei echte Sets desselben Themas nicht.
---
## 20. Die zwei Fragen
Dafür ist die ganze App gebaut:
@@ -300,10 +540,11 @@ Dafür ist die ganze App gebaut:
**„Welche Sets hat er noch nicht?"**
→ Katalog, Filter „Fehlt noch". Von dort in zwei Handgriffen auf eine
Wunschliste und per Link zu Oma.
Wunschliste und per Link zu Oma. Bei mehreren Kindern beantwortet der
Umschalter oben die Frage für jedes einzeln.
Im Laden vor dem Regal: Katalog öffnen, Setnummer von der Packung eintippen.
Steht ein grüner Haken dran, habt ihr es schon.
Im Laden vor dem Regal: Scanner öffnen, Strichcode einlesen. Steht ein grüner
Haken dran, habt ihr es schon.
---
+68
View File
@@ -29,6 +29,36 @@ Commit-Spalte verweist auf den Stand am Ende des Schritts.
**Summe Phase 1: 2:44**
## Phase 2 – Komfort (v0.2.0)
### 07.09.2026
| Von | Bis | Dauer | Schritt | Ergebnis | Commit |
|---|---|---|---|---|---|
| 06:50 | 07:01 | 0:11 | Einladungen | Admin erzeugt einmal gültige Registrierungslinks; Freigabe über AsyncLocalStorage, damit die Rolle nicht aus dem Formular kommen kann. Smoke-Test deckt den ganzen Weg ab | `52d69e5` |
| 07:01 | 07:12 | 0:11 | Rebrickable-Import | Setnummer genügt: Metadaten, Thema und lokal gespeichertes Bild; 14 Tests gegen gestubbtes `fetch` inklusive aller Fehlerfälle | `4e3cbb8` |
| 07:12 | 07:15 | 0:03 | Barcode-Scanner | EAN mit der Handykamera, Prüfziffer-Kontrolle, zxing wird erst beim Öffnen geladen | `baa94bf` |
| 07:15 | 07:18 | 0:03 | Fehlteile | Liste je Sammlungseintrag; Vollständigkeit pflegt sich daraus selbst | `5c5c521` |
| 07:18 | 07:23 | 0:05 | Export und Import | ZIP, JSON und CSV; Import ersetzt in einer Transaktion, 10 Tests für den Rundweg | `45aa8f1` |
| 07:23 | 07:27 | 0:04 | PWA | Manifest, Icons aus SVG-Quelle, Service Worker mit Offline-Katalog | `39271f8` |
| 07:27 | 07:45 | 0:18 | Verifikation und Dokumentation | Smoke-Test, Docker-Build, Changelog 0.2.0, Anleitung und Roadmap | folgt |
**Summe Phase 2: 0:55**
## Phase 3 – Nice to have (v0.3.0)
### 07.09.2026
| Von | Bis | Dauer | Schritt | Ergebnis | Commit |
|---|---|---|---|---|---|
| 08:35 | 09:00 | 0:25 | Mehrere Sammler | Neue Tabelle, Migration mit Nachziehen der Bestandsdaten, Besitzlogik um eine Dimension erweitert, Umschalter in Katalog und Sammlung, Zuordnung in Sammlungseintrag und Wunschliste | `c23b754` |
| 09:00 | 09:13 | 0:13 | Dublettenprüfung | Ähnlichkeitsvergleich mit konservativer Schwelle, Warndialog mit Links, E2E-Test | `87131b8` |
| 09:13 | 09:17 | 0:04 | Preisbeobachtung | Preisreihe je Set, Wertentwicklung gegen die UVP, Kachel „Aktueller Wert" | `91fb66b` |
| 09:17 | 09:22 | 0:05 | Erinnerungen | SMTP optional, stündliche Prüfung, Sperrfrist je Liste, Verwaltungsseite mit Testfunktionen | `0d08df0` |
| 09:22 | 09:50 | 0:28 | Verifikation und Dokumentation | Docker-Build, Smoke-Test, Screenshots, Changelog 0.3.0, Anleitung | folgt |
**Summe Phase 3: 1:15**
---
## Notizen und Entscheidungen
@@ -86,6 +116,39 @@ Commit-Spalte verweist auf den Stand am Ende des Schritts.
Produktions-Image im Container – verlässlich und zugleich näher an dem, was
Nutzer wirklich sehen.
### Phase 2
- **`server-only` in Tests**: Das Paket wirft beim Import ausserhalb einer
Server-Komponente, wodurch sich Servermodule nicht testen liessen. In
`vitest.config.mts` ist es jetzt auf einen leeren Stub gemappt – der Import
ist eine Build-Zeit-Absicherung, kein Verhalten.
- **Namenskonflikt `BASE_URL`**: Vite benutzt genau diesen Namen für seinen
Public-Base-Path und setzt ihn auf `/`. Die Tests überschreiben ihn deshalb
ausdrücklich. Dabei ist aufgefallen, dass `src/env.ts` bei einem leeren Wert
abbrach statt auf den Standard zurückzufallen – behoben, denn ein
`BASE_URL=` in der `.env` ist ein realistischer Tippfehler.
- **Rebrickable und Anleitungen**: Die API v3 stellt keine Anleitungsdateien
bereit. Statt das zu verschleiern, legt der Import einen externen Link auf
die LEGO-Serviceseite zur Setnummer an.
- **Import statt Zusammenführen**: Zwei Sammlungen zu mischen bräuchte
Konfliktregeln für jede Tabelle und wäre im Ergebnis nicht vorhersagbar.
Der Import ersetzt deshalb – dafür mit getipptem `ERSETZEN` als Bremse.
### Phase 3
- **Migration mit Bestandsdaten**: Die generierte SQL-Datei wurde von Hand um
drei Anweisungen ergänzt, die einen Sammler anlegen und alles Vorhandene
zuordnen. Ohne das wären nach dem Update alle Sammlungseinträge ohne
Besitzer gewesen.
- **Instrumentation und native Module**: Der Zeitplan für Erinnerungen liess
sich nicht aus Next' `instrumentation.ts` starten – die Datei wird auch für
die Edge-Runtime übersetzt, und das native SQLite-Modul bricht dort den
Build. Der Zeitplan startet jetzt aus dem Healthcheck heraus, den der
Container ohnehin alle 30 Sekunden aufruft.
- **BrickLink bewusst weggelassen**: OAuth1 mit einer pro Nutzer registrierten
Anwendung ist viel Apparat für eine Zahl, die man in zehn Sekunden eintippt.
Im Plan war der Punkt als optional vermerkt.
### Beim Testen gefunden und behoben
- Eine spontan aus dem Katalog angelegte Wunschliste war **nicht teilbar**:
@@ -96,3 +159,8 @@ Commit-Spalte verweist auf den Stand am Ende des Schritts.
`required` des Eingabefeldes.
- In der Mehrfachauswahl verdeckte die Aktionsleiste die letzte Kachelreihe;
das Raster bekommt im Auswahlmodus zusätzliches Polster nach unten.
- Die Sperrfrist der Erinnerungen rechnete mit umgedrehtem Vorzeichen und
hätte jede Erinnerung dauerhaft unterdrückt. Der Test dafür war zuerst da.
- Der Screenshot-Lauf wählte plötzlich die falschen Kacheln aus: Der neue
Sammler-Umschalter benutzt ebenfalls `aria-pressed`. Die Auswahlkacheln
tragen jetzt ein eigenes `data-set-card`.
+26 -13
View File
@@ -34,24 +34,37 @@ Laeuft vollstaendig im Docker-Stack.
- [x] Dokumentation: README, QUICKSTART, bebilderte Anleitung,
update.sh, Nginx-Proxy-Manager-Konfiguration
## Phase 2 - Komfort (Ziel: v0.2.0)
## Phase 2 - Komfort (v0.2.0, abgeschlossen am 07.09.2026)
- [ ] Rebrickable-Import: Setnummer eingeben, Metadaten und Bild
- [x] Rebrickable-Import: Setnummer eingeben, Metadaten und Bild
uebernehmen, Bild lokal cachen. Ohne API-Key bleibt alles manuell.
- [ ] Barcode-Scanner (EAN) per Handykamera, Suche nach EAN, bei
- [x] Barcode-Scanner (EAN) per Handykamera, Suche nach EAN, bei
Unbekanntem direkt ins Formular "Neues Set"
- [ ] Fehlteile je Sammlungseintrag pflegen
- [ ] Einladungslinks durch den Admin
- [ ] Export/Import: gesamte Datenbank als JSON, Dateien als ZIP,
- [x] Fehlteile je Sammlungseintrag pflegen
- [x] Einladungslinks durch den Admin
- [x] Export/Import: gesamte Datenbank als JSON, Dateien als ZIP,
Sammlung zusaetzlich als CSV
- [ ] PWA: Manifest, Service Worker, Katalog offline lesbar
- [x] PWA: Manifest, Service Worker, Katalog offline lesbar
## Phase 3 - Nice to have (Ziel: v0.3.0)
## Phase 3 - Nice to have (v0.3.0, abgeschlossen am 07.09.2026)
- [ ] Mehrere Sammler mit eigener Sammlung und eigenen Wunschlisten,
- [x] Mehrere Sammler mit eigener Sammlung und eigenen Wunschlisten,
gemeinsamer Katalog
- [ ] Preisbeobachtung und Wertentwicklung (manuell gepflegt,
optional BrickLink-Preisfuehrer)
- [ ] E-Mail-Erinnerung (SMTP), wenn ein Wunschlisten-Termin naeher
- [x] Preisbeobachtung und Wertentwicklung (manuell gepflegt).
Der BrickLink-Preisfuehrer war als optional vermerkt und wurde
bewusst weggelassen: er verlangt OAuth1 mit einer pro Nutzer
registrierten Anwendung - viel Apparat fuer eine Zahl, die sich in
zehn Sekunden eintippen laesst. Die Datenstruktur ist offen dafuer.
- [x] E-Mail-Erinnerung (SMTP), wenn ein Wunschlisten-Termin naeher
rueckt und Eintraege unreserviert sind
- [ ] Dublettenpruefung beim Anlegen von Sets
- [x] Dublettenpruefung beim Anlegen von Sets
## Danach
Phase 1 bis 3 sind umgesetzt. Was noch offen ist, steht hier - ohne Termin
und ohne Anspruch auf Vollstaendigkeit:
- [ ] BrickLink-Preisfuehrer anbinden, falls der Aufwand sich doch lohnt
- [ ] Mehrere Fotos je Sammlungseintrag (bisher nur je Set)
- [ ] Anleitungen im PDF-Viewer durchsuchen
- [ ] Statistik ueber die Zeit: was kam wann dazu
Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 285 KiB

After

Width:  |  Height:  |  Size: 294 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 202 KiB

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 212 KiB

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 252 KiB

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 183 KiB

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 292 KiB

After

Width:  |  Height:  |  Size: 326 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 244 KiB

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 187 KiB

After

Width:  |  Height:  |  Size: 343 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 294 KiB

After

Width:  |  Height:  |  Size: 328 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

+28
View File
@@ -0,0 +1,28 @@
CREATE TABLE `collector` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`color` text DEFAULT 'blue' NOT NULL,
`birthday` integer,
`notes` text,
`sort_order` integer DEFAULT 0 NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL
);
--> statement-breakpoint
CREATE INDEX `collector_sort_idx` ON `collector` (`sort_order`,`name`);--> statement-breakpoint
ALTER TABLE `collection_item` ADD `collector_id` text REFERENCES collector(id);--> statement-breakpoint
CREATE INDEX `collection_item_collector_idx` ON `collection_item` (`collector_id`);--> statement-breakpoint
ALTER TABLE `wishlist` ADD `collector_id` text REFERENCES collector(id);--> statement-breakpoint
-- Von Hand ergaenzt: Bestandsdaten nachziehen.
-- Vor dieser Migration gab es genau eine Sammlung. Sie bekommt einen Sammler,
-- damit alles Vorhandene zugeordnet bleibt; umbenennen laesst er sich unter
-- Verwaltung -> Sammler.
INSERT INTO `collector` (`id`, `name`, `color`, `sort_order`, `created_at`, `updated_at`)
SELECT 'c0000000-0000-4000-8000-000000000001', 'Sammlung', 'blue', 0, unixepoch(), unixepoch()
WHERE NOT EXISTS (SELECT 1 FROM `collector`);--> statement-breakpoint
UPDATE `collection_item`
SET `collector_id` = (SELECT `id` FROM `collector` ORDER BY `sort_order`, `name` LIMIT 1)
WHERE `collector_id` IS NULL;--> statement-breakpoint
UPDATE `wishlist`
SET `collector_id` = (SELECT `id` FROM `collector` ORDER BY `sort_order`, `name` LIMIT 1)
WHERE `collector_id` IS NULL;
+14
View File
@@ -0,0 +1,14 @@
CREATE TABLE `price_point` (
`id` text PRIMARY KEY NOT NULL,
`set_id` text NOT NULL,
`price_cents` integer NOT NULL,
`condition` text DEFAULT 'new' NOT NULL,
`source` text,
`observed_at` integer NOT NULL,
`note` text,
`created_at` integer NOT NULL,
FOREIGN KEY (`set_id`) REFERENCES `set`(`id`) ON UPDATE no action ON DELETE cascade,
CONSTRAINT "price_point_positive" CHECK("price_point"."price_cents" >= 0)
);
--> statement-breakpoint
CREATE INDEX `price_point_set_idx` ON `price_point` (`set_id`,`observed_at`);
+1
View File
@@ -0,0 +1 @@
ALTER TABLE `wishlist` ADD `last_reminder_at` integer;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+21
View File
@@ -8,6 +8,27 @@
"when": 1788726503038,
"tag": "0000_rapid_gargoyle",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1788763704015,
"tag": "0001_lumpy_bill_hollister",
"breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1788765229376,
"tag": "0002_aspiring_whiplash",
"breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1788765478965,
"tag": "0003_powerful_sally_floyd",
"breakpoints": true
}
]
}
-13
View File
@@ -1,13 +0,0 @@
import { execFileSync } from 'node:child_process';
/**
* Migrates the smoke-test database. The directory itself is wiped earlier by
* scripts/reset-e2e.mjs, because Playwright starts its web server before this
* hook runs and the open database file would block the delete on Windows.
*/
export default function globalSetup() {
execFileSync('node', ['node_modules/tsx/dist/cli.mjs', 'scripts/migrate.ts'], {
stdio: 'inherit',
env: { ...process.env, DATA_DIR: './data-e2e' },
});
}
+64 -1
View File
@@ -66,7 +66,8 @@ test('Screenshots fuer die Anleitung', async ({ page, browser }) => {
await page.getByRole('button', { name: 'Auswählen' }).click();
await expect(page.getByText('Tippe die Sets an')).toBeVisible();
const cards = page.locator('button[aria-pressed]');
// data-set-card, not aria-pressed: the collector switcher uses that too.
const cards = page.locator('button[data-set-card]');
await cards.nth(0).click();
await cards.nth(1).click();
await expect(page.getByText('2 ausgewählt')).toBeVisible();
@@ -127,4 +128,66 @@ test('Screenshots fuer die Anleitung', async ({ page, browser }) => {
await visit(page, '/konto');
await expect(page.getByText('Passkeys', { exact: true })).toBeVisible();
await shoot(page, '16-konto-passkeys');
// --- 12. Lego import ------------------------------------------------------
// The screenshot container runs with a placeholder API key so the manual
// shows the form, not the "not configured" hint. Nothing is submitted.
await visit(page, '/katalog/neu');
await expect(page.getByText('Lego-Import')).toBeVisible();
await shoot(page, '17-lego-import');
// --- 13. missing parts ----------------------------------------------------
await visit(page, '/katalog?q=60398');
await page.getByText('Familienhaus und Elektroauto').first().click();
await expect(page.getByRole('heading', { name: 'Familienhaus und Elektroauto' })).toBeVisible();
await page.getByRole('button', { name: 'Fehlteile' }).first().click();
await expect(page.getByRole('dialog')).toBeVisible();
await page.getByLabel('Teilenummer').fill('3001');
await page.getByLabel('Farbe').fill('Rot');
await page.getByRole('button', { name: 'Hinzufügen' }).click();
await expect(page.getByText('3001')).toBeVisible();
await page.screenshot({ path: shotPath('18-fehlteile') });
await page.getByRole('button', { name: 'Fertig' }).click();
// --- 14. invitations ------------------------------------------------------
await visit(page, '/verwaltung/einladungen');
await expect(page.getByRole('button', { name: 'Einladung erstellen' })).toBeVisible();
await page.getByRole('button', { name: 'Einladung erstellen' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await page.screenshot({ path: shotPath('19-einladungen') });
await page.getByRole('button', { name: 'Abbrechen' }).click();
// --- 15. export and import ------------------------------------------------
await visit(page, '/verwaltung/daten');
await expect(page.getByText('Alles als ZIP')).toBeVisible();
await shoot(page, '20-daten');
// --- 16. collectors -------------------------------------------------------
await visit(page, '/verwaltung/sammler');
await expect(page.getByText('Der Katalog ist gemeinsam')).toBeVisible();
await shoot(page, '21-sammler');
// The switcher only exists because the seed created two collectors.
await visit(page, '/katalog?besitz=fehlt');
await expect(page.getByRole('button', { name: /Max/ })).toBeVisible();
await page.getByRole('button', { name: /Max/ }).click();
await expect(page.getByText('Sets, die Max noch nicht hat')).toBeVisible();
await shoot(page, '22-sammler-filter');
// --- 17. prices -----------------------------------------------------------
await visit(page, '/katalog?q=60398');
await page.getByText('Familienhaus und Elektroauto').first().click();
await expect(page.getByText('Preis und Wertentwicklung')).toBeVisible();
await page.getByRole('button', { name: 'Preis notieren' }).click();
// Role, not label: the label text carries the required marker.
await page.getByRole('textbox', { name: 'Preis', exact: true }).fill('79,99');
await page.getByLabel('Wo').fill('Amazon');
await page.getByRole('button', { name: 'Speichern' }).click();
await expect(page.getByText('Amazon')).toBeVisible();
await shoot(page, '23-preise');
// --- 18. reminders --------------------------------------------------------
await visit(page, '/verwaltung/erinnerungen');
await expect(page.getByText('Erinnerungen').first()).toBeVisible();
await shoot(page, '24-erinnerungen');
});
+71
View File
@@ -81,6 +81,77 @@ test('Registrieren, Set anlegen, Wunschliste teilen und reservieren', async ({ p
await visitorContext.close();
});
test('Dublettenpruefung warnt vor einem fast gleichen Set', async ({ page }) => {
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();
// Dasselbe Set noch einmal, mit Tippfehler im Namen und anderer Nummer.
await page.goto('/katalog/neu');
await page.getByLabel('Setnummer').fill('60399');
await page
.getByRole('textbox', { name: 'Name', exact: true })
.fill('Familienhaus und Elektroauo');
await page.getByRole('button', { name: 'Set anlegen' }).click();
await expect(page.getByText('Gibt es das vielleicht schon?')).toBeVisible();
await expect(page.getByText('Sehr ähnlicher Name')).toBeVisible();
// Zurueck ins Formular: nichts wurde angelegt.
await page.getByRole('button', { name: 'Zurück zum Formular' }).click();
await page.goto('/katalog?q=60399');
await expect(page.getByText('0 Sets')).toBeVisible();
});
test('Einladung: zweiter Elternteil bekommt ein eigenes Konto', async ({ page, browser }) => {
// The admin from the first test is still the only account, so log in.
await page.goto('/anmelden');
await page.getByLabel('E-Mail').fill('test@bricktrack.local');
await page.getByLabel('Passwort').fill('smoketest-passwort');
await page.getByRole('button', { name: 'Anmelden', exact: true }).click();
await expect(page.getByRole('heading', { name: 'Hallo Florian' })).toBeVisible();
// --- the admin issues an invitation --------------------------------------
await page.goto('/verwaltung/einladungen');
await page.getByRole('button', { name: 'Einladung erstellen' }).click();
await page.getByRole('button', { name: 'Erstellen' }).click();
const inviteUrl = await page
.getByRole('textbox', { name: 'Einladungslink' })
.inputValue();
expect(inviteUrl).toContain('/einladung/');
await page.getByRole('button', { name: 'Fertig' }).click();
await expect(page.getByText('Offen').first()).toBeVisible();
// --- the invited person registers ----------------------------------------
const guestContext = await browser.newContext();
const guest = await guestContext.newPage();
await guest.goto(inviteUrl);
await expect(guest.getByRole('button', { name: 'Konto anlegen' })).toBeVisible();
await guest.getByLabel('Name').fill('Mama');
await guest.getByLabel('E-Mail').fill('mama@bricktrack.local');
await guest.getByLabel('Passwort').fill('zweites-passwort');
await guest.getByRole('button', { name: 'Konto anlegen' }).click();
await expect(guest.getByRole('heading', { name: 'Hallo Mama' })).toBeVisible();
// A member is not an admin: no invitation management for them.
await guest.goto('/verwaltung/einladungen');
await expect(guest).toHaveURL(/\/$|\/anmelden/);
// --- the link is single use ----------------------------------------------
const secondTry = await browser.newContext();
const secondPage = await secondTry.newPage();
await secondPage.goto(inviteUrl);
await expect(secondPage.getByText('Einladung ungültig')).toBeVisible();
await guestContext.close();
await secondTry.close();
});
test('Ohne Anmeldung fuehrt jeder geschuetzte Pfad zur Anmeldung', async ({ page }) => {
await page.goto('/sammlung');
await expect(page).toHaveURL(/\/anmelden|\/registrieren/);
+11
View File
@@ -11,6 +11,17 @@ const compat = new FlatCompat({
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
{
rules: {
// Destructuring a key out in order to drop it is how we omit fields
// (share tokens, reservations) from an export - the binding is
// deliberately unused.
"@typescript-eslint/no-unused-vars": [
"warn",
{ ignoreRestSiblings: true, argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
],
},
},
{
ignores: [
"node_modules/**",
+6 -1
View File
@@ -19,20 +19,24 @@
"db:studio": "drizzle-kit studio",
"test": "vitest run",
"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"
},
"dependencies": {
"@better-auth/passkey": "^1.7.3",
"@zxing/browser": "^0.2.1",
"@zxing/library": "^0.23.0",
"better-auth": "^1.7.3",
"better-sqlite3": "^13.0.3",
"class-variance-authority": "^0.7.1",
"cn": "^0.2.6",
"date-fns": "^4.4.0",
"drizzle-orm": "^0.45.2",
"fflate": "^0.8.3",
"lucide-react": "^1.41.0",
"next": "15.5.25",
"next-themes": "^0.4.6",
"nodemailer": "^10.0.0",
"pdfjs-dist": "^6.3.289",
"radix-ui": "^1.6.7",
"react": "19.1.0",
@@ -49,6 +53,7 @@
"@tailwindcss/postcss": "^4",
"@types/better-sqlite3": "^9.6.0",
"@types/node": "^20",
"@types/nodemailer": "^8.0.1",
"@types/react": "^19",
"@types/react-dom": "^19",
"dotenv": "^17.4.2",
+19 -16
View File
@@ -1,28 +1,29 @@
import path from 'node:path';
import { defineConfig, devices } from '@playwright/test';
/**
* Smoke test setup.
*
* Runs against its own data directory (./data-e2e) so a test never touches the
* real database. The global setup wipes and migrates it before each run.
* Runs against the production container with its own data directory
* (./data-e2e), so a test never touches the real database and never depends on
* the dev server compiling fast enough.
*/
const PORT = 3210;
const BASE_URL = `http://localhost:${PORT}`;
// Docker wants forward slashes, also on Windows.
const dataDir = path.resolve(process.cwd(), 'data-e2e').replace(/\\/g, '/');
export default defineConfig({
testDir: './e2e',
// The screenshot run has its own config, its own server and its own data
// directory - it must never join the smoke run.
testMatch: 'smoke.spec.ts',
globalSetup: './e2e/global-setup.ts',
fullyParallel: false,
workers: 1,
retries: 0,
// Generous: the dev server compiles each route on first visit.
timeout: 240_000,
// A first navigation to a route the dev server has not compiled yet can
// take well over ten seconds on a cold cache.
expect: { timeout: 30_000 },
timeout: 90_000,
expect: { timeout: 15_000 },
reporter: process.env.CI ? 'github' : 'list',
use: {
baseURL: BASE_URL,
@@ -38,18 +39,20 @@ export default defineConfig({
},
],
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`,
reuseExistingServer: false,
timeout: 180_000,
stdout: 'pipe',
stderr: 'pipe',
env: {
NODE_ENV: 'development',
DATA_DIR: './data-e2e',
BASE_URL,
BETTER_AUTH_SECRET: 'playwright-smoke-test-secret-0123456789abcdef',
MAX_UPLOAD_MB: '25',
},
},
});
+3
View File
@@ -41,6 +41,9 @@ export default defineConfig({
`-e BASE_URL=${BASE_URL}`,
'-e BETTER_AUTH_SECRET=screenshot-run-secret-0123456789abcdefgh',
'-e TZ=Europe/Berlin',
// A placeholder key so the manual shows the import form rather than the
// "not configured" hint. The screenshot run never submits it.
'-e REBRICKABLE_API_KEY=screenshot-placeholder',
`-v "${dataDir}:/data"`,
'bricktrack:shots',
].join(' '),
+66
View File
@@ -11,6 +11,12 @@ importers:
'@better-auth/passkey':
specifier: ^1.7.3
version: 1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.7.3(better-sqlite3@13.0.3)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3)(kysely@0.29.5))(next@15.5.25(@babel/core@7.29.7)(@playwright/test@1.63.0)(@types/node@20.19.43)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vitest@5.0.0(@types/node@20.19.43)(vite@8.2.2(@types/node@20.19.43)(esbuild@0.25.12)(jiti@2.7.0)(tsx@4.23.13))))(better-call@1.4.0(zod@4.5.4))(nanostores@1.5.3)
'@zxing/browser':
specifier: ^0.2.1
version: 0.2.1(@zxing/library@0.23.0)
'@zxing/library':
specifier: ^0.23.0
version: 0.23.0
better-auth:
specifier: ^1.7.3
version: 1.7.3(better-sqlite3@13.0.3)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3)(kysely@0.29.5))(next@15.5.25(@babel/core@7.29.7)(@playwright/test@1.63.0)(@types/node@20.19.43)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vitest@5.0.0(@types/node@20.19.43)(vite@8.2.2(@types/node@20.19.43)(esbuild@0.25.12)(jiti@2.7.0)(tsx@4.23.13)))
@@ -29,6 +35,9 @@ importers:
drizzle-orm:
specifier: ^0.45.2
version: 0.45.2(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3)(kysely@0.29.5)
fflate:
specifier: ^0.8.3
version: 0.8.3
lucide-react:
specifier: ^1.41.0
version: 1.41.0(react@19.1.0)
@@ -38,6 +47,9 @@ importers:
next-themes:
specifier: ^0.4.6
version: 0.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
nodemailer:
specifier: ^10.0.0
version: 10.0.0
pdfjs-dist:
specifier: ^6.3.289
version: 6.3.289
@@ -81,6 +93,9 @@ importers:
'@types/node':
specifier: ^20
version: 20.19.43
'@types/nodemailer':
specifier: ^8.0.1
version: 8.0.1
'@types/react':
specifier: ^19
version: 19.2.18
@@ -2253,6 +2268,9 @@ packages:
'@types/node@20.19.43':
resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==}
'@types/nodemailer@8.0.1':
resolution: {integrity: sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==}
'@types/react-dom@19.2.7':
resolution: {integrity: sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ==}
peerDependencies:
@@ -2457,6 +2475,18 @@ packages:
'@vitest/spy@5.0.0':
resolution: {integrity: sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==}
'@zxing/browser@0.2.1':
resolution: {integrity: sha512-92pVfVDUXbc15xu9vIEmhNyqIEASMEkDF92CwT6T+7sg2EHXJRktH9CQKoQSXe51UtbNsj+Fm09H/JBWHMs/Sw==}
peerDependencies:
'@zxing/library': ^0.23.0
'@zxing/library@0.23.0':
resolution: {integrity: sha512-6fkkoFwP8CHxl6ugnPsj74PLJgX2iRv5zczGAyt5OBzQgxFhuhF0NCEc4t4OvSr8xAv2MRLlI0Iu9ZGDZQ2urA==}
engines: {node: '>= 24.0.0'}
'@zxing/text-encoding@0.9.0':
resolution: {integrity: sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==}
accepts@2.0.0:
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
engines: {node: '>= 0.6'}
@@ -3330,6 +3360,9 @@ packages:
picomatch:
optional: true
fflate@0.8.3:
resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==}
figures@6.1.0:
resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==}
engines: {node: '>=18'}
@@ -4111,6 +4144,10 @@ packages:
resolution: {integrity: sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==}
engines: {node: '>=18'}
nodemailer@10.0.0:
resolution: {integrity: sha512-wdv+hXBg0iIPIOf108FaZyTxqjQpCvQeAJt9pHHTooPy/y3vQtI9eiImM9AIVKR3e/0FeMA1oueNfBjFJcaK+Q==}
engines: {node: '>=20.0.0'}
npm-run-path@4.0.1:
resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==}
engines: {node: '>=8'}
@@ -4745,6 +4782,10 @@ packages:
peerDependencies:
typescript: '>=4.8.4'
ts-custom-error@3.3.1:
resolution: {integrity: sha512-5OX1tzOjxWEgsr/YEUWSuPrQ00deKLh6D7OTWcvNHm12/7QPyRh8SYpyWvA4IZv8H/+GQWQEh/kwo95Q9OVW1A==}
engines: {node: '>=14.0.0'}
ts-morph@26.0.0:
resolution: {integrity: sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug==}
@@ -6922,6 +6963,10 @@ snapshots:
dependencies:
undici-types: 6.21.0
'@types/nodemailer@8.0.1':
dependencies:
'@types/node': 20.19.43
'@types/react-dom@19.2.7(@types/react@19.2.18)':
dependencies:
'@types/react': 19.2.18
@@ -7104,6 +7149,21 @@ snapshots:
'@vitest/spy@5.0.0': {}
'@zxing/browser@0.2.1(@zxing/library@0.23.0)':
dependencies:
'@zxing/library': 0.23.0
optionalDependencies:
'@zxing/text-encoding': 0.9.0
'@zxing/library@0.23.0':
dependencies:
ts-custom-error: 3.3.1
optionalDependencies:
'@zxing/text-encoding': 0.9.0
'@zxing/text-encoding@0.9.0':
optional: true
accepts@2.0.0:
dependencies:
mime-types: 3.0.2
@@ -8094,6 +8154,8 @@ snapshots:
optionalDependencies:
picomatch: 4.0.7
fflate@0.8.3: {}
figures@6.1.0:
dependencies:
is-unicode-supported: 2.1.0
@@ -8760,6 +8822,8 @@ snapshots:
node-releases@2.0.54: {}
nodemailer@10.0.0: {}
npm-run-path@4.0.1:
dependencies:
path-key: 3.1.1
@@ -9555,6 +9619,8 @@ snapshots:
dependencies:
typescript: 5.9.3
ts-custom-error@3.3.1: {}
ts-morph@26.0.0:
dependencies:
'@ts-morph/common': 0.27.0
Binary file not shown.

After

Width:  |  Height:  |  Size: 332 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

+45
View File
@@ -0,0 +1,45 @@
{
"name": "Bricktrack",
"short_name": "Bricktrack",
"description": "Sammlung und Wunschlisten für Spielzeug-Sets",
"lang": "de",
"dir": "ltr",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "portrait",
"background_color": "#ffffff",
"theme_color": "#0a0a0a",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"shortcuts": [
{
"name": "Fehlende Sets",
"short_name": "Fehlt noch",
"url": "/katalog?besitz=fehlt"
},
{
"name": "Sammlung",
"short_name": "Sammlung",
"url": "/sammlung"
}
]
}
+122
View File
@@ -0,0 +1,122 @@
/**
* Bricktrack service worker.
*
* Goal: the catalog stays readable in the cellar, in the shop, and wherever
* else the phone loses its connection. Anything that changes data still needs
* the network - a wish reserved offline would be a lie.
*
* Caches are versioned; bumping VERSION drops the old ones on activation.
*/
const VERSION = 'v1';
const PAGE_CACHE = `bricktrack-pages-${VERSION}`;
const ASSET_CACHE = `bricktrack-assets-${VERSION}`;
const IMAGE_CACHE = `bricktrack-images-${VERSION}`;
const OFFLINE_URL = '/offline';
self.addEventListener('install', (event) => {
event.waitUntil(
caches
.open(PAGE_CACHE)
.then((cache) => cache.addAll([OFFLINE_URL, '/manifest.webmanifest']))
.then(() => self.skipWaiting()),
);
});
self.addEventListener('activate', (event) => {
const keep = new Set([PAGE_CACHE, ASSET_CACHE, IMAGE_CACHE]);
event.waitUntil(
caches
.keys()
.then((keys) => Promise.all(keys.filter((key) => !keep.has(key)).map((key) => caches.delete(key))))
.then(() => self.clients.claim()),
);
});
/**
* Signing out must not leave readable pages behind on a shared device, so the
* app tells the worker to drop everything it holds.
*/
self.addEventListener('message', (event) => {
if (event.data === 'bricktrack:clear-cache') {
event.waitUntil(caches.keys().then((keys) => Promise.all(keys.map((key) => caches.delete(key)))));
}
});
/** Long-lived, content-addressed build output. */
function isStaticAsset(url) {
return url.pathname.startsWith('/_next/static/') || url.pathname.startsWith('/icons/');
}
/** Set pictures; their file names contain a UUID and never change. */
function isCacheableImage(url) {
return url.pathname.startsWith('/api/files/images/');
}
/** Everything that must never be answered from a cache. */
function isNeverCached(url) {
return (
url.pathname.startsWith('/api/auth/') ||
url.pathname.startsWith('/api/export/') ||
url.pathname.startsWith('/api/health')
);
}
async function cacheFirst(request, cacheName) {
const cache = await caches.open(cacheName);
const hit = await cache.match(request);
if (hit) return hit;
const response = await fetch(request);
if (response.ok) cache.put(request, response.clone());
return response;
}
/**
* Network first, cache as a fallback: the catalog is always current when there
* is a connection, and still readable when there is not.
*/
async function networkFirst(request, cacheName) {
const cache = await caches.open(cacheName);
try {
const response = await fetch(request);
if (response.ok) cache.put(request, response.clone());
return response;
} catch (error) {
const hit = await cache.match(request);
if (hit) return hit;
if (request.mode === 'navigate') {
const offline = await cache.match(OFFLINE_URL);
if (offline) return offline;
}
throw error;
}
}
self.addEventListener('fetch', (event) => {
const { request } = event;
// Only plain GETs are safe to serve from a cache.
if (request.method !== 'GET') return;
const url = new URL(request.url);
if (url.origin !== self.location.origin) return;
if (isNeverCached(url)) return;
if (isStaticAsset(url)) {
event.respondWith(cacheFirst(request, ASSET_CACHE));
return;
}
if (isCacheableImage(url)) {
event.respondWith(cacheFirst(request, IMAGE_CACHE));
return;
}
// Page loads and the RSC payloads behind client-side navigation.
if (request.mode === 'navigate' || url.searchParams.has('_rsc')) {
event.respondWith(networkFirst(request, PAGE_CACHE));
}
});
+29
View File
@@ -0,0 +1,29 @@
/**
* 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.`);
// A container left behind by an aborted run would hold the port.
try {
execFileSync('docker', ['rm', '-f', 'bricktrack-e2e'], { stdio: 'ignore' });
} catch {
// Nothing to remove - the normal case.
}
console.log('[e2e] Image wird gebaut (bricktrack:e2e) …');
execFileSync('docker', ['build', '-t', 'bricktrack:e2e', '.'], { stdio: 'inherit' });
console.log('[e2e] Image ist bereit.');
+67
View File
@@ -0,0 +1,67 @@
/**
* Renders the app icons from one SVG source.
*
* Run with `node scripts/generate-icons.mjs` after changing the mark; the
* results are committed so neither the build nor the container needs to draw
* anything at runtime.
*/
import fs from 'node:fs';
import path from 'node:path';
import sharp from 'sharp';
const OUT_DIR = path.resolve(process.cwd(), 'public/icons');
// Four studs on a rounded plate - readable down to 32px, which a detailed
// brick drawing would not be.
const mark = (background, foreground) => `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="112" fill="${background}"/>
<g fill="${foreground}">
<rect x="112" y="112" width="128" height="128" rx="28"/>
<rect x="272" y="112" width="128" height="128" rx="28"/>
<rect x="112" y="272" width="128" height="128" rx="28"/>
<rect x="272" y="272" width="128" height="128" rx="28"/>
</g>
</svg>`;
/**
* A maskable icon has to survive being cropped to a circle, so the mark sits
* on a full-bleed background with generous padding.
*/
const maskable = (background, foreground) => `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" fill="${background}"/>
<g fill="${foreground}">
<rect x="146" y="146" width="100" height="100" rx="22"/>
<rect x="266" y="146" width="100" height="100" rx="22"/>
<rect x="146" y="266" width="100" height="100" rx="22"/>
<rect x="266" y="266" width="100" height="100" rx="22"/>
</g>
</svg>`;
const DARK = '#0a0a0a';
const LIGHT = '#fafafa';
const targets = [
{ file: 'icon-192.png', size: 192, svg: mark(DARK, LIGHT) },
{ file: 'icon-512.png', size: 512, svg: mark(DARK, LIGHT) },
{ file: 'icon-maskable-512.png', size: 512, svg: maskable(DARK, LIGHT) },
{ file: 'apple-touch-icon.png', size: 180, svg: mark(DARK, LIGHT) },
];
fs.mkdirSync(OUT_DIR, { recursive: true });
for (const target of targets) {
await sharp(Buffer.from(target.svg))
.resize(target.size, target.size)
.png({ compressionLevel: 9 })
.toFile(path.join(OUT_DIR, target.file));
console.log(`[icons] ${target.file} (${target.size}px)`);
}
// The favicon keeps the same mark so the browser tab matches the home screen.
await sharp(Buffer.from(mark(DARK, LIGHT)))
.resize(32, 32)
.png({ compressionLevel: 9 })
.toFile(path.resolve(process.cwd(), 'public/favicon.png'));
console.log('[icons] favicon.png (32px)');
-13
View File
@@ -1,13 +0,0 @@
/**
* Wipes the Playwright data directory before the smoke run.
*
* Runs before Playwright starts, because Playwright boots its web server
* before global setup - on Windows the open SQLite file would then block the
* delete.
*/
import fs from 'node:fs';
import path from 'node:path';
const dataDir = path.resolve(process.cwd(), 'data-e2e');
fs.rmSync(dataDir, { recursive: true, force: true });
console.log(`[e2e] ${dataDir} zurueckgesetzt.`);
+7
View File
@@ -16,6 +16,13 @@ fs.mkdirSync(path.join(dataDir, 'db'), { recursive: true });
fs.mkdirSync(path.join(dataDir, 'files'), { recursive: true });
console.log(`[screenshots] ${dataDir} zurueckgesetzt.`);
// A container left behind by an aborted run would hold the port.
try {
execFileSync('docker', ['rm', '-f', 'bricktrack-shots'], { stdio: 'ignore' });
} catch {
// Nothing to remove - the normal case.
}
console.log('[screenshots] Image wird gebaut (bricktrack:shots) …');
execFileSync('docker', ['build', '-t', 'bricktrack:shots', '.'], { stdio: 'inherit' });
console.log('[screenshots] Image ist bereit.');
+90 -8
View File
@@ -33,11 +33,12 @@ type SeedSet = {
minifigCount: number | null;
retailPriceCents: number;
ageRange: string;
/** Copies owned - 0 means the set is only in the catalog. */
/** Copies owned - absent means the set is only in the catalog. */
owned?: {
status: 'owned' | 'built' | 'disassembled' | 'lent_out' | 'sold';
completeness: 'complete' | 'missing_parts' | 'unknown';
location: string;
collector: string;
giftedBy?: string;
};
};
@@ -59,6 +60,13 @@ const THEMES: Array<{ manufacturer: string; name: string }> = [
const LOCATIONS = ['Regal Kinderzimmer', 'Kiste Keller 3', 'Vitrine Wohnzimmer'];
// Two collectors, so the shared catalog and the separate collections are
// visible right away. With only one, the whole concept stays hidden in the UI.
const COLLECTORS = [
{ name: 'Max', color: 'blue' as const },
{ name: 'Mia', color: 'rose' as const },
];
const SETS: SeedSet[] = [
{
manufacturer: 'Lego',
@@ -70,7 +78,12 @@ const SETS: SeedSet[] = [
minifigCount: 4,
retailPriceCents: 4999,
ageRange: '6-12',
owned: { status: 'built', completeness: 'complete', location: 'Regal Kinderzimmer' },
owned: {
status: 'built',
completeness: 'complete',
location: 'Regal Kinderzimmer',
collector: 'Max',
},
},
{
manufacturer: 'Lego',
@@ -86,6 +99,7 @@ const SETS: SeedSet[] = [
status: 'owned',
completeness: 'missing_parts',
location: 'Kiste Keller 3',
collector: 'Max',
giftedBy: 'Oma Helga',
},
},
@@ -99,7 +113,12 @@ const SETS: SeedSet[] = [
minifigCount: null,
retailPriceCents: 999,
ageRange: '7-14',
owned: { status: 'disassembled', completeness: 'unknown', location: 'Kiste Keller 3' },
owned: {
status: 'disassembled',
completeness: 'unknown',
location: 'Kiste Keller 3',
collector: 'Mia',
},
},
{
manufacturer: 'Lego',
@@ -144,7 +163,12 @@ const SETS: SeedSet[] = [
minifigCount: 5,
retailPriceCents: 12999,
ageRange: '4-10',
owned: { status: 'built', completeness: 'complete', location: 'Regal Kinderzimmer' },
owned: {
status: 'built',
completeness: 'complete',
location: 'Regal Kinderzimmer',
collector: 'Mia',
},
},
{
manufacturer: 'Playmobil',
@@ -241,6 +265,59 @@ function seedThemes(manufacturers: Map<string, string>) {
return byKey;
}
function seedCollectors() {
const byName = new Map<string, string>();
COLLECTORS.forEach((entry, index) => {
const all = db
.select({ id: schema.collector.id, name: schema.collector.name })
.from(schema.collector)
.all();
const existing = all.find((row) => row.name === entry.name);
if (existing) {
byName.set(entry.name, existing.id);
return;
}
// A fresh install already has the collector the migration created. Rather
// than adding a third one next to it, the seed takes it over.
const untouchedDefault = all.find(
(row) =>
row.name === 'Sammlung' &&
!db
.select({ id: schema.collectionItem.id })
.from(schema.collectionItem)
.where(eq(schema.collectionItem.collectorId, row.id))
.get(),
);
if (untouchedDefault) {
db.update(schema.collector)
.set({ name: entry.name, color: entry.color, sortOrder: index, updatedAt: now })
.where(eq(schema.collector.id, untouchedDefault.id))
.run();
byName.set(entry.name, untouchedDefault.id);
return;
}
const newId = id();
db.insert(schema.collector)
.values({
id: newId,
name: entry.name,
color: entry.color,
sortOrder: index,
createdAt: now,
updatedAt: now,
})
.run();
byName.set(entry.name, newId);
});
return byName;
}
function seedLocations() {
const byName = new Map<string, string>();
@@ -270,6 +347,7 @@ function seedSets(
manufacturers: Map<string, string>,
themes: Map<string, string>,
locations: Map<string, string>,
collectors: Map<string, string>,
) {
const setIds = new Map<string, string>();
@@ -317,6 +395,7 @@ function seedSets(
completeness: entry.owned.completeness,
condition: 'good',
locationId: locations.get(entry.owned.location) ?? null,
collectorId: collectors.get(entry.owned.collector) ?? null,
acquiredAt: new Date(entry.year, 11, 24),
acquiredPriceCents: entry.retailPriceCents,
giftedBy: entry.owned.giftedBy ?? null,
@@ -330,7 +409,7 @@ function seedSets(
return setIds;
}
function seedWishlist(setIds: Map<string, string>) {
function seedWishlist(setIds: Map<string, string>, collectors: Map<string, string>) {
const owner = db.select({ id: schema.user.id }).from(schema.user).get();
if (!owner) {
console.log('[seed] Kein Nutzer vorhanden - die Beispiel-Wunschliste wird uebersprungen.');
@@ -353,6 +432,7 @@ function seedWishlist(setIds: Map<string, string>) {
.values({
id: wishlistId,
ownerUserId: owner.id,
collectorId: collectors.get('Max') ?? null,
title: 'Weihnachten 2026',
occasion: 'christmas',
eventDate: new Date(2026, 11, 24),
@@ -389,10 +469,12 @@ console.log('[seed] Beispieldaten werden eingespielt …');
const manufacturers = seedManufacturers();
const themes = seedThemes(manufacturers);
const locations = seedLocations();
const setIds = seedSets(manufacturers, themes, locations);
seedWishlist(setIds);
const collectors = seedCollectors();
const setIds = seedSets(manufacturers, themes, locations, collectors);
seedWishlist(setIds, collectors);
console.log(
`[seed] Fertig: ${manufacturers.size} Hersteller, ${themes.size} Themen, ${setIds.size} Sets.`,
`[seed] Fertig: ${collectors.size} Sammler, ${manufacturers.size} Hersteller, ` +
`${themes.size} Themen, ${setIds.size} Sets.`,
);
sqlite.close();
+44 -5
View File
@@ -4,6 +4,7 @@ import { notFound } from 'next/navigation';
import { Check, ExternalLink, Gift, ImageOff, Pencil } from 'lucide-react';
import { InstructionsSection } from '@/components/katalog/instructions-section';
import { PhotosSection } from '@/components/katalog/photos-section';
import { PriceSection } from '@/components/katalog/price-section';
import { SetActions } from '@/components/katalog/set-actions';
import { CollectionItemsSection } from '@/components/sammlung/collection-items-section';
import { Badge } from '@/components/ui/badge';
@@ -17,24 +18,37 @@ import {
listCollectionItemsForSet,
listInstructionsForSet,
listPhotosForSet,
listPricePoints,
} from '@/lib/queries/catalog';
import { listMissingParts } from '@/lib/queries/collection';
import { listCollectors, resolveCollectorId } from '@/lib/queries/collectors';
import { listLocations } from '@/lib/queries/master-data';
import { listWishlistsForSet, listWishlistTargets } from '@/lib/queries/wishlists';
import { requireUser } from '@/lib/session';
type Props = { params: Promise<{ id: string }> };
type Props = {
params: Promise<{ id: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
};
export async function generateMetadata({ params }: Props): Promise<Metadata> {
export async function generateMetadata({ params }: Pick<Props, 'params'>): Promise<Metadata> {
const { id } = await params;
const set = getSetById(id);
return { title: set ? `${set.setNumber} · ${set.name}` : 'Set' };
}
export default async function SetDetailPage({ params }: Props) {
export default async function SetDetailPage({ params, searchParams }: Props) {
await requireUser();
const { id } = await params;
const query = await searchParams;
const set = getSetById(id);
// Keeps the "vorhanden / fehlt" badge in step with the collector the user
// was looking at in the catalog.
const rawCollector = Array.isArray(query.sammler) ? query.sammler[0] : query.sammler;
const collectorId = resolveCollectorId(rawCollector);
const collectors = listCollectors();
const set = getSetById(id, collectorId);
if (!set) notFound();
const image = setImageSrc(set);
@@ -42,9 +56,16 @@ export default async function SetDetailPage({ params }: Props) {
const locations = listLocations().map((row) => ({ id: row.id, name: row.name }));
const locationNames = new Map(locations.map((row) => [row.id, row.name]));
const collectorsById = new Map(collectors.map((row) => [row.id, row]));
const collectionItems = listCollectionItemsForSet(set.id).map((item) => ({
...item,
locationName: item.locationId ? (locationNames.get(item.locationId) ?? null) : null,
setName: `${set.setNumber} · ${set.name}`,
missingParts: listMissingParts(item.id),
collectorName: item.collectorId ? (collectorsById.get(item.collectorId)?.name ?? null) : null,
collectorColor: item.collectorId
? (collectorsById.get(item.collectorId)?.color ?? null)
: null,
}));
const facts: Array<[string, string]> = [
@@ -110,6 +131,7 @@ export default async function SetDetailPage({ params }: Props) {
setName={`${set.setNumber} · ${set.name}`}
owned={owned}
wishlists={listWishlistTargets()}
collectors={collectors.map((row) => ({ id: row.id, name: row.name }))}
/>
<dl className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm sm:grid-cols-3">
@@ -141,7 +163,24 @@ export default async function SetDetailPage({ params }: Props) {
<CardTitle>In der Sammlung</CardTitle>
</CardHeader>
<CardContent>
<CollectionItemsSection items={collectionItems} locations={locations} />
<CollectionItemsSection
items={collectionItems}
locations={locations}
collectors={collectors.map((row) => ({ id: row.id, name: row.name }))}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Preis und Wertentwicklung</CardTitle>
</CardHeader>
<CardContent>
<PriceSection
setId={set.id}
prices={listPricePoints(set.id)}
retailPriceCents={set.retailPriceCents}
/>
</CardContent>
</Card>
+100
View File
@@ -0,0 +1,100 @@
'use server';
import { and, eq, ne } from 'drizzle-orm';
import { db } from '@/db';
import { manufacturer, set } from '@/db/schema';
import { actionData, type ActionResult } from '@/lib/action-result';
import { requireUser } from '@/lib/session';
import { isSimilarName } from '@/lib/similarity';
export type DuplicateCandidate = {
id: string;
setNumber: string;
name: string;
manufacturerName: string;
/** Why we are showing it. */
reason: 'same-number' | 'similar-name' | 'number-elsewhere';
};
/**
* Looks for sets that the one being created might be a duplicate of.
*
* Three cases, in descending order of certainty:
* - the same number at the same manufacturer (that is a real duplicate and is
* rejected on save anyway),
* - a suspiciously similar name at the same manufacturer,
* - the same number at a different manufacturer, which usually means the
* wrong manufacturer was selected.
*/
export async function findDuplicateCandidates(input: {
manufacturerId: string;
setNumber: string;
name: string;
excludeSetId?: string;
}): Promise<ActionResult<{ candidates: DuplicateCandidate[] }>> {
await requireUser();
const setNumber = input.setNumber?.trim() ?? '';
const name = input.name?.trim() ?? '';
if (!input.manufacturerId || (setNumber === '' && name === '')) {
return actionData({ candidates: [] });
}
const rows = db
.select({
id: set.id,
setNumber: set.setNumber,
name: set.name,
manufacturerId: set.manufacturerId,
manufacturerName: manufacturer.name,
})
.from(set)
.innerJoin(manufacturer, eq(manufacturer.id, set.manufacturerId))
.where(input.excludeSetId ? ne(set.id, input.excludeSetId) : undefined)
.all();
const candidates: DuplicateCandidate[] = [];
for (const row of rows) {
const sameManufacturer = row.manufacturerId === input.manufacturerId;
const sameNumber = setNumber !== '' && row.setNumber.toLowerCase() === setNumber.toLowerCase();
if (sameManufacturer && sameNumber) {
candidates.push({ ...row, reason: 'same-number' });
continue;
}
if (sameManufacturer && name !== '' && isSimilarName(row.name, name)) {
candidates.push({ ...row, reason: 'similar-name' });
continue;
}
if (!sameManufacturer && sameNumber) {
candidates.push({ ...row, reason: 'number-elsewhere' });
}
}
// Certain matches first, and never more than a handful.
const order: Record<DuplicateCandidate['reason'], number> = {
'same-number': 0,
'similar-name': 1,
'number-elsewhere': 2,
};
candidates.sort((a, b) => order[a.reason] - order[b.reason]);
return actionData({ candidates: candidates.slice(0, 5) });
}
/** Used by the import to point at an existing entry instead of failing blindly. */
export async function findSetByNumber(
manufacturerId: string,
setNumber: string,
): Promise<ActionResult<{ id: string } | null>> {
await requireUser();
const row = db
.select({ id: set.id })
.from(set)
.where(and(eq(set.manufacturerId, manufacturerId), eq(set.setNumber, setNumber)))
.get();
return actionData(row ?? null);
}
+237
View File
@@ -0,0 +1,237 @@
'use server';
import { revalidatePath } from 'next/cache';
import { and, eq } from 'drizzle-orm';
import { db } from '@/db';
import { instruction, manufacturer, set, theme } from '@/db/schema';
import { actionData, actionError, type ActionResult } from '@/lib/action-result';
import {
fetchImageBytes,
fetchMinifigCount,
fetchSet,
fetchTheme,
isRebrickableEnabled,
legoInstructionsUrl,
RebrickableError,
} from '@/lib/rebrickable';
import { requireUser } from '@/lib/session';
import { uniqueSlug } from '@/lib/slug';
import { storeImageBytes, UploadError } from '@/lib/uploads';
/**
* Import of a Lego set from Rebrickable.
*
* Runs entirely on the server, creates the manufacturer and the theme if they
* are missing, and stores the picture locally so the catalog keeps working
* when Rebrickable changes it.
*/
const LEGO_SLUG = 'lego';
function now() {
return new Date();
}
/** Returns the Lego manufacturer, creating it on first import. */
function ensureLegoManufacturer(): string {
const existing = db
.select({ id: manufacturer.id })
.from(manufacturer)
.where(eq(manufacturer.slug, LEGO_SLUG))
.get();
if (existing) return existing.id;
const taken = db.select({ slug: manufacturer.slug }).from(manufacturer).all();
const id = crypto.randomUUID();
db.insert(manufacturer)
.values({
id,
name: 'Lego',
slug: uniqueSlug('Lego', taken.map((row) => row.slug)),
website: 'https://www.lego.com/de-de',
createdAt: now(),
updatedAt: now(),
})
.run();
return id;
}
/**
* Maps a Rebrickable theme onto a local one, creating it (and its parent) when
* needed. Rebrickable nests themes deeply; one level of parent is enough to
* keep "Technic > Cars" readable without importing the whole tree.
*/
async function ensureTheme(manufacturerId: string, themeId: number | null): Promise<string | null> {
if (themeId === null) return null;
const remote = await fetchTheme(themeId);
if (!remote) return null;
const existing = db
.select({ id: theme.id })
.from(theme)
.where(and(eq(theme.manufacturerId, manufacturerId), eq(theme.name, remote.name)))
.get();
if (existing) return existing.id;
let parentThemeId: string | null = null;
if (remote.parentId !== null) {
const parent = await fetchTheme(remote.parentId);
if (parent) {
const existingParent = db
.select({ id: theme.id })
.from(theme)
.where(and(eq(theme.manufacturerId, manufacturerId), eq(theme.name, parent.name)))
.get();
if (existingParent) {
parentThemeId = existingParent.id;
} else {
parentThemeId = crypto.randomUUID();
db.insert(theme)
.values({
id: parentThemeId,
manufacturerId,
name: parent.name,
createdAt: now(),
updatedAt: now(),
})
.run();
}
}
}
const id = crypto.randomUUID();
db.insert(theme)
.values({
id,
manufacturerId,
name: remote.name,
parentThemeId,
createdAt: now(),
updatedAt: now(),
})
.run();
return id;
}
export type ImportResult = {
setId: string;
setNumber: string;
name: string;
imported: {
image: boolean;
theme: boolean;
minifigs: boolean;
};
};
export async function importSetFromRebrickable(
rawSetNumber: unknown,
): Promise<ActionResult<ImportResult>> {
await requireUser();
if (!isRebrickableEnabled()) {
return actionError(
'Der Import ist nicht eingerichtet. Trag REBRICKABLE_API_KEY in der .env ein und starte den Container neu.',
);
}
const setNumber = typeof rawSetNumber === 'string' ? rawSetNumber.trim() : '';
if (!setNumber) return actionError('Bitte gib eine Setnummer ein.');
if (setNumber.length > 40) return actionError('Das sieht nicht nach einer Setnummer aus.');
let remote;
try {
remote = await fetchSet(setNumber);
} catch (error) {
if (error instanceof RebrickableError) return actionError(error.message);
throw error;
}
if (!remote) {
return actionError(
`Rebrickable kennt die Setnummer ${setNumber} nicht. Du kannst das Set von Hand anlegen.`,
);
}
const manufacturerId = ensureLegoManufacturer();
const duplicate = db
.select({ id: set.id })
.from(set)
.where(and(eq(set.manufacturerId, manufacturerId), eq(set.setNumber, remote.setNumber)))
.get();
if (duplicate) {
return actionError(`${remote.setNumber} ist schon im Katalog: ${remote.name}.`);
}
const themeId = await ensureTheme(manufacturerId, remote.themeId);
const minifigCount = await fetchMinifigCount(remote.setNumber);
const setId = crypto.randomUUID();
// Cache the picture locally; a failed download must not fail the import.
let imagePath: string | null = null;
if (remote.imageUrl) {
const bytes = await fetchImageBytes(remote.imageUrl);
if (bytes) {
try {
imagePath = (await storeImageBytes(bytes, 'set-image', setId)).filePath;
} catch (error) {
if (!(error instanceof UploadError)) throw error;
console.error('[import] Bild konnte nicht gespeichert werden', error);
}
}
}
db.insert(set)
.values({
id: setId,
manufacturerId,
themeId,
setNumber: remote.setNumber,
name: remote.name,
year: remote.year,
pieceCount: remote.pieceCount,
minifigCount,
imagePath,
// Keep the source URL as a fallback if the local file is ever lost.
imageUrl: imagePath ? null : remote.imageUrl,
externalIds: { rebrickable: `${remote.setNumber}-1` },
createdAt: now(),
updatedAt: now(),
})
.run();
// Rebrickable does not serve instruction files, so this points at Lego's
// own service page for the set number.
db.insert(instruction)
.values({
id: crypto.randomUUID(),
setId,
kind: 'external_url',
url: legoInstructionsUrl(remote.setNumber),
label: 'Bauanleitung bei LEGO',
sortOrder: 0,
createdAt: now(),
})
.run();
revalidatePath('/katalog');
revalidatePath('/verwaltung/themen');
return actionData({
setId,
setNumber: remote.setNumber,
name: remote.name,
imported: {
image: imagePath !== null,
theme: themeId !== null,
minifigs: minifigCount !== null,
},
});
}
+19 -2
View File
@@ -1,8 +1,10 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { RebrickableImport } from '@/components/katalog/rebrickable-import';
import { SetForm } from '@/components/katalog/set-form';
import { Button } from '@/components/ui/button';
import { listManufacturers, listThemes } from '@/lib/queries/master-data';
import { isRebrickableEnabled } from '@/lib/rebrickable';
import { requireUser } from '@/lib/session';
export const metadata: Metadata = { title: 'Neues Set' };
@@ -21,12 +23,19 @@ export default async function NeuesSetPage({ searchParams }: { searchParams: Sea
manufacturerId: row.manufacturerId,
}));
const importEnabled = isRebrickableEnabled();
if (manufacturers.length === 0) {
return (
<div className="space-y-4">
<div className="space-y-6">
<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">
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.
</p>
<Button asChild size="lg">
@@ -45,6 +54,14 @@ export default async function NeuesSetPage({ searchParams }: { searchParams: Sea
Sammlung hinzufügen“ fest.
</p>
</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} />
</div>
);
+25 -8
View File
@@ -1,6 +1,8 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { Plus } from 'lucide-react';
import { CollectorFilter } from '@/components/layout/collector-filter';
import { BarcodeScannerButton } from '@/components/katalog/barcode-scanner';
import { CatalogFilters } from '@/components/katalog/catalog-filters';
import { CatalogGrid } from '@/components/katalog/catalog-grid';
import { Pagination } from '@/components/katalog/pagination';
@@ -8,6 +10,7 @@ import { Button } from '@/components/ui/button';
import { pluralize } from '@/lib/format';
import { OWNERSHIP_FILTER_LABELS, parseOwnershipFilter } from '@/lib/ownership';
import { CATALOG_PAGE_SIZE, listSets, listSetYears } from '@/lib/queries/catalog';
import { listCollectors, resolveCollectorId } from '@/lib/queries/collectors';
import { listManufacturers, listThemes } from '@/lib/queries/master-data';
import { listWishlistTargets } from '@/lib/queries/wishlists';
import { requireUser } from '@/lib/session';
@@ -26,6 +29,9 @@ export default async function KatalogPage({ searchParams }: { searchParams: Sear
const params = await searchParams;
const ownership = parseOwnershipFilter(single(params.besitz));
const collectors = listCollectors();
const collectorId = resolveCollectorId(single(params.sammler));
const activeCollector = collectors.find((row) => row.id === collectorId) ?? null;
const yearParam = single(params.jahr);
const page = Number(single(params.seite) ?? '1') || 1;
@@ -35,6 +41,7 @@ export default async function KatalogPage({ searchParams }: { searchParams: Sear
themeId: single(params.thema),
year: yearParam ? Number(yearParam) : undefined,
ownership,
collectorId,
page,
};
@@ -56,18 +63,28 @@ export default async function KatalogPage({ searchParams }: { searchParams: Sear
</h1>
<p className="text-sm text-muted-foreground">
{ownership === 'fehlt'
? 'Sets, die noch nicht in der Sammlung sind.'
? activeCollector
? `Sets, die ${activeCollector.name} noch nicht hat.`
: 'Sets, die noch nicht in der Sammlung sind.'
: 'Alle bekannten Sets – unabhängig davon, ob sie schon da sind.'}
</p>
</div>
<Button asChild size="lg" className="shrink-0">
<Link href="/katalog/neu">
<Plus />
<span className="hidden sm:inline">Set anlegen</span>
</Link>
</Button>
<div className="flex shrink-0 gap-2">
<BarcodeScannerButton />
<Button asChild size="lg">
<Link href="/katalog/neu">
<Plus />
<span className="hidden sm:inline">Set anlegen</span>
</Link>
</Button>
</div>
</div>
<CollectorFilter
collectors={collectors.map((row) => ({ id: row.id, name: row.name, color: row.color }))}
allLabel="Alle zusammen"
/>
<CatalogFilters options={{ manufacturers, themes, years: listSetYears() }} />
<p className="text-sm text-muted-foreground">
@@ -75,7 +92,7 @@ export default async function KatalogPage({ searchParams }: { searchParams: Sear
{ownership !== 'alle' ? ` · ${OWNERSHIP_FILTER_LABELS[ownership]}` : ''}
</p>
<CatalogGrid sets={rows} wishlists={wishlists} />
<CatalogGrid sets={rows} wishlists={wishlists} collectorId={collectorId} />
<Pagination page={page} total={total} pageSize={CATALOG_PAGE_SIZE} />
</div>
+61
View File
@@ -0,0 +1,61 @@
'use server';
import { revalidatePath } from 'next/cache';
import { eq } from 'drizzle-orm';
import { z } from 'zod';
import { db } from '@/db';
import { pricePoint } from '@/db/schema';
import { actionError, actionOk, parseForm, type ActionResult } from '@/lib/action-result';
import { requireUser } from '@/lib/session';
import { optionalDate, optionalText, optionalPriceCents } from '@/lib/validation';
const schema = z.object({
priceCents: optionalPriceCents,
condition: z.enum(['new', 'used']).default('new'),
source: optionalText(80),
observedAt: optionalDate,
note: optionalText(200),
});
export async function addPricePoint(setId: string, input: unknown): Promise<ActionResult> {
await requireUser();
const parsed = parseForm(schema, input);
if (!parsed.ok) return parsed;
if (parsed.data.priceCents === null) return actionError('Bitte gib einen Preis an.');
db.insert(pricePoint)
.values({
id: crypto.randomUUID(),
setId,
priceCents: parsed.data.priceCents,
condition: parsed.data.condition,
source: parsed.data.source,
// No date given means "seen today".
observedAt: parsed.data.observedAt ?? new Date(),
note: parsed.data.note,
createdAt: new Date(),
})
.run();
revalidatePath(`/katalog/${setId}`);
revalidatePath('/sammlung');
return actionOk();
}
export async function deletePricePoint(id: string): Promise<ActionResult> {
await requireUser();
const existing = db
.select({ setId: pricePoint.setId })
.from(pricePoint)
.where(eq(pricePoint.id, id))
.get();
if (!existing) return actionOk();
db.delete(pricePoint).where(eq(pricePoint.id, id)).run();
revalidatePath(`/katalog/${existing.setId}`);
revalidatePath('/sammlung');
return actionOk();
}
+37
View File
@@ -0,0 +1,37 @@
'use server';
import { actionData, actionError, type ActionResult } from '@/lib/action-result';
import { isValidEan } from '@/lib/ean';
import { findSetByEan } from '@/lib/queries/catalog';
import { requireUser } from '@/lib/session';
export type ScanLookup =
| { found: true; setId: string; setNumber: string; name: string }
| { found: false; ean: string };
/**
* Resolves a scanned barcode.
*
* Either the set is already in the catalog - then we jump straight to it - or
* it is not, and the caller opens the "new set" form with the number filled in.
*/
export async function lookupScannedEan(rawEan: unknown): Promise<ActionResult<ScanLookup>> {
await requireUser();
const ean = typeof rawEan === 'string' ? rawEan.replace(/\D/g, '') : '';
if (!isValidEan(ean)) {
return actionError('Der Strichcode wurde nicht sauber erkannt. Bitte noch einmal scannen.');
}
const match = findSetByEan(ean);
if (match) {
return actionData({
found: true as const,
setId: match.id,
setNumber: match.setNumber,
name: match.name,
});
}
return actionData({ found: false as const, ean });
}
+5
View File
@@ -1,8 +1,13 @@
import { AppHeader } from '@/components/layout/app-header';
import { BottomNav } from '@/components/layout/bottom-nav';
import { ensureReminderScheduler } from '@/lib/reminder-job';
import { requireUser } from '@/lib/session';
export default async function AppLayout({ children }: { children: React.ReactNode }) {
// Second entry point besides the healthcheck, in case someone turns that
// off. The call is idempotent.
ensureReminderScheduler();
const user = await requireUser();
return (
+133 -4
View File
@@ -1,9 +1,9 @@
'use server';
import { revalidatePath } from 'next/cache';
import { eq } from 'drizzle-orm';
import { and, eq } from 'drizzle-orm';
import { db } from '@/db';
import { collectionItem, set } from '@/db/schema';
import { collectionItem, missingPart, set } from '@/db/schema';
import {
actionData,
actionError,
@@ -11,9 +11,10 @@ import {
parseForm,
type ActionResult,
} from '@/lib/action-result';
import { defaultCollectorId } from '@/lib/queries/collectors';
import { reconcileFulfilledWishlistItems } from '@/lib/queries/wishlists';
import { requireUser } from '@/lib/session';
import { collectionItemSchema } from '@/lib/validation';
import { collectionItemSchema, missingPartSchema } from '@/lib/validation';
import type { Condition } from '@/db/types';
function now() {
@@ -32,17 +33,24 @@ function revalidateCollection(setId?: string) {
* Quick add from the set detail page: one tap, sensible defaults, details can
* be filled in later.
*/
export async function addSetToCollection(setId: string): Promise<ActionResult<{ id: string }>> {
export async function addSetToCollection(
setId: string,
collectorId?: string,
): Promise<ActionResult<{ id: string }>> {
await requireUser();
const exists = db.select({ id: set.id }).from(set).where(eq(set.id, setId)).get();
if (!exists) return actionError('Das Set gibt es nicht mehr.');
// With a single collector nobody is asked - the copy simply belongs to them.
const owner = collectorId ?? defaultCollectorId();
const id = crypto.randomUUID();
db.insert(collectionItem)
.values({
id,
setId,
collectorId: owner,
status: 'owned',
completeness: 'unknown',
createdAt: now(),
@@ -68,6 +76,7 @@ export async function updateCollectionItem(id: string, input: unknown): Promise<
db.update(collectionItem)
.set({
collectorId: parsed.data.collectorId ?? existing.collectorId,
status: parsed.data.status,
completeness: parsed.data.completeness,
condition: parsed.data.condition as Condition | null,
@@ -103,3 +112,123 @@ export async function deleteCollectionItem(id: string): Promise<ActionResult> {
revalidateCollection(existing.setId);
return actionOk();
}
// ---------------------------------------------------------------------------
// Missing parts
// ---------------------------------------------------------------------------
/**
* Keeps `completeness` honest without ever fighting the user.
*
* The moment a part is reported missing the item is not complete any more, and
* once the last open entry is resolved it is complete again. A deliberate
* "unknown" is left alone in the second direction only.
*/
function syncCompleteness(collectionItemId: string): void {
const open = db
.select({ id: missingPart.id })
.from(missingPart)
.where(and(eq(missingPart.collectionItemId, collectionItemId), eq(missingPart.resolved, false)))
.all();
const item = db
.select({ completeness: collectionItem.completeness })
.from(collectionItem)
.where(eq(collectionItem.id, collectionItemId))
.get();
if (!item) return;
if (open.length > 0 && item.completeness !== 'missing_parts') {
db.update(collectionItem)
.set({ completeness: 'missing_parts', updatedAt: now() })
.where(eq(collectionItem.id, collectionItemId))
.run();
return;
}
if (open.length === 0 && item.completeness === 'missing_parts') {
db.update(collectionItem)
.set({ completeness: 'complete', updatedAt: now() })
.where(eq(collectionItem.id, collectionItemId))
.run();
}
}
function loadItemSetId(collectionItemId: string): string | null {
const row = db
.select({ setId: collectionItem.setId })
.from(collectionItem)
.where(eq(collectionItem.id, collectionItemId))
.get();
return row?.setId ?? null;
}
export async function addMissingPart(
collectionItemId: string,
input: unknown,
): Promise<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();
}
+36 -4
View File
@@ -1,19 +1,23 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { Boxes, Euro, ImageOff, Layers, PackageSearch } from 'lucide-react';
import { CollectorFilter } from '@/components/layout/collector-filter';
import { CollectionFilters } from '@/components/sammlung/collection-filters';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
import { formatDate, formatNumber, pluralize } from '@/lib/format';
import { collectorBadgeClass } from '@/lib/collector-colors';
import { setImageSrc } from '@/lib/images';
import { completenessLabel, statusLabel } from '@/lib/labels';
import { formatEuro } from '@/lib/money';
import {
countMissingSets,
countOpenMissingParts,
getCollectionStats,
listCollectionItems,
type CollectionFilters as Filters,
} from '@/lib/queries/collection';
import { listCollectors, resolveCollectorId } from '@/lib/queries/collectors';
import { listLocations, listManufacturers } from '@/lib/queries/master-data';
import { requireUser } from '@/lib/session';
import type { CollectionStatus, Completeness } from '@/db/types';
@@ -31,22 +35,33 @@ export default async function SammlungPage({ searchParams }: { searchParams: Sea
await requireUser();
const params = await searchParams;
const collectors = listCollectors();
const collectorId = resolveCollectorId(single(params.sammler));
const activeCollector = collectors.find((row) => row.id === collectorId) ?? null;
const filters: Filters = {
q: single(params.q),
status: single(params.status) as CollectionStatus | undefined,
completeness: single(params.zustand) as Completeness | undefined,
locationId: single(params.standort),
manufacturerId: single(params.hersteller),
collectorId,
};
const items = listCollectionItems(filters);
const stats = getCollectionStats();
const missing = countMissingSets();
const openParts = countOpenMissingParts(items.map((item) => item.id));
const stats = getCollectionStats(collectorId);
const missing = countMissingSets(collectorId);
const tiles = [
{ icon: Boxes, label: 'Sets', value: formatNumber(stats.setCount) },
{ icon: Layers, label: 'Teile gesamt', value: formatNumber(stats.pieceCount) },
{ icon: Euro, label: 'Wert nach UVP', value: formatEuro(stats.retailValueCents) },
{
icon: Euro,
// Notierte Preise gehen vor, sonst die UVP.
label: stats.currentValueCents === stats.retailValueCents ? 'Wert nach UVP' : 'Aktueller Wert',
value: formatEuro(stats.currentValueCents),
},
{ icon: PackageSearch, label: 'Fehlt noch', value: formatNumber(missing) },
] as const;
@@ -54,9 +69,18 @@ export default async function SammlungPage({ searchParams }: { searchParams: Sea
<div className="space-y-5">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Sammlung</h1>
<p className="text-sm text-muted-foreground">Alles, was tatsächlich da ist.</p>
<p className="text-sm text-muted-foreground">
{activeCollector
? `Alles, was ${activeCollector.name} tatsächlich hat.`
: 'Alles, was tatsächlich da ist.'}
</p>
</div>
<CollectorFilter
collectors={collectors.map((row) => ({ id: row.id, name: row.name, color: row.color }))}
allLabel="Alle zusammen"
/>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{tiles.map((tile) => (
<Card key={tile.label}>
@@ -132,6 +156,14 @@ export default async function SammlungPage({ searchParams }: { searchParams: Sea
{item.completeness !== 'unknown' ? (
<Badge variant="secondary">{completenessLabel(item.completeness)}</Badge>
) : null}
{collectors.length > 1 && item.collectorName ? (
<Badge className={collectorBadgeClass(item.collectorColor)}>
{item.collectorName}
</Badge>
) : null}
{openParts.get(item.id) ? (
<Badge variant="outline">{openParts.get(item.id)} Fehlteile</Badge>
) : null}
{item.acquiredAt ? (
<span className="text-xs text-muted-foreground">
seit {formatDate(item.acquiredAt)}
+45
View File
@@ -0,0 +1,45 @@
'use server';
import { revalidatePath } from 'next/cache';
import { actionData, actionError, type ActionResult } from '@/lib/action-result';
import { importArchive, ImportError, type ImportSummary } from '@/lib/export';
import { requireAdmin } from '@/lib/session';
const MAX_ARCHIVE_BYTES = 512 * 1024 * 1024;
/** Typed by hand before anything is deleted. */
const CONFIRMATION = 'ERSETZEN';
export async function importArchiveAction(
formData: FormData,
): Promise<ActionResult<ImportSummary>> {
const admin = await requireAdmin();
if (formData.get('confirmation') !== CONFIRMATION) {
return actionError(`Bitte tipp ${CONFIRMATION} ein, um den Austausch zu bestätigen.`);
}
const file = formData.get('archive');
if (!(file instanceof File) || file.size === 0) {
return actionError('Bitte wähle ein Bricktrack-Archiv (.zip) aus.');
}
if (file.size > MAX_ARCHIVE_BYTES) {
return actionError('Das Archiv ist größer als 512 MB und wird nicht eingelesen.');
}
try {
const summary = importArchive(
new Uint8Array(await file.arrayBuffer()),
admin.id,
);
revalidatePath('/', 'layout');
return actionData(summary);
} catch (error) {
if (error instanceof ImportError) return actionError(error.message);
console.error('[import] Archiv konnte nicht eingelesen werden', error);
return actionError(
'Das Archiv konnte nicht eingelesen werden. Die vorhandenen Daten wurden nicht verändert.',
);
}
}
+10
View File
@@ -0,0 +1,10 @@
import type { Metadata } from 'next';
import { DataTransfer } from '@/components/verwaltung/data-transfer';
import { requireAdmin } from '@/lib/session';
export const metadata: Metadata = { title: 'Daten' };
export default async function DatenPage() {
await requireAdmin();
return <DataTransfer />;
}
@@ -0,0 +1,44 @@
'use server';
import { revalidatePath } from 'next/cache';
import { z } from 'zod';
import { actionData, actionOk, parseForm, type ActionResult } from '@/lib/action-result';
import { createInvite, deleteInvite, INVITE_VALID_DAYS } from '@/lib/invites';
import { requireAdmin } from '@/lib/session';
import { optionalText } from '@/lib/validation';
const schema = z.object({
// Optional: when set, the invitation only works for that address.
email: optionalText(160),
role: z.enum(['admin', 'member']).default('member'),
validDays: z.coerce.number().int().min(1).max(90).default(INVITE_VALID_DAYS),
});
export async function createInviteAction(
input: unknown,
): Promise<ActionResult<{ token: string }>> {
const admin = await requireAdmin();
const parsed = parseForm(schema, input);
if (!parsed.ok) return parsed;
const created = createInvite({
createdByUserId: admin.id,
email: parsed.data.email,
role: parsed.data.role,
validDays: parsed.data.validDays,
});
revalidatePath('/verwaltung/einladungen');
// The plaintext token is returned once and never stored - only its hash is.
return actionData({ token: created.token });
}
export async function deleteInviteAction(id: string): Promise<ActionResult> {
await requireAdmin();
deleteInvite(id);
revalidatePath('/verwaltung/einladungen');
return actionOk();
}
@@ -0,0 +1,22 @@
import type { Metadata } from 'next';
import { InviteList, type InviteListRow } from '@/components/verwaltung/invite-list';
import { inviteState, listInvites } from '@/lib/invites';
import { requireAdmin } from '@/lib/session';
export const metadata: Metadata = { title: 'Einladungen' };
export default async function EinladungenPage() {
await requireAdmin();
const invites: InviteListRow[] = listInvites().map((row) => ({
id: row.id,
email: row.email,
role: row.role,
expiresAt: row.expiresAt,
usedAt: row.usedAt,
usedByName: row.usedByName,
state: inviteState(row),
}));
return <InviteList invites={invites} />;
}
@@ -0,0 +1,51 @@
'use server';
import { revalidatePath } from 'next/cache';
import { actionData, actionError, type ActionResult } from '@/lib/action-result';
import { env } from '@/env';
import { sendMail, verifyMailConnection } from '@/lib/mailer';
import { runReminderJob, type ReminderRun } from '@/lib/reminder-job';
import { requireAdmin } from '@/lib/session';
export async function testMailConnection(): Promise<ActionResult> {
await requireAdmin();
const result = await verifyMailConnection();
return result.ok ? { ok: true } : actionError(result.error);
}
export async function sendTestMail(): Promise<ActionResult> {
const admin = await requireAdmin();
if (!env.isMailConfigured) {
return actionError('Es ist kein SMTP-Server konfiguriert.');
}
const result = await sendMail({
to: admin.email,
subject: 'Bricktrack: Testnachricht',
text: [
'Das hat geklappt.',
'',
'Wenn diese Nachricht ankommt, funktionieren auch die Erinnerungen zu',
'Wunschlisten-Terminen.',
'',
env.BASE_URL,
].join('\n'),
});
return result.ok ? { ok: true } : actionError(result.error);
}
/** Runs the check immediately instead of waiting for the next hour. */
export async function runRemindersNow(): Promise<ActionResult<ReminderRun>> {
await requireAdmin();
if (!env.isMailConfigured) {
return actionError('Es ist kein SMTP-Server konfiguriert.');
}
const run = await runReminderJob();
revalidatePath('/verwaltung/erinnerungen');
return actionData(run);
}
@@ -0,0 +1,51 @@
import type { Metadata } from 'next';
import { eq, sql } from 'drizzle-orm';
import { db } from '@/db';
import { wishlist } from '@/db/schema';
import { env } from '@/env';
import {
ReminderSettings,
type ReminderListRow,
} from '@/components/verwaltung/reminder-settings';
import { daysUntil } from '@/lib/reminders';
import { requireAdmin } from '@/lib/session';
export const metadata: Metadata = { title: 'Erinnerungen' };
export default async function ErinnerungenPage() {
const admin = await requireAdmin();
const openItems = sql<number>`(
select count(*) from wishlist_item wi
where wi.wishlist_id = ${wishlist.id}
and wi.reserved_at is null
and wi.fulfilled_at is null
)`.mapWith(Number);
const now = new Date();
const lists: ReminderListRow[] = db
.select({
id: wishlist.id,
title: wishlist.title,
eventDate: wishlist.eventDate,
lastReminderAt: wishlist.lastReminderAt,
openItemCount: openItems,
})
.from(wishlist)
.where(eq(wishlist.ownerUserId, admin.id))
.all()
.filter((row) => row.eventDate !== null)
.map((row) => ({
...row,
daysLeft: row.eventDate ? daysUntil(row.eventDate, now) : null,
}))
.sort((a, b) => (a.daysLeft ?? 0) - (b.daysLeft ?? 0));
return (
<ReminderSettings
configured={env.isMailConfigured}
daysBefore={env.REMINDER_DAYS_BEFORE}
lists={lists}
/>
);
}
+2 -2
View File
@@ -5,7 +5,7 @@ import { requireUser } from '@/lib/session';
export const metadata: Metadata = { title: 'Verwaltung' };
export default async function VerwaltungLayout({ children }: { children: React.ReactNode }) {
await requireUser();
const user = await requireUser();
return (
<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.
</p>
</div>
<VerwaltungNav />
<VerwaltungNav isAdmin={user.role === 'admin'} />
{children}
</div>
);
+160
View File
@@ -0,0 +1,160 @@
'use server';
import { revalidatePath } from 'next/cache';
import { asc, eq, sql } from 'drizzle-orm';
import { db } from '@/db';
import { collectionItem, collector, wishlist } from '@/db/schema';
import { actionError, actionOk, parseForm, type ActionResult } from '@/lib/action-result';
import { requireUser } from '@/lib/session';
import { collectorSchema } from '@/lib/validation';
function now() {
return new Date();
}
function revalidateEverything() {
// A collector shows up in the catalog, the collection and the wishlists.
revalidatePath('/', 'layout');
}
export async function createCollector(input: unknown): Promise<ActionResult> {
await requireUser();
const parsed = parseForm(collectorSchema, input);
if (!parsed.ok) return parsed;
const [row] = db
.select({ next: sql<number>`coalesce(max(${collector.sortOrder}), -1) + 1`.mapWith(Number) })
.from(collector)
.all();
db.insert(collector)
.values({
id: crypto.randomUUID(),
name: parsed.data.name,
color: parsed.data.color,
birthday: parsed.data.birthday,
notes: parsed.data.notes,
sortOrder: row?.next ?? 0,
createdAt: now(),
updatedAt: now(),
})
.run();
revalidateEverything();
return actionOk();
}
export async function updateCollector(id: string, input: unknown): Promise<ActionResult> {
await requireUser();
const parsed = parseForm(collectorSchema, input);
if (!parsed.ok) return parsed;
db.update(collector)
.set({
name: parsed.data.name,
color: parsed.data.color,
birthday: parsed.data.birthday,
notes: parsed.data.notes,
updatedAt: now(),
})
.where(eq(collector.id, id))
.run();
revalidateEverything();
return actionOk();
}
/**
* Deleting a collector would take their whole collection with it, so it is
* only allowed while nothing is attached - and never for the last one.
*/
export async function deleteCollector(id: string): Promise<ActionResult> {
await requireUser();
const remaining = db.select({ id: collector.id }).from(collector).all();
if (remaining.length <= 1) {
return actionError('Der letzte Sammler kann nicht gelöscht werden.');
}
const owned = db
.select({ id: collectionItem.id })
.from(collectionItem)
.where(eq(collectionItem.collectorId, id))
.get();
if (owned) {
return actionError(
'Diesem Sammler sind noch Sets zugeordnet. Ordne sie erst jemand anderem zu, dann lässt er sich löschen.',
);
}
const lists = db
.select({ id: wishlist.id })
.from(wishlist)
.where(eq(wishlist.collectorId, id))
.get();
if (lists) {
return actionError(
'Zu diesem Sammler gibt es noch Wunschlisten. Ordne sie erst um oder lösch sie.',
);
}
db.delete(collector).where(eq(collector.id, id)).run();
revalidateEverything();
return actionOk();
}
/** Moves every owned copy of one collector over to another. */
export async function reassignCollectionItems(
fromCollectorId: string,
toCollectorId: string,
): Promise<ActionResult> {
await requireUser();
if (fromCollectorId === toCollectorId) return actionOk();
const target = db
.select({ id: collector.id })
.from(collector)
.where(eq(collector.id, toCollectorId))
.get();
if (!target) return actionError('Diesen Sammler gibt es nicht.');
db.update(collectionItem)
.set({ collectorId: toCollectorId, updatedAt: now() })
.where(eq(collectionItem.collectorId, fromCollectorId))
.run();
revalidateEverything();
return actionOk();
}
/** Reorders the collectors; the first one is the default for new entries. */
export async function reorderCollectors(orderedIds: string[]): Promise<ActionResult> {
await requireUser();
db.transaction((tx) => {
orderedIds.forEach((id, index) => {
tx.update(collector)
.set({ sortOrder: index, updatedAt: now() })
.where(eq(collector.id, id))
.run();
});
});
revalidateEverything();
return actionOk();
}
/** Every collector, ordered - for the selects in the forms. */
export async function listCollectorOptions(): Promise<Array<{ id: string; name: string }>> {
await requireUser();
return db
.select({ id: collector.id, name: collector.name })
.from(collector)
.orderBy(asc(collector.sortOrder), asc(collector.name))
.all();
}
+40
View File
@@ -0,0 +1,40 @@
import type { Metadata } from 'next';
import { CollectorList, type CollectorRowItem } from '@/components/verwaltung/collector-list';
import { formatDate, pluralize } from '@/lib/format';
import { COLLECTOR_COLOR_LABELS } from '@/lib/collector-colors';
import { listCollectors } from '@/lib/queries/collectors';
import { requireUser } from '@/lib/session';
export const metadata: Metadata = { title: 'Sammler' };
export default async function SammlerPage() {
await requireUser();
const collectors = listCollectors();
const rows: CollectorRowItem[] = collectors.map((row) => ({
id: row.id,
title: row.name,
subtitle: [
pluralize(row.itemCount, 'Set', 'Sets'),
COLLECTOR_COLOR_LABELS[row.color],
row.birthday ? `geboren am ${formatDate(row.birthday)}` : null,
]
.filter(Boolean)
.join(' · '),
color: row.color,
birthday: row.birthday,
notes: row.notes,
itemCount: row.itemCount,
}));
return (
<div className="space-y-4">
<p className="rounded-lg border border-dashed border-border p-4 text-sm text-muted-foreground">
Der Katalog ist gemeinsam: Ein Set existiert einmal, unabhängig davon, wer es hat.
Sammlung und Wunschlisten gehören dagegen je einem Sammler. Solange es nur einen gibt,
taucht die Unterscheidung nirgends in der App auf – sie erscheint erst mit dem zweiten.
</p>
<CollectorList collectors={rows} />
</div>
);
}
+17 -2
View File
@@ -8,10 +8,12 @@ import { WishlistHeaderActions } from '@/components/wunschlisten/wishlist-header
import { WishlistItems } from '@/components/wunschlisten/wishlist-items';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { collectorBadgeClass } from '@/lib/collector-colors';
import { formatDate, pluralize } from '@/lib/format';
import { occasionLabel } from '@/lib/labels';
import { CATALOG_PAGE_SIZE, listSets, listSetYears } from '@/lib/queries/catalog';
import { listManufacturers, listThemes } from '@/lib/queries/master-data';
import { listCollectors } from '@/lib/queries/collectors';
import { getWishlist, listWishlistItems } from '@/lib/queries/wishlists';
import { requireUser } from '@/lib/session';
@@ -39,7 +41,9 @@ export default async function WunschlisteDetailPage({ params, searchParams }: Pr
const list = getWishlist(id);
if (!list || list.ownerUserId !== user.id) notFound();
const items = listWishlistItems(list.id);
const collectors = listCollectors();
const activeCollector = collectors.find((row) => row.id === list.collectorId) ?? null;
const items = listWishlistItems(list.id, list.collectorId);
// The embedded picker: missing sets that are not on this list yet. It shares
// the URL parameters with the catalog, so the filters behave identically.
@@ -52,6 +56,7 @@ export default async function WunschlisteDetailPage({ params, searchParams }: Pr
year: yearParam ? Number(yearParam) : undefined,
ownership: 'fehlt',
excludeWishlistId: list.id,
collectorId: list.collectorId ?? undefined,
page,
});
@@ -63,6 +68,11 @@ export default async function WunschlisteDetailPage({ params, searchParams }: Pr
<Badge variant="secondary">{occasionLabel(list.occasion)}</Badge>
{list.eventDate ? <Badge variant="outline">{formatDate(list.eventDate)}</Badge> : null}
{list.surpriseMode ? <Badge variant="outline">Überraschungsmodus</Badge> : null}
{collectors.length > 1 && activeCollector ? (
<Badge className={collectorBadgeClass(activeCollector.color)}>
für {activeCollector.name}
</Badge>
) : null}
</div>
<h1 className="mt-2 truncate text-2xl font-semibold tracking-tight">{list.title}</h1>
<p className="text-sm text-muted-foreground">
@@ -70,8 +80,10 @@ export default async function WunschlisteDetailPage({ params, searchParams }: Pr
</p>
</div>
<WishlistHeaderActions
collectors={collectors.map((row) => ({ id: row.id, name: row.name }))}
wishlist={{
id: list.id,
collectorId: list.collectorId,
title: list.title,
occasion: list.occasion,
eventDate: list.eventDate,
@@ -99,7 +111,9 @@ export default async function WunschlisteDetailPage({ params, searchParams }: Pr
<CardHeader>
<CardTitle>Aus fehlenden Sets hinzufügen</CardTitle>
<CardDescription>
Nur Sets, die noch nicht in der Sammlung und noch nicht auf dieser Liste sind. Kachel
{activeCollector
? `Nur Sets, die ${activeCollector.name} noch nicht hat und die noch nicht auf dieser Liste stehen.`
: 'Nur Sets, die noch nicht in der Sammlung und noch nicht auf dieser Liste sind.'}{' '} Kachel
lange drücken oder „Auswählen“ tippen, dann mehrere auf einmal übernehmen.
</CardDescription>
</CardHeader>
@@ -124,6 +138,7 @@ export default async function WunschlisteDetailPage({ params, searchParams }: Pr
sets={missing.rows}
wishlists={[{ id: list.id, title: list.title }]}
defaultWishlistId={list.id}
collectorId={list.collectorId ?? undefined}
emptyState="Es fehlt gerade nichts, was noch nicht auf dieser Liste steht."
/>
+4 -1
View File
@@ -12,6 +12,7 @@ import {
type ActionResult,
} from '@/lib/action-result';
import { ownedCopiesSql } from '@/lib/ownership';
import { defaultCollectorId } from '@/lib/queries/collectors';
import {
findExistingWishlistSetIds,
getWishlist,
@@ -58,6 +59,7 @@ export async function createWishlist(input: unknown): Promise<ActionResult<{ id:
.values({
id,
ownerUserId: user.id,
collectorId: parsed.data.collectorId ?? defaultCollectorId(),
title: parsed.data.title,
occasion: parsed.data.occasion,
eventDate: parsed.data.eventDate,
@@ -83,6 +85,7 @@ export async function updateWishlist(id: string, input: unknown): Promise<Action
db.update(wishlist)
.set({
collectorId: parsed.data.collectorId,
title: parsed.data.title,
occasion: parsed.data.occasion,
eventDate: parsed.data.eventDate,
@@ -153,7 +156,7 @@ export async function addSetsToWishlist(
id: set.id,
name: set.name,
setNumber: set.setNumber,
ownedCopies: ownedCopiesSql(set.id).mapWith(Number),
ownedCopies: ownedCopiesSql(set.id, list.collectorId).mapWith(Number),
})
.from(set)
.where(inArray(set.id, setIds))
+11 -1
View File
@@ -4,8 +4,10 @@ import { CalendarDays, Gift, Link2Off } from 'lucide-react';
import { NewWishlistButton } from '@/components/wunschlisten/wishlist-form-dialog';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { collectorBadgeClass } from '@/lib/collector-colors';
import { formatDate, pluralize } from '@/lib/format';
import { occasionLabel } from '@/lib/labels';
import { listCollectors } from '@/lib/queries/collectors';
import { listWishlists } from '@/lib/queries/wishlists';
import { requireUser } from '@/lib/session';
@@ -14,6 +16,7 @@ export const metadata: Metadata = { title: 'Wunschlisten' };
export default async function WunschlistenPage() {
const user = await requireUser();
const lists = listWishlists(user.id);
const collectors = listCollectors();
return (
<div className="space-y-5">
@@ -24,7 +27,9 @@ export default async function WunschlistenPage() {
Zum Teilen mit Familie und Freunden – ganz ohne Konto für die anderen.
</p>
</div>
<NewWishlistButton />
<NewWishlistButton
collectors={collectors.map((row) => ({ id: row.id, name: row.name }))}
/>
</div>
{lists.length === 0 ? (
@@ -54,6 +59,11 @@ export default async function WunschlistenPage() {
</CardDescription>
</CardHeader>
<CardContent className="flex flex-wrap items-center gap-2 text-xs">
{collectors.length > 1 && list.collectorName ? (
<Badge className={collectorBadgeClass(list.collectorColor)}>
{list.collectorName}
</Badge>
) : null}
<Badge variant="secondary">{pluralize(list.itemCount, 'Wunsch', 'Wünsche')}</Badge>
{list.openCount > 0 ? (
<Badge variant="outline">{list.openCount} noch offen</Badge>
@@ -0,0 +1,63 @@
'use server';
import { headers } from 'next/headers';
import { z } from 'zod';
import { actionError, actionOk, parseForm, type ActionResult } from '@/lib/action-result';
import { auth } from '@/lib/auth';
import { findUsableInvite, markInviteUsed, withInviteGrant } from '@/lib/invites';
const schema = z.object({
token: z.string().trim().min(1),
name: z.string().trim().min(1, 'Bitte gib deinen Namen an.').max(80),
email: z.email('Bitte gib eine gültige E-Mail-Adresse an.'),
password: z.string().min(10, 'Das Passwort braucht mindestens 10 Zeichen.'),
});
/**
* Registration through an invitation link.
*
* The token is checked here, on the server, and only then is the sign-up
* wrapped in a grant that the user-create hook can see. The role comes from
* the invitation, never from the submitted form.
*/
export async function registerWithInvite(input: unknown): Promise<ActionResult> {
const parsed = parseForm(schema, input);
if (!parsed.ok) return parsed;
const invitation = findUsableInvite(parsed.data.token);
if (!invitation) {
return actionError('Diese Einladung ist abgelaufen oder wurde bereits verwendet.');
}
if (invitation.email && invitation.email.toLowerCase() !== parsed.data.email.toLowerCase()) {
return actionError(`Diese Einladung gilt für ${invitation.email}.`);
}
const requestHeaders = await headers();
try {
const result = await withInviteGrant(
{ inviteId: invitation.id, role: invitation.role },
() =>
auth.api.signUpEmail({
body: {
name: parsed.data.name,
email: parsed.data.email,
password: parsed.data.password,
},
headers: requestHeaders,
}),
);
markInviteUsed(invitation.id, result.user.id);
} catch (error) {
const message = error instanceof Error ? error.message : '';
if (message.toLowerCase().includes('exist')) {
return actionError('Zu dieser E-Mail-Adresse gibt es schon ein Konto.');
}
console.error('[invite] Registrierung fehlgeschlagen', error);
return actionError('Die Registrierung hat nicht geklappt. Bitte versuch es noch einmal.');
}
return actionOk();
}
+54
View File
@@ -0,0 +1,54 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { InviteForm } from '@/components/auth/invite-form';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { formatDate } from '@/lib/format';
import { findUsableInvite } from '@/lib/invites';
import { getSession } from '@/lib/session';
export const metadata: Metadata = {
title: 'Einladung',
robots: { index: false, follow: false },
};
export default async function InvitePage({ params }: { params: Promise<{ token: string }> }) {
if (await getSession()) redirect('/');
const { token } = await params;
const invitation = findUsableInvite(token);
if (!invitation) {
return (
<Card>
<CardHeader>
<CardTitle>Einladung ungültig</CardTitle>
<CardDescription>
Dieser Link ist abgelaufen oder wurde schon benutzt. Lass dir einfach einen neuen
schicken.
</CardDescription>
</CardHeader>
<CardContent>
<Link href="/anmelden" className="text-sm underline underline-offset-4">
Zur Anmeldung
</Link>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle>Konto anlegen</CardTitle>
<CardDescription>
Du wurdest zu Bricktrack eingeladen. Der Link gilt noch bis zum{' '}
{formatDate(invitation.expiresAt)}.
</CardDescription>
</CardHeader>
<CardContent>
<InviteForm token={token} email={invitation.email} />
</CardContent>
</Card>
);
}
+106
View File
@@ -0,0 +1,106 @@
import { NextResponse } from 'next/server';
import {
buildCollectionCsv,
buildExportPayload,
buildExportZip,
exportFileName,
} from '@/lib/export';
import { formatDate } from '@/lib/format';
import { conditionLabel, completenessLabel, statusLabel } from '@/lib/labels';
import { centsToInput } from '@/lib/money';
import { listCollectionItems } from '@/lib/queries/collection';
import { getCurrentUser } from '@/lib/session';
export const dynamic = 'force-dynamic';
const CSV_COLUMNS = [
{ key: 'setNumber', label: 'Setnummer' },
{ key: 'setName', label: 'Name' },
{ key: 'manufacturer', label: 'Hersteller' },
{ key: 'theme', label: 'Thema' },
{ key: 'year', label: 'Jahr' },
{ key: 'pieceCount', label: 'Teile' },
{ key: 'status', label: 'Status' },
{ key: 'completeness', label: 'Vollständigkeit' },
{ key: 'condition', label: 'Zustand' },
{ key: 'location', label: 'Standort' },
{ key: 'acquiredAt', label: 'Im Besitz seit' },
{ key: 'acquiredPrice', label: 'Kaufpreis (EUR)' },
{ key: 'retailPrice', label: 'UVP (EUR)' },
{ key: 'acquiredFrom', label: 'Gekauft bei' },
{ key: 'giftedBy', label: 'Geschenkt von' },
{ key: 'notes', label: 'Notizen' },
];
/**
* Downloads.
*
* The full archive is for admins - it contains the whole collection. The CSV
* of the collection is available to every signed-in member.
*/
export async function GET(
_request: Request,
context: { params: Promise<{ kind: string }> },
) {
const user = await getCurrentUser();
if (!user) return new NextResponse('Nicht angemeldet', { status: 401 });
const { kind } = await context.params;
if (kind === 'csv') {
const rows = listCollectionItems({}).map((item) => ({
setNumber: item.setNumber,
setName: item.setName,
manufacturer: item.manufacturerName,
theme: item.themeName,
year: item.year,
pieceCount: item.pieceCount,
status: statusLabel(item.status),
completeness: completenessLabel(item.completeness),
condition: conditionLabel(item.condition),
location: item.locationName,
acquiredAt: item.acquiredAt ? formatDate(item.acquiredAt) : '',
acquiredPrice: centsToInput(item.acquiredPriceCents),
retailPrice: centsToInput(item.retailPriceCents),
acquiredFrom: item.acquiredFrom,
giftedBy: item.giftedBy,
notes: item.notes,
}));
return new NextResponse(buildCollectionCsv(rows, CSV_COLUMNS), {
headers: {
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition': `attachment; filename="${exportFileName('csv')}"`,
'Cache-Control': 'no-store',
},
});
}
if (user.role !== 'admin') {
return new NextResponse('Nur Administratoren dürfen die Daten exportieren', { status: 403 });
}
if (kind === 'json') {
return new NextResponse(JSON.stringify(buildExportPayload(), null, 2), {
headers: {
'Content-Type': 'application/json; charset=utf-8',
'Content-Disposition': `attachment; filename="${exportFileName('json')}"`,
'Cache-Control': 'no-store',
},
});
}
if (kind === 'zip') {
const archive = buildExportZip();
return new NextResponse(archive as unknown as BodyInit, {
headers: {
'Content-Type': 'application/zip',
'Content-Length': String(archive.byteLength),
'Content-Disposition': `attachment; filename="${exportFileName('zip')}"`,
'Cache-Control': 'no-store',
},
});
}
return new NextResponse('Unbekanntes Format', { status: 404 });
}
+5
View File
@@ -1,6 +1,7 @@
import { sql } from 'drizzle-orm';
import { NextResponse } from 'next/server';
import { db } from '@/db';
import { ensureReminderScheduler } from '@/lib/reminder-job';
export const dynamic = 'force-dynamic';
@@ -9,6 +10,10 @@ export const dynamic = 'force-dynamic';
* file is readable - a healthy Node process on a broken volume is not healthy.
*/
export function GET() {
// The container polls this every 30s, which makes it the earliest reliable
// moment to have the reminder scheduler running. Repeat calls are free.
ensureReminderScheduler();
try {
db.get(sql`select 1`);
return NextResponse.json({ status: 'ok', time: new Date().toISOString() });
+12
View File
@@ -1,4 +1,5 @@
import type { Metadata, Viewport } from 'next';
import { ServiceWorkerRegistration } from '@/components/layout/service-worker';
import { Toaster } from '@/components/ui/sonner';
import './globals.css';
@@ -9,6 +10,16 @@ export const metadata: Metadata = {
},
description: 'Sammlung und Wunschlisten für Spielzeug-Sets',
applicationName: 'Bricktrack',
manifest: '/manifest.webmanifest',
icons: {
icon: [{ url: '/favicon.png', type: 'image/png', sizes: '32x32' }],
apple: [{ url: '/icons/apple-touch-icon.png', sizes: '180x180' }],
},
appleWebApp: {
capable: true,
title: 'Bricktrack',
statusBarStyle: 'default',
},
// A private, self-hosted instance never belongs in a search index - and the
// public wishlist links are meant to be shared, not found.
robots: { index: false, follow: false },
@@ -30,6 +41,7 @@ export default function RootLayout({ children }: Readonly<{ children: React.Reac
<body className="min-h-dvh bg-background text-foreground antialiased">
{children}
<Toaster position="top-center" richColors closeButton />
<ServiceWorkerRegistration />
</body>
</html>
);
+28
View File
@@ -0,0 +1,28 @@
import type { Metadata } from 'next';
import { CloudOff } from 'lucide-react';
export const metadata: Metadata = {
title: 'Offline',
robots: { index: false, follow: false },
};
/**
* Shown when a page is requested that the device has never loaded before and
* there is no connection. Pages that were visited once come from the cache
* instead of landing here.
*/
export default function OfflinePage() {
return (
<div className="mx-auto flex min-h-dvh max-w-sm flex-col items-center justify-center gap-4 px-4 text-center">
<CloudOff className="size-10 text-muted-foreground" aria-hidden />
<h1 className="text-xl font-semibold tracking-tight">Gerade offline</h1>
<p className="text-sm text-muted-foreground">
Diese Seite war noch nie geladen und lässt sich ohne Verbindung nicht anzeigen. Seiten,
die du schon einmal geöffnet hast – zum Beispiel der Katalog – funktionieren weiter.
</p>
<p className="text-xs text-muted-foreground">
Sobald wieder Netz da ist, aktualisiert sich alles von selbst.
</p>
</div>
);
}
+1 -1
View File
@@ -27,7 +27,7 @@ export default async function PublicWishlistPage({ params }: Props) {
const list = getWishlistByShareToken(shareToken);
if (!list || !list.isPublic) notFound();
const rows = listWishlistItems(list.id);
const rows = listWishlistItems(list.id, list.collectorId);
// Fulfilled wishes are gone from the public view: the present is already
// under the tree, no reason to let someone buy it twice.
+83
View File
@@ -0,0 +1,83 @@
'use client';
import { useRouter } from 'next/navigation';
import { useState, useTransition } from 'react';
import { Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { registerWithInvite } from '@/app/(auth)/einladung/[token]/actions';
import { Button } from '@/components/ui/button';
import { Field } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
const MIN_PASSWORD_LENGTH = 10;
export function InviteForm({ token, email }: { token: string; email: string | null }) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [password, setPassword] = useState('');
function onSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const input = Object.fromEntries(new FormData(event.currentTarget).entries());
startTransition(async () => {
const result = await registerWithInvite({ ...input, token });
if (!result.ok) {
toast.error(result.error);
return;
}
toast.success('Konto angelegt. Willkommen bei Bricktrack!');
router.push('/');
router.refresh();
});
}
return (
<form onSubmit={onSubmit} className="space-y-4">
<Field label="Name" htmlFor="name" required>
<Input id="name" name="name" autoComplete="name" required maxLength={80} />
</Field>
<Field
label="E-Mail"
htmlFor="email"
required
hint={email ? 'Diese Einladung gilt für genau diese Adresse.' : undefined}
>
<Input
id="email"
name="email"
type="email"
inputMode="email"
autoComplete="username"
required
defaultValue={email ?? ''}
readOnly={Boolean(email)}
/>
</Field>
<Field
label="Passwort"
htmlFor="password"
required
hint={`Mindestens ${MIN_PASSWORD_LENGTH} Zeichen. Einen Passkey kannst du danach unter „Konto“ hinterlegen.`}
>
<Input
id="password"
name="password"
type="password"
autoComplete="new-password"
required
minLength={MIN_PASSWORD_LENGTH}
value={password}
onChange={(event) => setPassword(event.target.value)}
/>
</Field>
<Button type="submit" size="lg" className="w-full" disabled={pending}>
{pending ? <Loader2 className="animate-spin" /> : null}
Konto anlegen
</Button>
</form>
);
}
+199
View File
@@ -0,0 +1,199 @@
'use client';
import { useRouter } from 'next/navigation';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Loader2, ScanLine } from 'lucide-react';
import { toast } from 'sonner';
import { lookupScannedEan } from '@/app/(app)/katalog/scan-actions';
import { formatEan, isValidEan } from '@/lib/ean';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
type Controls = { stop: () => void };
/**
* Scans the barcode on a box with the phone camera.
*
* Decoding runs in the browser with zxing, which is bundled with the app and
* loaded only when the scanner is opened - nothing leaves the device, and the
* catalog pages stay light for everyone who never scans.
*/
export function BarcodeScannerButton() {
const router = useRouter();
const videoRef = useRef<HTMLVideoElement>(null);
const controlsRef = useRef<Controls | null>(null);
const handledRef = useRef(false);
const [open, setOpen] = useState(false);
const [status, setStatus] = useState<'starting' | 'scanning' | 'checking' | 'error'>('starting');
const [message, setMessage] = useState<string | null>(null);
const stop = useCallback(() => {
controlsRef.current?.stop();
controlsRef.current = null;
}, []);
const onDetected = useCallback(
(raw: string) => {
if (handledRef.current) return;
if (!isValidEan(raw)) return; // keep scanning on a misread
handledRef.current = true;
stop();
setStatus('checking');
void lookupScannedEan(raw).then((result) => {
if (!result.ok) {
toast.error(result.error);
setOpen(false);
return;
}
setOpen(false);
if (result.data.found) {
toast.success(`${result.data.setNumber} · ${result.data.name}`);
router.push(`/katalog/${result.data.setId}`);
} else {
toast.info(`${formatEan(result.data.ean)} ist noch nicht im Katalog.`);
router.push(`/katalog/neu?ean=${encodeURIComponent(result.data.ean)}`);
}
router.refresh();
});
},
[router, stop],
);
useEffect(() => {
if (!open) return;
handledRef.current = false;
setStatus('starting');
setMessage(null);
// The camera needs a secure context - same rule as for passkeys.
if (!window.isSecureContext) {
setStatus('error');
setMessage(
'Die Kamera funktioniert nur über HTTPS. Ruf Bricktrack über deine Domain auf, nicht über die IP-Adresse.',
);
return;
}
let cancelled = false;
void (async () => {
try {
const { BrowserMultiFormatReader } = await import('@zxing/browser');
const reader = new BrowserMultiFormatReader();
const video = videoRef.current;
if (!video || cancelled) return;
const controls = await reader.decodeFromConstraints(
// The rear camera is the one pointing at the box.
{ video: { facingMode: 'environment' } },
video,
(result) => {
if (result) onDetected(result.getText());
},
);
if (cancelled) {
controls.stop();
return;
}
controlsRef.current = controls;
setStatus('scanning');
} catch (error) {
if (cancelled) return;
console.error('[scan] Kamera konnte nicht gestartet werden', error);
setStatus('error');
const name = (error as { name?: string })?.name;
setMessage(
name === 'NotAllowedError'
? 'Der Zugriff auf die Kamera wurde abgelehnt. Erlaube ihn in den Browser-Einstellungen und versuch es noch einmal.'
: name === 'NotFoundError'
? 'Dieses Gerät hat keine nutzbare Kamera.'
: 'Die Kamera lässt sich nicht starten.',
);
}
})();
return () => {
cancelled = true;
stop();
};
}, [open, onDetected, stop]);
return (
<>
<Button
variant="outline"
size="icon-lg"
aria-label="Barcode scannen"
title="Barcode scannen"
onClick={() => setOpen(true)}
>
<ScanLine />
</Button>
<Dialog
open={open}
onOpenChange={(next) => {
if (!next) stop();
setOpen(next);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Barcode scannen</DialogTitle>
<DialogDescription>
Halte den Strichcode auf der Packung ins Bild. Kennt der Katalog das Set schon,
landest du direkt darauf – sonst im Formular mit ausgefüllter EAN.
</DialogDescription>
</DialogHeader>
{status === 'error' ? (
<p className="rounded-lg border border-dashed border-border p-4 text-sm text-muted-foreground">
{message}
</p>
) : (
<div className="relative overflow-hidden rounded-lg bg-black">
<video
ref={videoRef}
className="aspect-[3/4] w-full object-cover"
muted
playsInline
aria-label="Kamerabild"
/>
{/* A guide line makes people hold the box the right way round. */}
<div className="pointer-events-none absolute inset-x-6 top-1/2 h-0.5 -translate-y-1/2 bg-red-500/80" />
{status !== 'scanning' ? (
<div className="absolute inset-0 flex items-center justify-center gap-2 bg-black/60 text-sm text-white">
<Loader2 className="size-4 animate-spin" />
{status === 'checking' ? 'Im Katalog nachsehen …' : 'Kamera startet …'}
</div>
) : null}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
Abbrechen
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
+11 -1
View File
@@ -33,6 +33,8 @@ type Props = {
wishlists: WishlistTarget[];
/** Preselected target, used by the embedded view on a wishlist page. */
defaultWishlistId?: string;
/** A list created from a filtered view belongs to that collector. */
collectorId?: string;
emptyState?: React.ReactNode;
};
@@ -43,7 +45,13 @@ type Props = {
* On a desktop the "Auswählen" button does the same job. Sets that are already
* in the collection cannot be selected - they do not belong on a wishlist.
*/
export function CatalogGrid({ sets, wishlists, defaultWishlistId, emptyState }: Props) {
export function CatalogGrid({
sets,
wishlists,
defaultWishlistId,
collectorId,
emptyState,
}: Props) {
const router = useRouter();
const [selecting, setSelecting] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
@@ -102,6 +110,7 @@ export function CatalogGrid({ sets, wishlists, defaultWishlistId, emptyState }:
title: newTitle,
occasion: 'other',
isPublic: 'on',
collectorId: collectorId ?? '',
});
if (!created.ok) {
toast.error(created.error);
@@ -199,6 +208,7 @@ export function CatalogGrid({ sets, wishlists, defaultWishlistId, emptyState }:
<button
key={item.id}
type="button"
data-set-card
disabled={owned}
aria-pressed={isSelected}
onClick={() => toggle(item.id)}
+206
View File
@@ -0,0 +1,206 @@
'use client';
import { useRouter } from 'next/navigation';
import { useState, useTransition } from 'react';
import { Loader2, Plus, TrendingDown, TrendingUp, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { addPricePoint, deletePricePoint } from '@/app/(app)/katalog/price-actions';
import { formatDate, toDateInputValue } from '@/lib/format';
import { formatEuro } from '@/lib/money';
import { priceTrend, type PriceObservation } from '@/lib/prices';
import type { PricePointRow } from '@/lib/queries/catalog';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Field } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { NativeSelect } from '@/components/ui/native-select';
const CONDITION_LABELS: Record<string, string> = {
new: 'Neu',
used: 'Gebraucht',
};
/**
* Observed prices for one set.
*
* The point is not the single current price but the development: what a set
* did since it was bought. Everything here is maintained by hand - no price
* service is queried, and nothing leaves the server.
*/
export function PriceSection({
setId,
prices,
retailPriceCents,
}: {
setId: string;
prices: PricePointRow[];
retailPriceCents: number | null;
}) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [open, setOpen] = useState(false);
const observations: PriceObservation[] = prices.map((price) => ({
priceCents: price.priceCents,
condition: price.condition,
observedAt: price.observedAt,
}));
const trend = priceTrend(observations, retailPriceCents);
const gained = (trend.changeCents ?? 0) > 0;
function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const form = event.currentTarget;
const input = Object.fromEntries(new FormData(form).entries());
startTransition(async () => {
const result = await addPricePoint(setId, input);
if (!result.ok) {
toast.error(result.error);
return;
}
form.reset();
setOpen(false);
toast.success('Preis notiert.');
router.refresh();
});
}
function remove(id: string) {
startTransition(async () => {
const result = await deletePricePoint(id);
if (!result.ok) {
toast.error(result.error);
return;
}
toast.success('Eintrag gelöscht.');
router.refresh();
});
}
return (
<div className="space-y-4">
<dl className="grid grid-cols-3 gap-3 text-sm">
<div>
<dt className="text-xs text-muted-foreground">Ursprünglich</dt>
<dd className="font-medium">{formatEuro(trend.baseCents)}</dd>
</div>
<div>
<dt className="text-xs text-muted-foreground">Aktuell</dt>
<dd className="font-medium">{formatEuro(trend.currentCents)}</dd>
</div>
<div>
<dt className="text-xs text-muted-foreground">Entwicklung</dt>
<dd>
{trend.changePercent === null ? (
<span className="text-muted-foreground">–</span>
) : (
<span
className={`flex items-center gap-1 font-medium ${
gained ? 'text-emerald-600' : 'text-muted-foreground'
}`}
>
{gained ? (
<TrendingUp className="size-4" aria-hidden />
) : (
<TrendingDown className="size-4" aria-hidden />
)}
{trend.changePercent > 0 ? '+' : ''}
{trend.changePercent.toLocaleString('de-DE')} %
</span>
)}
</dd>
</div>
</dl>
{prices.length > 0 ? (
<ul className="divide-y divide-border rounded-lg border border-border">
{prices.map((price) => (
<li key={price.id} className="flex items-center gap-3 p-3">
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">
{formatEuro(price.priceCents)}{' '}
<Badge variant="secondary">{CONDITION_LABELS[price.condition]}</Badge>
</p>
<p className="truncate text-xs text-muted-foreground">
{[formatDate(price.observedAt), price.source, price.note]
.filter(Boolean)
.join(' · ')}
</p>
</div>
<Button
variant="ghost"
size="icon-sm"
aria-label="Preis löschen"
onClick={() => remove(price.id)}
disabled={pending}
>
<Trash2 className="text-destructive" />
</Button>
</li>
))}
</ul>
) : (
<p className="text-sm text-muted-foreground">
Noch kein Preis notiert. Der Ausgangswert ist die UVP des Sets.
</p>
)}
{open ? (
<form onSubmit={submit} className="space-y-3 rounded-lg border border-border p-3">
<p className="text-sm font-medium">Preis notieren</p>
<div className="grid gap-3 sm:grid-cols-3">
<Field label="Preis" htmlFor="priceCents" required hint="In Euro.">
<Input
id="priceCents"
name="priceCents"
inputMode="decimal"
required
placeholder="79,99"
/>
</Field>
<Field label="Zustand" htmlFor="condition">
<NativeSelect id="condition" name="condition" defaultValue="new">
<option value="new">Neu</option>
<option value="used">Gebraucht</option>
</NativeSelect>
</Field>
<Field label="Gesehen am" htmlFor="observedAt" hint="Leer = heute.">
<Input
id="observedAt"
name="observedAt"
type="date"
defaultValue={toDateInputValue(new Date())}
/>
</Field>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<Field label="Wo" htmlFor="source">
<Input id="source" name="source" maxLength={80} placeholder="Amazon, Flohmarkt …" />
</Field>
<Field label="Notiz" htmlFor="price-note">
<Input id="price-note" name="note" maxLength={200} />
</Field>
</div>
<div className="flex gap-2">
<Button type="submit" disabled={pending}>
{pending ? <Loader2 className="animate-spin" /> : <Plus />}
Speichern
</Button>
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
Abbrechen
</Button>
</div>
</form>
) : (
<Button variant="outline" onClick={() => setOpen(true)}>
<Plus />
Preis notieren
</Button>
)}
</div>
);
}
@@ -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>
);
}
+52 -4
View File
@@ -38,32 +38,47 @@ export function SetActions({
setName,
owned,
wishlists,
collectors,
}: {
setId: string;
setName: string;
owned: boolean;
wishlists: Array<{ id: string; title: string }>;
/** More than one turns "Zur Sammlung" into a question of "for whom". */
collectors: Array<{ id: string; name: string }>;
}) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [wishlistOpen, setWishlistOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [targetId, setTargetId] = useState(wishlists[0]?.id ?? NEW_LIST);
const [collectorOpen, setCollectorOpen] = useState(false);
const [collectorId, setCollectorId] = useState(collectors[0]?.id ?? '');
const [newTitle, setNewTitle] = useState('');
const [priority, setPriority] = useState('2');
function addToCollection() {
function addToCollection(forCollectorId?: string) {
startTransition(async () => {
const result = await addSetToCollection(setId);
const result = await addSetToCollection(setId, forCollectorId);
if (!result.ok) {
toast.error(result.error);
return;
}
toast.success('Zur Sammlung hinzugefügt.');
const owner = collectors.find((entry) => entry.id === forCollectorId);
toast.success(
owner ? `Zur Sammlung von ${owner.name} hinzugefügt.` : 'Zur Sammlung hinzugefügt.',
);
setCollectorOpen(false);
router.refresh();
});
}
// With a single collector there is nothing to ask.
function onAddClick() {
if (collectors.length > 1) setCollectorOpen(true);
else addToCollection();
}
function addToWishlist() {
startTransition(async () => {
let wishlistId = targetId;
@@ -111,7 +126,7 @@ export function SetActions({
return (
<div className="flex flex-wrap gap-2">
<Button size="lg" onClick={addToCollection} disabled={pending}>
<Button size="lg" onClick={onAddClick} disabled={pending}>
{pending ? <Loader2 className="animate-spin" /> : <Plus />}
Zur Sammlung hinzufügen
</Button>
@@ -138,6 +153,39 @@ export function SetActions({
</p>
) : null}
<Dialog open={collectorOpen} onOpenChange={setCollectorOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Wer bekommt das Set?</DialogTitle>
<DialogDescription>{setName}</DialogDescription>
</DialogHeader>
<Field label="Sammler" htmlFor="add-collector" required>
<NativeSelect
id="add-collector"
value={collectorId}
onChange={(event) => setCollectorId(event.target.value)}
>
{collectors.map((entry) => (
<option key={entry.id} value={entry.id}>
{entry.name}
</option>
))}
</NativeSelect>
</Field>
<DialogFooter>
<Button variant="outline" onClick={() => setCollectorOpen(false)} disabled={pending}>
Abbrechen
</Button>
<Button onClick={() => addToCollection(collectorId)} disabled={pending}>
{pending ? <Loader2 className="animate-spin" /> : <Plus />}
Hinzufügen
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={wishlistOpen} onOpenChange={setWishlistOpen}>
<DialogContent>
<DialogHeader>
+127 -21
View File
@@ -1,13 +1,28 @@
'use client';
import { useRouter } from 'next/navigation';
import { useState, useTransition } from 'react';
import { useRef, useState, useTransition } from 'react';
import Link from 'next/link';
import { Loader2, Save } from 'lucide-react';
import { toast } from 'sonner';
import { createSet, updateSet } from '@/app/(app)/katalog/actions';
import {
findDuplicateCandidates,
type DuplicateCandidate,
} from '@/app/(app)/katalog/duplicate-actions';
import { centsToInput } from '@/lib/money';
import { setImageSrc } from '@/lib/images';
import { Button } from '@/components/ui/button';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Checkbox } from '@/components/ui/checkbox';
import { Field } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
@@ -46,6 +61,8 @@ export function SetForm({ manufacturers, themes, set, defaultEan }: Props) {
const [manufacturerId, setManufacturerId] = useState(
set?.manufacturerId ?? manufacturers[0]?.id ?? '',
);
const [duplicates, setDuplicates] = useState<DuplicateCandidate[] | null>(null);
const pendingForm = useRef<FormData | null>(null);
const existingImage = set ? setImageSrc(set) : null;
const availableThemes = themes.filter((theme) => theme.manufacturerId === manufacturerId);
@@ -54,32 +71,70 @@ export function SetForm({ manufacturers, themes, set, defaultEan }: Props) {
event.preventDefault();
const formData = new FormData(event.currentTarget);
startTransition(async () => {
if (set) {
const result = await updateSet(set.id, formData);
if (!result.ok) {
toast.error(result.error);
// Ask before creating something that already exists. Editing skips this:
// there the entry is the one we would be warning about.
if (!set) {
startTransition(async () => {
const check = await findDuplicateCandidates({
manufacturerId: String(formData.get('manufacturerId') ?? ''),
setNumber: String(formData.get('setNumber') ?? ''),
name: String(formData.get('name') ?? ''),
});
if (check.ok && check.data.candidates.length > 0) {
pendingForm.current = formData;
setDuplicates(check.data.candidates);
return;
}
toast.success('Set gespeichert.');
router.push(`/katalog/${set.id}`);
router.refresh();
return;
}
const result = await createSet(formData);
if (!result.ok) {
toast.error(result.error);
return;
}
toast.success('Set angelegt.');
router.push(`/katalog/${result.data.id}`);
router.refresh();
await save(formData);
});
return;
}
startTransition(() => save(formData));
}
async function save(formData: FormData) {
return new Promise<void>((resolve) => {
startTransition(async () => {
if (set) {
const result = await updateSet(set.id, formData);
if (!result.ok) {
toast.error(result.error);
resolve();
return;
}
toast.success('Set gespeichert.');
router.push(`/katalog/${set.id}`);
router.refresh();
resolve();
return;
}
const result = await createSet(formData);
if (!result.ok) {
toast.error(result.error);
resolve();
return;
}
toast.success('Set angelegt.');
router.push(`/katalog/${result.data.id}`);
router.refresh();
resolve();
});
});
}
const REASON_LABELS: Record<DuplicateCandidate['reason'], string> = {
'same-number': 'Gleiche Setnummer beim gleichen Hersteller',
'similar-name': 'Sehr ähnlicher Name',
'number-elsewhere': 'Gleiche Setnummer bei einem anderen Hersteller',
};
return (
<form onSubmit={onSubmit} className="space-y-6">
<>
<form onSubmit={onSubmit} className="space-y-6">
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Hersteller" htmlFor="manufacturerId" required>
<NativeSelect
@@ -243,6 +298,57 @@ export function SetForm({ manufacturers, themes, set, defaultEan }: Props) {
Abbrechen
</Button>
</div>
</form>
</form>
<AlertDialog
open={duplicates !== null}
onOpenChange={(open) => {
if (!open) {
setDuplicates(null);
pendingForm.current = null;
}
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Gibt es das vielleicht schon?</AlertDialogTitle>
<AlertDialogDescription>
Diese Einträge sehen ähnlich aus. Schau kurz nach, bevor der Katalog zwei Mal
dasselbe Set enthält.
</AlertDialogDescription>
</AlertDialogHeader>
<ul className="divide-y divide-border rounded-lg border border-border">
{(duplicates ?? []).map((candidate) => (
<li key={candidate.id} className="p-3">
<Link
href={`/katalog/${candidate.id}`}
className="text-sm font-medium hover:underline"
>
{candidate.setNumber} · {candidate.name}
</Link>
<p className="text-xs text-muted-foreground">
{candidate.manufacturerName} — {REASON_LABELS[candidate.reason]}
</p>
</li>
))}
</ul>
<AlertDialogFooter>
<AlertDialogCancel>Zurück zum Formular</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
const formData = pendingForm.current;
setDuplicates(null);
pendingForm.current = null;
if (formData) void save(formData);
}}
>
Trotzdem anlegen
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
@@ -0,0 +1,78 @@
'use client';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { useTransition } from 'react';
import { cn } from 'cn';
import { collectorDotClass } from '@/lib/collector-colors';
export type CollectorOption = { id: string; name: string; color: string };
/**
* Switches the view between collectors.
*
* Rendered only when there is more than one - with a single collector the
* whole question does not exist, and the app should not ask it. The choice
* lives in the URL (`?sammler=`), so a filtered view stays shareable and the
* back button behaves.
*/
export function CollectorFilter({
collectors,
allLabel = 'Alle',
}: {
collectors: CollectorOption[];
allLabel?: string;
}) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [, startTransition] = useTransition();
if (collectors.length < 2) return null;
const current = searchParams.get('sammler') ?? '';
function select(id: string) {
const params = new URLSearchParams(searchParams.toString());
if (id) params.set('sammler', id);
else params.delete('sammler');
params.delete('seite');
const query = params.toString();
startTransition(() => router.push(query ? `${pathname}?${query}` : pathname));
}
const options = [{ id: '', name: allLabel, color: 'slate' }, ...collectors];
return (
<nav
aria-label="Sammler"
className="flex gap-1 overflow-x-auto rounded-lg bg-muted p-1"
>
{options.map((option) => {
const active = current === option.id;
return (
<button
key={option.id || 'alle'}
type="button"
aria-pressed={active}
onClick={() => select(option.id)}
className={cn(
'flex flex-1 items-center justify-center gap-2 rounded-md px-3 py-2 text-sm font-medium whitespace-nowrap transition-colors',
active
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground',
)}
>
{option.id ? (
<span
className={cn('size-2 rounded-full', collectorDotClass(option.color))}
aria-hidden
/>
) : null}
{option.name}
</button>
);
})}
</nav>
);
}
+38
View File
@@ -0,0 +1,38 @@
'use client';
import { useEffect } from 'react';
/**
* Registers the service worker.
*
* Only in production: in development the worker would serve stale chunks and
* turn every edit into a puzzle.
*/
export function ServiceWorkerRegistration() {
useEffect(() => {
if (process.env.NODE_ENV !== 'production') return;
if (!('serviceWorker' in navigator)) return;
const register = () => {
void navigator.serviceWorker.register('/sw.js').catch((error) => {
console.error('[pwa] Service Worker konnte nicht registriert werden', error);
});
};
// Waiting for load keeps the worker from competing with the first render.
if (document.readyState === 'complete') register();
else window.addEventListener('load', register, { once: true });
return () => window.removeEventListener('load', register);
}, []);
return null;
}
/** Drops every cached page - called on sign-out. */
export async function clearOfflineCaches(): Promise<void> {
if (!('serviceWorker' in navigator)) return;
const registration = await navigator.serviceWorker.getRegistration();
registration?.active?.postMessage('bricktrack:clear-cache');
}
+3
View File
@@ -3,6 +3,7 @@
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { LogOut, Settings, UserRound } from 'lucide-react';
import { clearOfflineCaches } from '@/components/layout/service-worker';
import { authClient } from '@/lib/auth-client';
import { Button } from '@/components/ui/button';
import {
@@ -20,6 +21,8 @@ export function UserMenu({ user }: { user: SessionUser }) {
async function signOut() {
await authClient.signOut();
// Cached pages must not stay readable on a shared device.
await clearOfflineCaches();
router.push('/anmelden');
router.refresh();
}
@@ -2,10 +2,13 @@
import { useRouter } from 'next/navigation';
import { useState, useTransition } from 'react';
import { Loader2, Pencil, Trash2 } from 'lucide-react';
import { Loader2, Pencil, PuzzleIcon, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { deleteCollectionItem, updateCollectionItem } from '@/app/(app)/sammlung/actions';
import type { MissingPartRow } from '@/lib/queries/collection';
import { MissingPartsDialog } from './missing-parts-dialog';
import type { CollectionStatus, Completeness, Condition } from '@/db/types';
import { collectorBadgeClass } from '@/lib/collector-colors';
import { formatDate, toDateInputValue } from '@/lib/format';
import {
COMPLETENESS_LABELS,
@@ -54,19 +57,31 @@ export type CollectionItemRow = {
giftedBy: string | null;
lentOutTo: string | null;
notes: string | null;
setName: string;
missingParts: MissingPartRow[];
collectorId: string | null;
collectorName: string | null;
collectorColor: string | null;
};
export function CollectionItemsSection({
items,
locations,
collectors,
}: {
items: CollectionItemRow[];
locations: Array<{ id: string; name: string }>;
collectors: Array<{ id: string; name: string }>;
}) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [editing, setEditing] = 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>) {
event.preventDefault();
@@ -117,10 +132,20 @@ export function CollectionItemsSection({
<li key={item.id} className="flex items-start gap-3 p-3">
<div className="min-w-0 flex-1 space-y-1">
<div className="flex flex-wrap items-center gap-2">
{collectors.length > 1 && item.collectorName ? (
<Badge className={collectorBadgeClass(item.collectorColor)}>
{item.collectorName}
</Badge>
) : null}
<Badge variant={item.status === 'sold' ? 'outline' : 'default'}>
{statusLabel(item.status)}
</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 ? (
<span className="text-xs text-muted-foreground">{item.locationName}</span>
) : null}
@@ -140,6 +165,16 @@ export function CollectionItemsSection({
</p>
{item.notes ? <p className="text-xs">{item.notes}</p> : null}
</div>
<Button
variant="ghost"
size="icon-sm"
aria-label="Fehlteile"
title="Fehlteile"
onClick={() => setPartsItemId(item.id)}
disabled={pending}
>
<PuzzleIcon />
</Button>
<Button
variant="ghost"
size="icon-sm"
@@ -170,6 +205,22 @@ export function CollectionItemsSection({
<form id="collection-item-form" onSubmit={submit} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
{collectors.length > 1 ? (
<Field label="Sammler" htmlFor="collectorId" className="sm:col-span-2">
<NativeSelect
id="collectorId"
name="collectorId"
defaultValue={editing?.collectorId ?? collectors[0]?.id ?? 'none'}
>
{collectors.map((entry) => (
<option key={entry.id} value={entry.id}>
{entry.name}
</option>
))}
</NativeSelect>
</Field>
) : null}
<Field label="Status" htmlFor="status">
<NativeSelect id="status" name="status" defaultValue={editing?.status ?? 'owned'}>
{Object.entries(STATUS_LABELS).map(([value, label]) => (
@@ -283,6 +334,16 @@ export function CollectionItemsSection({
</DialogContent>
</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)}>
<AlertDialogContent>
<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,87 @@
'use client';
import {
createCollector,
deleteCollector,
updateCollector,
} from '@/app/(app)/verwaltung/sammler/actions';
import { COLLECTOR_COLOR_LABELS } from '@/lib/collector-colors';
import { toDateInputValue } from '@/lib/format';
import { CrudSection, type CrudItem } from './crud-section';
import { Field } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { NativeSelect } from '@/components/ui/native-select';
import { Textarea } from '@/components/ui/textarea';
export type CollectorRowItem = CrudItem & {
color: string;
birthday: Date | null;
notes: string | null;
itemCount: number;
};
export function CollectorList({ collectors }: { collectors: CollectorRowItem[] }) {
return (
<CrudSection<CollectorRowItem>
items={collectors}
emptyText="Noch kein Sammler angelegt."
createLabel="Sammler anlegen"
dialogTitle={(item) => (item ? 'Sammler bearbeiten' : 'Neuer Sammler')}
dialogDescription="Der Katalog bleibt gemeinsam – Sammlung und Wunschlisten gehören je einem Sammler."
onCreate={createCollector}
onUpdate={updateCollector}
onDelete={deleteCollector}
deleteWarning={(item) =>
item.itemCount > 0
? `„${item.title}“ hat ${item.itemCount} Sets in der Sammlung. Ordne sie erst jemand anderem zu – erst dann lässt sich der Sammler löschen.`
: `„${item.title}“ wird gelöscht. Der Katalog bleibt unverändert.`
}
renderFields={(item) => (
<>
<Field label="Name" htmlFor="name" required>
<Input
id="name"
name="name"
defaultValue={item?.title ?? ''}
required
maxLength={60}
placeholder="Max"
/>
</Field>
<Field
label="Farbe"
htmlFor="color"
hint="Nur zur Unterscheidung auf einen Blick."
>
<NativeSelect id="color" name="color" defaultValue={item?.color ?? 'blue'}>
{Object.entries(COLLECTOR_COLOR_LABELS).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</NativeSelect>
</Field>
<Field label="Geburtstag" htmlFor="birthday" hint="Optional.">
<Input
id="birthday"
name="birthday"
type="date"
defaultValue={toDateInputValue(item?.birthday)}
/>
</Field>
<Field label="Notizen" htmlFor="collector-notes">
<Textarea
id="collector-notes"
name="notes"
defaultValue={item?.notes ?? ''}
rows={3}
/>
</Field>
</>
)}
/>
);
}
+138
View File
@@ -0,0 +1,138 @@
'use client';
import { useRouter } from 'next/navigation';
import { useState, useTransition } from 'react';
import { AlertTriangle, Download, FileSpreadsheet, FileText, Loader2, Upload } from 'lucide-react';
import { toast } from 'sonner';
import { importArchiveAction } from '@/app/(app)/verwaltung/daten/actions';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Field } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
const CONFIRMATION = 'ERSETZEN';
export function DataTransfer() {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [confirmation, setConfirmation] = useState('');
function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const form = event.currentTarget;
startTransition(async () => {
const result = await importArchiveAction(new FormData(form));
if (!result.ok) {
toast.error(result.error);
return;
}
toast.success(
`Eingelesen: ${result.data.sets} Sets, ${result.data.collectionItems} Sammlungseinträge, ${result.data.wishlists} Wunschlisten, ${result.data.files} Dateien.`,
);
form.reset();
setConfirmation('');
router.refresh();
});
}
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Exportieren</CardTitle>
<CardDescription>
Zum Umziehen, zum Nachsehen außerhalb der App oder als zweites Netz. Das eigentliche
Backup bleibt das Verzeichnis <code className="font-mono text-xs">./data</code>.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-wrap gap-2">
<Button asChild size="lg">
<a href="/api/export/zip" download>
<Download />
Alles als ZIP
</a>
</Button>
<Button asChild variant="outline" size="lg">
<a href="/api/export/json" download>
<FileText />
Nur Daten (JSON)
</a>
</Button>
<Button asChild variant="outline" size="lg">
<a href="/api/export/csv" download>
<FileSpreadsheet />
Sammlung als CSV
</a>
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Was im Export steckt</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm text-muted-foreground">
<p>
<span className="font-medium text-foreground">Dabei:</span> Hersteller, Themen, Sets,
Anleitungen, Standorte, Sammlungseinträge, Fehlteile, Fotos und Wunschlisten – im ZIP
zusätzlich alle hochgeladenen Dateien.
</p>
<p>
<span className="font-medium text-foreground">Nicht dabei:</span> Konten, Passwörter
und Passkeys. Ein Export voller Passwort-Hashes im Download-Ordner wäre ein Risiko,
kein Backup. Ebenfalls draußen: Reservierungen und Teilen-Links – die gehören zu der
Instanz, auf der sie entstanden sind.
</p>
</CardContent>
</Card>
<Card className="border-destructive/40">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<AlertTriangle className="size-5 text-destructive" aria-hidden />
Importieren
</CardTitle>
<CardDescription>
Ein Import <strong>ersetzt</strong> alle vorhandenen Daten – Katalog, Sammlung und
Wunschlisten. Zusammenführen ist nicht möglich. Wunschlisten gehören danach dir und
sind zunächst nicht geteilt.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={submit} className="space-y-4">
<Field label="Archiv" htmlFor="archive" required>
<Input id="archive" name="archive" type="file" accept=".zip,application/zip" required />
</Field>
<Field
label={`Zum Bestätigen ${CONFIRMATION} eintippen`}
htmlFor="confirmation"
required
>
<Input
id="confirmation"
name="confirmation"
value={confirmation}
onChange={(event) => setConfirmation(event.target.value)}
placeholder={CONFIRMATION}
autoComplete="off"
/>
</Field>
<Button
type="submit"
size="lg"
variant="destructive"
disabled={pending || confirmation !== CONFIRMATION}
>
{pending ? <Loader2 className="animate-spin" /> : <Upload />}
Daten ersetzen
</Button>
</form>
</CardContent>
</Card>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More