23 Commits
Author SHA1 Message Date
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
unknownandClaude Opus 5 546040c837 test: screenshot-lauf vom smoke-test trennen
Beide Specs liegen unter e2e/, deshalb hat der Smoke-Lauf auch die
Screenshot-Spec mitgenommen - beide registrieren einen ersten Nutzer in
derselben Datenbank und sind sich dabei in die Quere gekommen. Der
Smoke-Lauf nimmt jetzt ausdruecklich nur smoke.spec.ts.

Dazu 30 statt 15 Sekunden fuer die Erwartungen: die erste Navigation auf
eine noch nicht uebersetzte Route dauert im Dev-Server laenger.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 01:06:31 +02:00
unknownandClaude Opus 5 f42cd31673 feat(docker): host-adresse und port konfigurierbar machen
BIND_ADDRESS und HOST_PORT statt eines fest verdrahteten
127.0.0.1:3000. Die Voreinstellung bleibt localhost - wer den Reverse
Proxy auf einem anderen Host betreibt oder Port 3000 schon belegt hat,
aendert jetzt zwei Zeilen in der .env statt der compose-Datei.

Der komplette Stack wurde damit einmal durchgespielt: docker compose up
laeuft, Container wird healthy, Migrationen greifen und /data landet mit
Datenbank und Dateiverzeichnis auf dem Host.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 00:56:38 +02:00
unknownandClaude Opus 5 b1fc502d55 docs: readme, schnellstart, bebilderte anleitung und changelog 0.1.0
QUICKSTART fuehrt von null zur laufenden Instanz und enthaelt die
vollstaendige Nginx-Proxy-Manager-Konfiguration - inklusive der beiden
Stellen, an denen es sonst klemmt: client_max_body_size fuer grosse
Anleitungs-PDFs und X-Forwarded-For fuer das Rate-Limit.

Die Anleitung ist bebildert. Die 16 Screenshots entstehen automatisiert
mit `pnpm screenshots` gegen das Produktions-Image im Container, nicht
gegen den Dev-Server: der brach unter Last reproduzierbar mit
ChunkLoadError ab, und die Bilder zeigen so ohnehin genau das, was nach
`docker compose up` zu sehen ist.

Nebenbei behoben: in der Mehrfachauswahl verdeckte die Aktionsleiste die
letzte Kachelreihe.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 00:48:32 +02:00
unknownandClaude Opus 5 61b1249afd build: dockerfile, compose-stack, entrypoint und update-skript
Multi-Stage-Build mit standalone-Output, non-root Nutzer node (UID 1000)
und Healthcheck auf /api/health. Der Entrypoint prueft zuerst, ob /data
beschreibbar ist, und sagt bei einem Rechteproblem genau, was zu tun
ist; danach laufen die Migrationen und erst dann startet die App.

Der Migrations-Runner wird beim Build mit esbuild zu einer einzelnen
CJS-Datei gebuendelt. Damit braucht das Runtime-Image weder drizzle-kit
noch tsx - nur das native better-sqlite3, das ohnehin dabei ist.

update.sh sichert data und .env, holt den neuen Stand, baut, startet und
wartet auf den Healthcheck. Wird der Container nicht gesund, rollt es
Code und Container auf den vorherigen Commit zurueck und nennt den Pfad
der Sicherung.

.gitattributes erzwingt LF: eine entrypoint.sh mit CRLF scheitert im
Container mit "no such file or directory" auf der Shebang-Zeile.

Verifiziert: Image gebaut (358 MB), Container healthy, Migration laeuft,
Registrierung des ersten Nutzers funktioniert, sharp arbeitet unter musl.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 00:09:03 +02:00
unknownandClaude Opus 5 b0152b8667 test: unit-tests der kernlogik und playwright-smoke-test
50 Unit-Tests mit Vitest fuer die Stellen, an denen ein Fehler teuer
waere: Besitzstatus, Mehrfachauswahl inklusive Ueberspringen von
Dubletten und bereits besessenen Sets, Token-Erzeugung und -Vergleich,
Cent-Umrechnung und Slugs.

Der Playwright-Smoke-Test laeuft den ganzen Weg: Registrierung des
ersten Nutzers, Hersteller anlegen, Set anlegen, auf eine neue
Wunschliste setzen, Link holen und als Besucher ohne Konto reservieren.
Er nutzt ein eigenes Datenverzeichnis (./data-e2e), das vor jedem Lauf
geleert wird.

Dabei aufgefallen und behoben: eine spontan aus dem Katalog angelegte
Wunschliste war nicht teilbar, weil das Feld isPublic ohne Formular
auf false fiel. Ausserdem ist das Sternchen an Pflichtfeldern jetzt
aria-hidden, damit der zugaengliche Name des Feldes sauber bleibt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 23:31:21 +02:00
unknownandClaude Opus 5 79b0133083 feat(wunschliste): mehrfachauswahl, teilen und oeffentliche ansicht
Der zentrale Weg auf eine Wunschliste ist die Ansicht "Fehlende Sets":
Kachel lange druecken startet den Auswahlmodus, die Aktionsleiste am
unteren Rand uebernimmt die Auswahl in eine bestehende oder neue Liste.
Dubletten und bereits besessene Sets werden uebersprungen und im Toast
benannt; der Unique-Index auf (wishlist_id, set_id) macht das zur
Datenbankgarantie. Dieselbe Auswahlansicht steckt eingebettet auf der
Wunschlisten-Seite, vorgefiltert auf Sets, die noch nicht drauf sind.

Die oeffentliche Ansicht unter /w/[shareToken] braucht keine Anmeldung,
gibt nichts ausser den Wunschlisten-Daten preis und ist noindex.
Besucher reservieren mit ihrem Namen und bekommen einen Storno-Link;
in der Datenbank liegt nur der SHA-256-Hash des Tokens. Die Reservierung
ist ein bedingtes UPDATE, damit bei zwei gleichzeitigen Klicks nur einer
gewinnt. Erfuellte Wuensche verschwinden aus der oeffentlichen Ansicht.

Dazu die Sammlungsuebersicht mit Statistik-Kacheln und Filtern nach
Status, Vollstaendigkeit, Standort und Hersteller.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 23:06:19 +02:00
unknownandClaude Opus 5 856800b4e4 feat(katalog): set-katalog mit filtern, detailseite und uploads
Kachelliste mit Suche ueber Setnummer, Name und EAN sowie Filtern nach
Hersteller, Thema, Jahr und Besitzstatus. Besitz kommt aus genau einer
Stelle: src/lib/ownership.ts definiert "hat er nicht" als Katalogeintrag
ohne collection_item mit Status ungleich sold, als korrelierte
Subquery, damit mehrere Exemplare die Liste nicht verdoppeln.

Detailseite mit Metadaten, Anleitungen (PDF-Upload plus pdf.js-Viewer
ohne CDN, oder externer Link), Fotos, Sammlungseintraegen und den
Aktionen "Zur Sammlung" und "Auf Wunschliste".

Upload-Pipeline prueft Magic Bytes statt des gemeldeten MIME-Typs,
erzeugt Dateinamen serverseitig, wandelt Bilder in WebP inklusive
Thumbnail und liefert Dateien ueber /api/files aus - Set-Bilder
oeffentlich, Fotos und PDFs nur mit Session.

Dazu das Seed-Skript mit 3 Herstellern, 6 Themen, 10 Sets und
4 Sammlungseintraegen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 22:59:26 +02:00
unknownandClaude Opus 5 af0ae039cb feat(stammdaten): crud fuer hersteller, themen und standorte
Gemeinsame CrudSection fuer die drei kleinen Stammdatentabellen; sie
unterscheiden sich nur in den Formularfeldern. Loeschen ist bewusst
blockiert, solange Sets am Hersteller oder Thema haengen - Standorte
duerfen weg, die Sammlungseintraege verlieren dann nur den Ort.

Dazu die gemeinsamen Bausteine: zod-Schemata fuer alle Formulare,
einheitliches ActionResult, Slug-Erzeugung mit deutschen Umlauten,
Geld als Cent und Datumsformatierung dd.MM.yyyy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 22:46:45 +02:00
unknownandClaude Opus 5 4c78c5672b feat(auth): better-auth mit passwort, passkeys und app-shell
Registrierung des ersten Nutzers macht ihn zum Administrator, danach
ist die Registrierung geschlossen (Einladungen folgen in Phase 2).
Login per Passwort oder Passkey inklusive Conditional UI, Verwaltung
der Passkeys unter "Konto" mit Umbenennen und Loeschen.

Rate-Limiting liegt in der Datenbank und ueberlebt Neustarts;
Session-Cookies sind httpOnly, sameSite=lax und im Produktivbetrieb
secure. Die Client-IP wird hinter dem Reverse Proxy aus
x-forwarded-for gelesen.

Dazu die mobile-first Grundstruktur: Bottom-Navigation, Header mit
Konto-Menue, Healthcheck unter /api/health und ein Dashboard mit den
vier Einstiegspunkten. Bedienelemente sind auf 44px Touch-Groesse
angehoben.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 22:40:54 +02:00
212 changed files with 24503 additions and 1002 deletions
+20
View File
@@ -0,0 +1,20 @@
node_modules
.next
.git
.github
data
data-e2e
test-results
playwright-report
blob-report
coverage
.env
.env.*.local
*.log
docs
README.md
QUICKSTART.md
CHANGELOG.md
CLAUDE.md
.vscode
.idea
+23 -2
View File
@@ -21,6 +21,27 @@ MAX_UPLOAD_MB=25
# Zeitzone des Containers.
TZ=Europe/Berlin
# Optional (ab Phase 2): Rebrickable-API-Key fuer den Lego-Import.
# Ohne Key funktioniert alles manuell weiter.
# Wo der Container auf dem Host lauscht. Voreinstellung: nur localhost, weil
# der Reverse Proxy auf demselben Host laeuft. Sitzt der Proxy woanders:
# BIND_ADDRESS=0.0.0.0 (und dann unbedingt die Firewall zuziehen).
# BIND_ADDRESS=127.0.0.1
# HOST_PORT=3000
# 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
+17
View File
@@ -0,0 +1,17 @@
# Normalise line endings so files written on Windows still work in the
# Linux container. A shell script with CRLF fails with the confusing
# "no such file or directory" on its shebang line.
* text=auto eol=lf
*.sh text eol=lf
Dockerfile text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
# Binary files must never be touched.
*.png binary
*.jpg binary
*.jpeg binary
*.webp binary
*.pdf binary
*.ico binary
+6
View File
@@ -19,6 +19,12 @@ next-env.d.ts
# runtime data (database, uploads) - never in git, this is the backup target
/data
/data-e2e
/data-shots
/data-docker
/backups
/data-test
/data-test-export
# production
*.tsbuildinfo
+188 -8
View File
@@ -7,14 +7,194 @@ 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
- Projekt-Scaffold: Next.js 15 (App Router, TypeScript strict),
Tailwind CSS 4, shadcn/ui, pnpm.
- Vollstaendiges Datenmodell in Drizzle ORM mit initialer Migration:
Katalog, Besitz, Wunschlisten und die Auth-Tabellen von better-auth.
- SQLite-Anbindung mit WAL-Modus, aktivierten Fremdschluesseln und
Migrations-Runner (`pnpm db:migrate`).
- Projektdokumentation: CLAUDE.md, Roadmap, Changelog, Devlog.
**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.
[Unreleased]: https://git.wilde-server.de/florian.wilde/bricktrack/compare/main...HEAD
**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.
### Hinzugefuegt
**Anmeldung**
- Registrierung des ersten Nutzers, der damit Administrator wird; danach ist
die Registrierung geschlossen.
- Anmeldung per E-Mail und Passwort oder per Passkey (WebAuthn), inklusive
Conditional UI, wo der Browser sie unterstuetzt.
- Passkey-Verwaltung unter "Konto": beliebig viele Geraete, umbenennen,
loeschen.
- Rate-Limiting auf den Login-Endpunkten, in der Datenbank abgelegt und damit
neustartfest.
**Katalog**
- Set-Katalog mit Kachelansicht, Suche ueber Setnummer, Name und EAN sowie
Filtern nach Hersteller, Thema, Jahr und Besitzstatus.
- Sets anlegen und bearbeiten, mit Bild-Upload oder Bild-URL.
- Set-Detailseite mit Metadaten, Anleitungen, Fotos und Sammlungseintraegen.
- Anleitungen als PDF-Upload mit eingebautem pdf.js-Viewer oder als externer
Link; mehrere Anleitungen je Set.
- Eigene Fotos je Set, am Handy direkt aus der Kamera.
**Sammlung**
- Uebersicht aller besessenen Sets mit Filtern nach Status, Vollstaendigkeit,
Standort und Hersteller.
- Statistik-Kacheln: Anzahl Sets, Teile gesamt, Wert nach UVP, fehlende Sets,
sowie die Aufteilung nach Hersteller.
- Sammlungseintraege mit Status, Zustand, Standort, Kaufdatum, Kaufpreis und
"geschenkt von".
**Wunschlisten**
- Ansicht "Fehlende Sets" mit Mehrfachauswahl: langes Druecken startet die
Auswahl, die Aktionsleiste uebernimmt sie in eine bestehende oder neue
Wunschliste. Dubletten und bereits besessene Sets werden uebersprungen und
im Ergebnis benannt.
- Wunschlisten mit Prioritaet, Reihenfolge, Anlass, Termin und Hinweistext.
- Bereich "Aus fehlenden Sets hinzufuegen" auf der Wunschlisten-Seite,
vorgefiltert auf Sets, die noch nicht drauf sind.
- Oeffentliche Ansicht unter /w/[shareToken] ohne Anmeldung; Besucher
reservieren mit ihrem Namen und erhalten einen Storno-Link.
- Ueberraschungsmodus: der Besitzer sieht nur "reserviert", nicht von wem.
- Erfuellte Wuensche werden automatisch markiert, sobald das Set in der
Sammlung landet, und verschwinden aus der oeffentlichen Ansicht.
- Share-Token laesst sich neu erzeugen; der alte Link ist danach tot.
**Stammdaten**
- Hersteller, Themen (mit Unterthemen) und Standorte (schachtelbar) verwalten.
**Betrieb**
- Docker-Stack: Multi-Stage-Build mit standalone-Output, non-root Nutzer,
Healthcheck unter /api/health, ein Service und ein Bind-Mount auf ./data.
- Migrationen laufen bei jedem Containerstart automatisch.
- update.sh mit Sicherung, Build, Healthcheck und automatischem Rollback.
- Seed-Skript mit 3 Herstellern, 6 Themen, 10 Sets, 4 Sammlungseintraegen und
einer Wunschliste.
- Dokumentation: Schnellstart inklusive Nginx-Proxy-Manager-Konfiguration,
bebilderte Anleitung, README, Roadmap und Devlog.
### Sicherheit
- Serverseitige Validierung aller Eingaben mit zod.
- Uploads auf PDF, JPEG, PNG und WebP begrenzt, geprueft an den Magic Bytes
statt am gemeldeten MIME-Typ; Dateinamen erzeugt immer der Server.
- Oeffentliche Wunschlisten-Routen nutzen ausschliesslich den Share-Token und
geben nichts ausserhalb der Wunschliste preis; die Seite ist noindex.
- Storno- und Einladungs-Token liegen nur als SHA-256-Hash in der Datenbank.
- Session-Cookies httpOnly, sameSite=lax und im Betrieb secure.
- Anleitungs-PDFs und eigene Fotos sind nur mit Session abrufbar; oeffentlich
sind allein die Set-Bilder, die die geteilte Wunschliste braucht.
### Bekannte Einschraenkungen
- Der Katalog enthaelt nur, was von Hand angelegt wurde. Der Import ueber
Rebrickable kommt in 0.2.0.
- Weitere Konten sind noch nicht moeglich; Einladungslinks kommen in 0.2.0.
- 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.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
+55 -6
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**
@@ -33,9 +36,9 @@ Bild-URLs, die der Nutzer selbst hinterlegt hat.
(`retail_price_cents`, `acquired_price_cents`). SQLite hat keinen
Decimal-Typ; Float-Summen waeren ungenau. Umrechnung ausschliesslich
in `src/lib/money.ts`.
- Datenbankspalten in `snake_case` - **ausser** in den Auth-Tabellen,
die `camelCase` verwenden, weil das exakt der Ausgabe von
`@better-auth/cli generate` entspricht.
- Alle Datenbankspalten in `snake_case`. Auch in den Auth-Tabellen -
der Drizzle-Adapter von better-auth loest Modelle ueber die
TypeScript-Property-Namen auf, nicht ueber die Spaltennamen.
- IDs sind Text-UUIDs (`crypto.randomUUID()`), Zeitstempel sind
`integer(mode: 'timestamp')`.
- Routen sind deutsch (`/katalog`, `/sammlung`, `/wunschlisten`),
@@ -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`.
+86
View File
@@ -0,0 +1,86 @@
# syntax=docker/dockerfile:1
# ---------------------------------------------------------------------------
# Bricktrack - Multi-Stage-Build
#
# Ergebnis ist ein schlankes Runtime-Image mit Next.js im Standalone-Modus,
# das als Nutzer "node" laeuft. Alle Daten liegen ausserhalb des Images unter
# /data, damit ein Update nichts anfasst, was dir gehoert.
# ---------------------------------------------------------------------------
FROM node:24-alpine AS base
ENV PNPM_HOME=/pnpm
ENV PATH=$PNPM_HOME:$PATH
ENV NEXT_TELEMETRY_DISABLED=1
RUN corepack enable
# --- Abhaengigkeiten -------------------------------------------------------
# better-sqlite3 und sharp bringen Prebuilds mit; die Build-Werkzeuge sind der
# Rueckfallweg, falls fuer die Plattform keines existiert.
FROM base AS deps
RUN apk add --no-cache libc6-compat python3 make g++
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile
# --- Build -----------------------------------------------------------------
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Platzhalter nur fuer den Build: src/env.ts validiert beim Import, und die
# echten Werte kommen zur Laufzeit aus der .env. Keiner dieser Werte landet im
# Image-Zustand, weil keine Seite vorgerendert wird.
ENV NODE_ENV=production
ENV BASE_URL=http://localhost:3000
ENV BETTER_AUTH_SECRET=build-platzhalter-0123456789-abcdefghij
ENV DATA_DIR=/tmp/build-data
RUN pnpm build
# Der Migrations-Runner wird zu einer einzelnen Datei gebuendelt. So braucht
# das Runtime-Image kein drizzle-kit und kein tsx - nur das native
# better-sqlite3, das ohnehin dabei ist. Das Buendel ist CommonJS, weil
# mitgebuendelte CJS-Abhaengigkeiten sonst am require() der Node-Builtins
# scheitern.
RUN pnpm exec esbuild scripts/migrate.ts \
--bundle \
--platform=node \
--format=cjs \
--target=node22 \
--external:better-sqlite3 \
--outfile=dist/migrate.cjs
# --- Laufzeit --------------------------------------------------------------
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
ENV DATA_DIR=/data
ENV MIGRATIONS_DIR=/app/drizzle
# Standalone bringt seine eigenen node_modules mit - kein pnpm install noetig.
COPY --from=builder --chown=node:node /app/.next/standalone ./
COPY --from=builder --chown=node:node /app/.next/static ./.next/static
COPY --from=builder --chown=node:node /app/public ./public
COPY --from=builder --chown=node:node /app/drizzle ./drizzle
COPY --from=builder --chown=node:node /app/dist/migrate.cjs ./migrate.cjs
COPY --chown=node:node docker/entrypoint.sh /app/entrypoint.sh
RUN chmod +x /app/entrypoint.sh
# Anlaufstelle fuer Datenbank und Uploads. Wird per Bind-Mount ueberdeckt;
# das Anlegen hier sorgt nur dafuer, dass der Pfad auch ohne Mount existiert.
RUN mkdir -p /data/db /data/files && chown -R node:node /data
USER node
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD wget -q --spider http://127.0.0.1:3000/api/health || exit 1
ENTRYPOINT ["/app/entrypoint.sh"]
CMD ["node", "server.js"]
+211
View File
@@ -0,0 +1,211 @@
# Bricktrack – Schnellstart
Von null auf laufende Instanz. Zeitbedarf: etwa 15 Minuten, davon 10 für den
ersten Docker-Build.
**Voraussetzungen:** Ein Linux-Host (z. B. eine LXC oder VM auf Proxmox) mit
Docker und dem Compose-Plugin, ein Reverse Proxy mit gültigem TLS-Zertifikat
und eine Subdomain, die auf den Proxy zeigt.
> **Warum HTTPS Pflicht ist:** Passkeys (WebAuthn) funktionieren
> ausschließlich über HTTPS und sind fest an den Hostnamen gebunden. Ohne
> Zertifikat bleibt nur die Anmeldung per Passwort. Wird die Domain später
> geändert, sind **alle** Passkeys ungültig und müssen neu angelegt werden.
---
## 1. Projekt holen
```bash
sudo mkdir -p /opt/bricktrack
sudo chown "$USER" /opt/bricktrack
git clone https://git.wilde-server.de/florian.wilde/bricktrack.git /opt/bricktrack
cd /opt/bricktrack
```
## 2. Konfiguration anlegen
```bash
cp .env.example .env
```
Jetzt `.env` öffnen und zwei Werte setzen:
```bash
# Die Adresse, unter der du die App im Browser aufrufst.
BASE_URL=https://bricktrack.example.de
# Signiergeheimnis - hiermit erzeugen:
# openssl rand -base64 48
BETTER_AUTH_SECRET=<dein-erzeugter-wert>
```
Alles andere kann so bleiben. `DATA_DIR=/data` ist der Pfad **im Container**
und wird nicht angefasst.
## 3. Datenverzeichnis vorbereiten
Der Container läuft aus Sicherheitsgründen nicht als root, sondern als Nutzer
mit der UID 1000. Das gemountete Verzeichnis muss ihm gehören:
```bash
mkdir -p ./data
sudo chown -R 1000:1000 ./data
```
Diesen Schritt vergisst man gern – der Container sagt dir dann aber beim Start
genau das.
## 4. Starten
```bash
docker compose up -d --build
```
Der erste Build dauert einige Minuten. Danach:
```bash
docker compose ps # Status "healthy" abwarten
docker compose logs -f app # Log mitlesen, mit Strg+C beenden
curl -s http://127.0.0.1:3000/api/health
# {"status":"ok","time":"..."}
```
## 5. Reverse Proxy einrichten (Nginx Proxy Manager)
In NPM unter **Hosts → Proxy Hosts → Add Proxy Host**:
**Reiter „Details"**
| Feld | Wert |
|---|---|
| Domain Names | `bricktrack.example.de` |
| Scheme | `http` |
| Forward Hostname / IP | IP des Docker-Hosts, z. B. `192.168.1.50` |
| Forward Port | `3000` |
| Cache Assets | **aus** (Next.js setzt seine Cache-Header selbst) |
| Block Common Exploits | an |
| Websockets Support | **an** |
**Reiter „SSL"**
| Feld | Wert |
|---|---|
| SSL Certificate | Let's Encrypt anfordern oder vorhandenes wählen |
| Force SSL | **an** |
| HTTP/2 Support | an |
| HSTS Enabled | an (optional, aber sinnvoll) |
**Reiter „Advanced"** – hier hinein, sonst scheitern große Anleitungs-PDFs am
Standardlimit von 1 MB:
```nginx
# Anleitungs-PDFs koennen mehrere Megabyte gross sein.
client_max_body_size 64m;
# Uploads und PDF-Auslieferung duerfen etwas dauern.
proxy_read_timeout 300;
proxy_send_timeout 300;
# Die App leitet ihre oeffentliche Adresse und die Client-IP hieraus ab.
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Real-IP $remote_addr;
```
`client_max_body_size` muss mindestens so groß sein wie `MAX_UPLOAD_MB` in
deiner `.env` (Standard 25 MB); 64m gibt Luft.
> **Wo lauscht der Container?** Voreinstellung ist `127.0.0.1:3000` – die App
> ist dann ausschließlich über einen Proxy auf demselben Host erreichbar.
> Zwei Fälle brauchen eine Anpassung in der `.env`:
>
> - **NPM läuft auf einem anderen Host:** `BIND_ADDRESS=0.0.0.0` setzen und
> den Zugriff per Firewall auf die Proxy-IP beschränken.
> - **Port 3000 ist schon belegt:** `HOST_PORT=3001` (o. ä.) setzen und im
> Proxy denselben Port eintragen.
>
> **Läuft NPM im selben Docker-Netz?** Dann brauchst du gar kein
> Port-Mapping: die `ports`-Zeile in `docker-compose.yml` entfernen, beide
> Container in ein gemeinsames Netzwerk hängen und als Forward Hostname den
> Container-Namen `bricktrack` mit Port `3000` eintragen.
## 6. Ersten Nutzer anlegen
`https://bricktrack.example.de` aufrufen. Die App leitet direkt auf die
Registrierung.
**Der erste registrierte Nutzer wird automatisch Administrator.** Danach ist
die Registrierung geschlossen – weitere Konten entstehen nur über einen
Einladungslink (ab Version 0.2.0).
Also: gleich beim ersten Aufruf selbst registrieren, bevor es jemand anderes
tut.
## 7. Passkey hinterlegen
Unter **Konto → Passkeys → Passkey hinzufügen**. Ab dann meldest du dich per
Fingerabdruck, Gesichtserkennung oder Geräte-PIN an. Für jedes Gerät ein
eigener Passkey – Handy, Tablet, Laptop.
## 8. Loslegen
1. **Verwaltung → Hersteller**: Lego, Playmobil, Cobi … anlegen.
2. **Verwaltung → Themen**: „City", „Technic", „Novelmore" …
3. **Verwaltung → Standorte**: „Regal Kinderzimmer", „Kiste Keller 3" …
4. **Katalog → Set anlegen**: Setnummer, Name, Bild.
5. Auf der Set-Seite **„Zur Sammlung hinzufügen"**, wenn das Set da ist.
6. **Katalog → Filter „Fehlt noch"**: Kachel lange drücken, mehrere auswählen,
**„Zur Wunschliste hinzufügen"**.
7. **Wunschlisten → Liste öffnen → Link kopieren** und verschicken.
Wie das im Einzelnen aussieht, zeigt die [bebilderte
Anleitung](docs/ANLEITUNG.md).
> Beispieldaten (`pnpm db:seed`) gibt es nur in der Entwicklungsumgebung – das
> Produktions-Image bringt sie bewusst nicht mit, damit in deiner Sammlung
> nichts steht, was dir nicht gehört.
---
## Sichern
Alles, was zählt, liegt in **einem** Verzeichnis:
```bash
docker compose stop app
tar czf bricktrack-$(date +%F).tar.gz data .env
docker compose start app
```
Zurückspielen:
```bash
docker compose down
tar xzf bricktrack-2026-09-06.tar.gz
sudo chown -R 1000:1000 ./data
docker compose up -d
```
## Aktualisieren
```bash
./update.sh
```
Das Skript sichert vorher automatisch, baut neu, wartet auf den Healthcheck
und rollt bei einem Fehlschlag zurück. Details in der [README](README.md).
---
## Wenn etwas klemmt
| Symptom | Ursache und Lösung |
|---|---|
| `FEHLER: In /data darf nicht geschrieben werden` | `sudo chown -R 1000:1000 ./data` |
| Container startet, aber `unhealthy` | `docker compose logs app` – meist eine fehlende Variable in `.env` |
| `Ungueltige Umgebungsvariablen: BETTER_AUTH_SECRET` | Das Geheimnis braucht mindestens 32 Zeichen |
| Passkey lässt sich nicht anlegen | Kein HTTPS, oder `BASE_URL` passt nicht zur aufgerufenen Domain |
| Passkeys funktionieren nach einem Domainwechsel nicht mehr | Erwartet – Passkeys sind an die Domain gebunden, neu anlegen |
| `413 Request Entity Too Large` beim PDF-Upload | `client_max_body_size` im Proxy erhöhen (Schritt 5) |
| Anmeldung meldet „Zu viele Versuche" | Rate-Limit: fünf Fehlversuche pro Minute, danach kurz warten |
+229 -21
View File
@@ -1,36 +1,244 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
# Bricktrack
## Getting Started
Self-hosted Verwaltung für die Spielzeug-Set-Sammlung deines Kindes – Lego,
Playmobil, Cobi, BlueBrixx, Mega Bloks und jeden weiteren Hersteller, den du
selbst anlegst.
First, run the development server:
Die App beantwortet zwei Fragen sofort:
- **Welche Sets hat er?** → die Sammlung, mit Standort, Zustand und Statistik.
- **Welche Sets hat er noch nicht?** → alles im Katalog ohne Sammlungseintrag.
Aus den fehlenden Sets entsteht in wenigen Handgriffen eine Wunschliste, die
du per Link mit Familie und Freunden teilst. Die reservieren dort ihr Geschenk
– **ohne sich zu registrieren**, und ohne dass jemand doppelt kauft.
> **Neu hier?** → [QUICKSTART.md](QUICKSTART.md) führt dich in 15 Minuten von
> null zur laufenden Instanz, inklusive Nginx Proxy Manager.
> **Bedienung?** → [docs/ANLEITUNG.md](docs/ANLEITUNG.md), bebildert.
---
## Was drin ist
| | |
|---|---|
| **Katalog** | Sets aller Hersteller, Suche nach Nummer, Name oder EAN, Filter nach Hersteller, Thema, Jahr und Besitzstatus |
| **Sammlung** | Was tatsächlich da ist: Status, Vollständigkeit, Zustand, Standort, Kaufpreis, „geschenkt von" |
| **Anleitungen** | PDFs hochladen und im Browser blättern (pdf.js, ohne externe Dienste) oder auf die Hersteller-Seite verlinken |
| **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
am Schreibtisch.
## Technik
Next.js 15 (App Router, Server Actions, TypeScript strict) · Tailwind CSS 4 ·
shadcn/ui · SQLite über Drizzle ORM und better-sqlite3 · better-auth mit
Passkey-Plugin · alles in **einem** Docker-Container.
Keine Cloud, keine externen Dienste, keine Telemetrie. Der Browser lädt nichts
von fremden Servern außer den Bild-URLs, die du selbst hinterlegt hast.
Details zu Stack, Konventionen und Datenmodell: [CLAUDE.md](CLAUDE.md).
---
## Installation
Ausführlich in [QUICKSTART.md](QUICKSTART.md). Die Kurzfassung:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
git clone https://git.wilde-server.de/florian.wilde/bricktrack.git
cd bricktrack
cp .env.example .env
# BASE_URL und BETTER_AUTH_SECRET setzen:
# openssl rand -base64 48
mkdir -p ./data
sudo chown -R 1000:1000 ./data # der Container laeuft als UID 1000
docker compose up -d --build
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
Danach `https://deine-domain` aufrufen. **Der erste registrierte Nutzer wird
Administrator**, danach ist die Registrierung geschlossen.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
### Konfiguration
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
Alle Werte kommen aus `.env` (Vorlage: `.env.example`).
## Learn More
| Variable | Pflicht | Standard | Bedeutung |
|---|---|---|---|
| `BASE_URL` | ja | `http://localhost:3000` | Öffentliche Adresse. Bestimmt die Passkey-Domain (RP-ID). |
| `BETTER_AUTH_SECRET` | ja | – | Signiergeheimnis, mindestens 32 Zeichen. |
| `DATA_DIR` | nein | `/data` | Datenverzeichnis im Container. Nicht ändern. |
| `MAX_UPLOAD_MB` | nein | `25` | Größenlimit je Datei. Muss zum Proxy passen. |
| `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 | – | 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. |
To learn more about Next.js, take a look at the following resources:
### Reverse Proxy
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
TLS wird außerhalb terminiert, der Container spricht nur HTTP auf Port 3000.
Drei Dinge sind wichtig:
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
1. **HTTPS mit Force-SSL** – ohne das keine Passkeys.
2. **`client_max_body_size`** mindestens so groß wie `MAX_UPLOAD_MB`, sonst
scheitern große Anleitungs-PDFs mit „413".
3. **`X-Forwarded-For`** durchreichen, damit das Rate-Limit auf dem
Login-Endpunkt die echte Client-IP sieht.
## Deploy on Vercel
Die vollständige Nginx-Proxy-Manager-Konfiguration steht in
[QUICKSTART.md, Schritt 5](QUICKSTART.md#5-reverse-proxy-einrichten-nginx-proxy-manager).
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
---
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
## Backup und Restore
**Ein Verzeichnis reicht.** `./data` enthält die Datenbank *und* alle
hochgeladenen Dateien:
```
data/
db/bricktrack.db SQLite (dazu -wal und -shm)
files/images/sets/… Set-Bilder
files/photos/… eigene Fotos
files/instructions/… Anleitungs-PDFs
```
**Sichern** (Container kurz anhalten, damit die Datenbank nicht mitten im
Schreiben eingepackt wird):
```bash
docker compose stop app
tar czf bricktrack-$(date +%F).tar.gz data .env
docker compose start app
```
**Zurückspielen:**
```bash
docker compose down
rm -rf data
tar xzf bricktrack-2026-09-06.tar.gz
sudo chown -R 1000:1000 ./data
docker compose up -d
```
Migrationen laufen bei jedem Start automatisch – ein altes Backup wird beim
Zurückspielen also auf den aktuellen Schemastand gehoben.
## Aktualisieren
```bash
./update.sh
```
Das Skript sichert `./data` und `.env`, holt den neuen Stand, baut das Image,
startet neu und wartet auf den Healthcheck. Wird der Container nicht gesund,
rollt es Code **und** Container automatisch auf den vorherigen Commit zurück
und sagt dir, wo die Sicherung liegt.
```bash
./update.sh --no-pull # nur neu bauen, ohne git pull
./update.sh --no-backup # ohne Sicherung (nicht empfohlen)
```
Die letzten zehn Sicherungen bleiben unter `./backups` liegen.
---
## Entwicklung
```bash
corepack enable pnpm # pnpm 10
pnpm install
cp .env.example .env # DATA_DIR=./data, BASE_URL=http://localhost:3000
pnpm db:migrate # Datenbank anlegen
pnpm db:seed # Beispieldaten: 3 Hersteller, 10 Sets, 1 Wunschliste
pnpm dev # http://localhost:3000
```
| Befehl | Wirkung |
|---|---|
| `pnpm dev` | Entwicklungsserver |
| `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 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
> administrative Rechte braucht. Der Docker-Build unter Linux ist davon nicht
> betroffen. Zum Prüfen lokal: `docker build -t bricktrack:test .`
Die Passkey-Anmeldung funktioniert lokal auch über `http://localhost` –
WebAuthn behandelt localhost als sicheren Kontext. Auf jeder anderen Adresse
braucht es HTTPS.
---
## Datenmodell in einem Absatz
Ein **`set`** ist ein Katalogeintrag: Das Set existiert in der Welt,
unabhängig davon, ob wir es besitzen. Ein **`collection_item`** verknüpft ein
Set mit einem tatsächlich vorhandenen Exemplar – dasselbe Set zweimal im Regal
sind zwei Einträge. Daraus folgt die zentrale Regel:
> „Hat er nicht" = Sets im Katalog, zu denen kein `collection_item` mit einem
> Status ungleich `sold` existiert.
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
- Jede Eingabe wird serverseitig mit zod validiert.
- Uploads sind auf PDF, JPEG, PNG und WebP begrenzt – geprüft an den **Magic
Bytes**, nicht am gemeldeten MIME-Typ. Dateinamen erzeugt immer der Server.
- Die öffentliche Wunschliste nutzt ausschließlich den Share-Token als
Schlüssel und gibt nichts außer den Wunschlisten-Daten preis. Sie ist
`noindex`.
- Storno- und Einladungs-Token liegen nur als SHA-256-Hash in der Datenbank.
- Session-Cookies sind `httpOnly`, `sameSite=lax` und im Betrieb `secure`.
Auf den Login-Endpunkten liegt ein Rate-Limit (5 Versuche pro Minute).
- Set-Bilder sind ohne Anmeldung abrufbar – die geteilte Wunschliste braucht
sie. Anleitungs-PDFs und eigene Fotos **nie**.
## Stand und Pläne
Aktuelle Version und Änderungen: [CHANGELOG.md](CHANGELOG.md).
Was als Nächstes kommt: [docs/ROADMAP.md](docs/ROADMAP.md).
Wie lange was gedauert hat: [docs/DEVLOG.md](docs/DEVLOG.md).
## Lizenz
Privates Projekt, keine Lizenz vergeben.
+47
View File
@@ -0,0 +1,47 @@
# ---------------------------------------------------------------------------
# Bricktrack - ein Service, ein Datenverzeichnis.
#
# TLS terminiert der Reverse Proxy (z. B. Nginx Proxy Manager). Der Container
# spricht nur HTTP und muss nicht nach aussen erreichbar sein.
# ---------------------------------------------------------------------------
services:
app:
build:
context: .
dockerfile: Dockerfile
image: bricktrack:latest
container_name: bricktrack
restart: unless-stopped
env_file:
- .env
environment:
# Im Container liegen die Daten immer unter /data - der Pfad auf dem Host
# wird ueber den Bind-Mount unten bestimmt.
DATA_DIR: /data
PORT: 3000
ports:
# Standardmaessig nur an localhost gebunden: erreichbar wird die App
# ueber den Reverse Proxy. Sitzt der Proxy auf einem anderen Host, in
# der .env BIND_ADDRESS=0.0.0.0 setzen (und die Firewall zuziehen).
- "${BIND_ADDRESS:-127.0.0.1}:${HOST_PORT:-3000}:3000"
volumes:
# Das eine Verzeichnis, das gesichert werden muss.
- ./data:/data
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:3000/api/health"]
interval: 30s
timeout: 5s
start_period: 20s
retries: 3
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
+30
View File
@@ -0,0 +1,30 @@
#!/bin/sh
# Startet den Container: Verzeichnisse pruefen, Migrationen anwenden, App starten.
set -e
DATA_DIR="${DATA_DIR:-/data}"
echo "[bricktrack] Datenverzeichnis: $DATA_DIR"
if ! mkdir -p "$DATA_DIR/db" "$DATA_DIR/files" 2>/dev/null; then
echo ""
echo " FEHLER: In $DATA_DIR darf nicht geschrieben werden."
echo ""
echo " Der Container laeuft als Nutzer 'node' (UID 1000). Das gemountete"
echo " Verzeichnis muss ihm gehoeren. Auf dem Host einmalig:"
echo ""
echo " mkdir -p ./data && sudo chown -R 1000:1000 ./data"
echo ""
exit 1
fi
if [ ! -w "$DATA_DIR/db" ]; then
echo " FEHLER: $DATA_DIR/db ist nicht beschreibbar (sudo chown -R 1000:1000 ./data)."
exit 1
fi
echo "[bricktrack] Migrationen werden angewendet …"
node /app/migrate.cjs
echo "[bricktrack] Start auf Port ${PORT:-3000}"
exec "$@"
+563
View File
@@ -0,0 +1,563 @@
# Bricktrack – Anleitung
So benutzt du Bricktrack im Alltag. Die Bilder sind auf einem Handy
aufgenommen, weil die App genau dafür gebaut ist: für den Laden und das
Kinderzimmer, nicht für den Schreibtisch.
> Installation und Reverse Proxy stehen im [Schnellstart](../QUICKSTART.md).
> Die Bilder hier lassen sich mit `pnpm screenshots` jederzeit neu erzeugen –
> sie stammen aus den Beispieldaten des Seed-Skripts.
---
## Inhalt
1. [Das erste Konto](#1-das-erste-konto)
2. [Der Überblick](#2-der-überblick)
3. [Stammdaten anlegen](#3-stammdaten-anlegen)
4. [Sets in den Katalog](#4-sets-in-den-katalog)
5. [Was fehlt noch? Mehrere Sets auf einmal auswählen](#5-was-fehlt-noch)
6. [Die Set-Detailseite](#6-die-set-detailseite)
7. [Die Sammlung](#7-die-sammlung)
8. [Wunschlisten](#8-wunschlisten)
9. [Teilen – so sehen es Oma und Tante Anne](#9-teilen)
10. [Passkeys einrichten](#10-passkeys-einrichten)
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)
---
## 1. Das erste Konto
![Registrierung](bilder/01-registrierung.png)
Beim allerersten Aufruf landest du direkt auf der Registrierung. **Wer sich
zuerst registriert, wird Administrator** – danach ist die Registrierung
geschlossen. Melde dich also gleich selbst an, bevor es jemand anderes tut.
Das Passwort braucht mindestens zehn Zeichen. Einen Passkey hinterlegst du
gleich danach unter „Konto" (siehe [Abschnitt 10](#10-passkeys-einrichten)).
---
## 2. Der Überblick
![Startseite](bilder/02-start.png)
Die Startseite ist die Abzweigung zu den vier Bereichen:
| | |
|---|---|
| **Katalog** | Alle bekannten Sets – egal ob vorhanden oder nicht |
| **Fehlende Sets** | Der direkte Weg zu allem, was noch fehlt |
| **Sammlung** | Was tatsächlich da ist |
| **Wunschlisten** | Zum Teilen mit Familie und Freunden |
Unten am Bildschirmrand liegt die Navigation – auf dem Handy mit dem Daumen
erreichbar.
---
## 3. Stammdaten anlegen
![Hersteller verwalten](bilder/03-verwaltung-hersteller.png)
Bevor du Sets anlegst, brauchst du mindestens einen **Hersteller**. Zu finden
über das Konto-Menü oben rechts → **Verwaltung**.
Drei Arten von Stammdaten:
- **Hersteller** – Lego, Playmobil, Cobi, BlueBrixx … beliebig erweiterbar.
Die hinterlegte Website erscheint später als „Zum Shop"-Link auf der
geteilten Wunschliste.
- **Themen** – Serien wie „City", „Technic", „Novelmore". Themen gehören zu
einem Hersteller und können Unterthemen haben („City → Feuerwehr").
- **Standorte** – „Regal Kinderzimmer", „Kiste Keller 3". Auch schachtelbar,
damit „Keller → Kiste 3" möglich ist.
> Ein Hersteller oder Thema lässt sich nicht löschen, solange noch Sets daran
> hängen. Standorte schon – die betroffenen Sammlungseinträge verlieren dann
> nur ihren Standort.
---
## 4. Sets in den Katalog
![Katalog](bilder/04-katalog.png)
Der Katalog ist die Liste **aller Sets, die du kennst** – unabhängig davon, ob
ihr sie besitzt. Das ist der wichtigste Gedanke der App:
> Ein Set im Katalog heißt „das gibt es". Erst ein Eintrag in der Sammlung
> heißt „das haben wir".
Deshalb kannst du unbesorgt Sets anlegen, die ihr euch nur anschaut – genau
die braucht die Wunschliste später.
**Suchen** kannst du nach Setnummer, Name oder EAN. Die vier Filter darunter:
Besitzstatus, Hersteller, Thema und Jahr. Jeder Filterstand steckt in der
URL – ein Lesezeichen auf „alle fehlenden Lego-Technic-Sets" ist damit möglich.
Die Kacheln zeigen auf einen Blick:
- **grüner Haken „Vorhanden"** – ist in der Sammlung (bei mehreren Exemplaren
„2× vorhanden")
- **„Auf Wunschliste"** – steht auf mindestens einer Liste
- Setnummer, Hersteller, Thema, Jahr und UVP
Mit **„Set anlegen"** oben rechts kommt ein neues Set dazu. Pflicht sind nur
Hersteller, Setnummer und Name; alles andere kannst du nachtragen. Ein Bild
lädst du entweder hoch (es wird verkleinert und lokal gespeichert) oder du
hinterlegst eine Bild-URL.
---
## 5. Was fehlt noch?
![Fehlende Sets](bilder/05-fehlende-sets.png)
**Katalog → Filter „Fehlt noch"** (oder direkt `/katalog?besitz=fehlt`) zeigt
ausschließlich Sets, die **nicht** in der Sammlung sind. Das ist der zentrale
Weg zur Wunschliste.
### Mehrere Sets auf einmal
![Mehrfachauswahl](bilder/06-mehrfachauswahl.png)
Eine Kachel **lange drücken** startet den Auswahlmodus – am Handy die
natürliche Geste, das Gerät vibriert kurz. Am Rechner tut es der Knopf
**„Auswählen"**. Danach wählt ein Tippen jede weitere Kachel an oder ab.
Unten erscheint die Aktionsleiste mit der Anzahl und dem Knopf **„Zur
Wunschliste hinzufügen"**.
![Zieldialog](bilder/07-wunschliste-dialog.png)
Im Dialog wählst du die Ziel-Wunschliste oder legst gleich eine neue an, dazu
eine Priorität für alle ausgewählten Sets:
| Priorität | Bedeutung |
|---|---|
| Großer Wunsch | Das steht ganz oben |
| Gerne | Der Normalfall |
| Irgendwann | Nice to have |
Die Priorität lässt sich später je Eintrag ändern.
**Doppelte werden übersprungen.** Sets, die schon auf der Liste stehen oder
bereits in der Sammlung sind, werden nicht noch einmal hinzugefügt – die
Meldung danach sagt dir genau, welche das waren und warum. Das ist keine
UI-Höflichkeit, sondern in der Datenbank abgesichert.
---
## 6. Die Set-Detailseite
![Set-Detailseite](bilder/08-set-detail.png)
Hier steht alles zu einem Set:
- **Zur Sammlung hinzufügen** – ein Tipp, und das Set gilt als vorhanden.
Details wie Standort, Zustand oder Kaufpreis trägst du danach nach.
- **Auf Wunschliste** – derselbe Zieldialog wie oben, nur für dieses eine Set.
Ist das Set bereits in der Sammlung, ist der Knopf ausgegraut: Was da ist,
gehört nicht auf einen Wunschzettel.
- **Anleitungen** – PDFs hochladen (mehrere je Set, etwa „Buch 1 von 3") und
direkt im Browser durchblättern, ohne Download und ohne fremde Dienste.
Alternativ verlinkst du die Anleitung beim Hersteller.
- **Fotos** – eigene Bilder, am Handy direkt aus der Kamera.
- **In der Sammlung** – jedes Exemplar mit Status, Vollständigkeit, Zustand,
Standort, Kaufdatum, Preis und „geschenkt von".
### Die fünf Status eines Exemplars
| Status | Bedeutung |
|---|---|
| Vorhanden | Da, aber weder auf- noch abgebaut vermerkt |
| Aufgebaut | Steht zusammengebaut im Regal |
| Zerlegt | In Einzelteilen in der Kiste |
| Verliehen | Gerade bei jemand anderem |
| **Verkauft** | Zählt **nicht** mehr als Besitz |
Der letzte Punkt ist wichtig: Ein verkauftes Set taucht wieder unter „Fehlt
noch" auf und darf zurück auf eine Wunschliste.
---
## 7. Die Sammlung
![Sammlung](bilder/09-sammlung.png)
Oben vier Kacheln: Anzahl Sets, Teile insgesamt, Wert nach UVP und wie viele
Sets im Katalog noch fehlen. Darunter die Aufteilung nach Hersteller.
Gefiltert wird nach Status, Vollständigkeit, Standort und Hersteller – nützlich
für die Frage „Was steht eigentlich in Kiste 3?".
Verkaufte Exemplare zählen in keiner Kachel mit.
---
## 8. Wunschlisten
![Wunschlisten](bilder/10-wunschlisten.png)
Jede Liste hat einen Titel, einen Anlass und optional einen Termin. Die Karte
zeigt, wie viele Wünsche drauf sind und wie viele davon noch niemand
übernommen hat.
![Wunschliste bearbeiten](bilder/11-wunschliste-bearbeiten.png)
Auf der Liste selbst kannst du:
- die **Reihenfolge** mit den Pfeilen ändern – so stehen die wichtigsten
Wünsche oben,
- die **Priorität** je Eintrag anpassen,
- Einträge entfernen,
- unten im Bereich **„Aus fehlenden Sets hinzufügen"** direkt weitere Sets
dazunehmen. Dort steckt dieselbe Auswahlansicht wie im Katalog, schon
vorgefiltert auf Sets, die weder in der Sammlung noch auf dieser Liste sind.
### Zwei Einstellungen, die den Unterschied machen
- **Über Link teilbar** – ohne diesen Haken ist der öffentliche Link tot.
Praktisch, wenn du eine Liste erst fertig bauen willst.
- **Überraschungsmodus** – dann siehst *du* nur „Reserviert", nicht von wem.
Die Schenkenden untereinander sehen die Namen weiterhin, damit niemand
doppelt kauft.
---
## 9. Teilen
Auf der Wunschliste steht oben der **Link zum Teilen**. Kopieren, oder am
Handy über den Teilen-Knopf direkt in WhatsApp, Signal oder eine Mail geben.
> Der Link ist der einzige Schlüssel. Wer ihn hat, sieht die Liste – mehr
> nicht: keine Sammlung, keine anderen Listen, keine Namen von Konten. Für
> Suchmaschinen ist die Seite gesperrt.
>
> Ist ein Link in falsche Hände geraten, erzeugst du mit **„Neuen Link
> erzeugen"** einen frischen. Der alte funktioniert sofort nicht mehr.
### So sieht es für die Beschenkenden aus
![Öffentliche Wunschliste](bilder/12-oeffentliche-wunschliste.png)
Kein Konto, keine Anmeldung, keine App. Nur die Liste, mit Bild, Setnummer,
Preis, Priorität und – falls hinterlegt – einem Link zum Shop des Herstellers.
![Reservieren](bilder/13-reservieren.png)
Ein Tipp auf **„Das schenke ich"**, Name eintragen, fertig.
![Reserviert](bilder/14-reserviert.png)
Danach sehen alle anderen, dass dieser Wunsch vergeben ist. Die Person selbst
sieht „Das schenkst du" und kann die Reservierung jederzeit wieder aufheben –
auf demselben Gerät mit einem Tipp, von einem anderen Gerät über den
Storno-Link, den sie beim Reservieren bekommt.
![Reservierung in der Übersicht](bilder/15-wunschliste-reserviert.png)
Auf deiner Seite siehst du, wer was übernimmt – oder im Überraschungsmodus
eben nur, *dass* etwas übernommen ist.
**Was passiert, wenn das Geschenk ankommt?** Sobald du das Set zur Sammlung
hinzufügst, markiert Bricktrack den Wunsch automatisch als **erfüllt** und
blendet ihn aus der öffentlichen Ansicht aus. Niemand kauft es dann noch ein
zweites Mal.
---
## 10. Passkeys einrichten
![Konto und Passkeys](bilder/16-konto-passkeys.png)
Unter **Konto → Passkeys → Passkey hinzufügen** hinterlegst du Fingerabdruck,
Gesichtserkennung oder die Geräte-PIN als Anmeldung. Danach reicht auf der
Anmeldeseite ein Tipp auf „Mit Passkey anmelden" – auf vielen Geräten schlägt
der Browser den Passkey sogar von selbst vor.
Ein Passkey gilt pro Gerät. Leg ruhig mehrere an – Handy, Tablet, Laptop – und
gib ihnen sprechende Namen.
> **Zwei Dinge, die man wissen muss:**
> 1. Passkeys brauchen HTTPS. Über eine reine `http://`-Adresse (außer
> `localhost`) bietet der Browser sie gar nicht erst an.
> 2. Passkeys sind an die Domain gebunden. Ziehst du von
> `bricktrack.example.de` auf eine andere Adresse um, musst du alle
> Passkeys neu anlegen. Das Passwort funktioniert weiterhin – deshalb ist
> es sinnvoll, es nicht zu vergessen.
---
## 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:
**„Welche Sets hat er?"**
→ Sammlung. Oder im Katalog der Filter „In der Sammlung".
**„Welche Sets hat er noch nicht?"**
→ Katalog, Filter „Fehlt noch". Von dort in zwei Handgriffen auf eine
Wunschliste und per Link zu Oma. Bei mehreren Kindern beantwortet der
Umschalter oben die Frage für jedes einzeln.
Im Laden vor dem Regal: Scanner öffnen, Strichcode einlesen. Steht ein grüner
Haken dran, habt ihr es schon.
---
## Kleinigkeiten, die den Alltag leichter machen
- **Filter stecken in der URL.** Ein Lesezeichen auf
`/katalog?besitz=fehlt&hersteller=…` ist eine gespeicherte Suche.
- **Preise** kannst du als `12,99` oder `12.99` eintippen – beides wird
verstanden, gespeichert wird auf den Cent genau.
- **Dasselbe Set zweimal** ist kein Problem: Zwei Exemplare sind zwei Einträge
in der Sammlung, mit eigenem Standort und Zustand.
- **Anleitungs-PDFs** können groß sein. Kommt beim Hochladen ein Fehler
„413", muss im Reverse Proxy `client_max_body_size` hoch – siehe
[QUICKSTART, Schritt 5](../QUICKSTART.md#5-reverse-proxy-einrichten-nginx-proxy-manager).
- **Alles Wichtige liegt in `./data`.** Dieses eine Verzeichnis sichern
genügt – Datenbank, Bilder, Fotos und Anleitungen sind darin.
+150 -16
View File
@@ -1,32 +1,166 @@
# Devlog
Zeiterfassung und Nachvollziehbarkeit der Entwicklung. Eine Zeile je
abgeschlossenem Arbeitsschritt, Zeiten in `HH:mm` (Europe/Berlin).
abgeschlossenem Arbeitsschritt, Zeiten in `HH:mm` (Europe/Berlin). Die
Commit-Spalte verweist auf den Stand am Ende des Schritts.
## Phase 1 - MVP
## Phase 1 – MVP (v0.1.0)
### 2026-09-06
### 06.09.2026
| Von | Bis | Dauer | Schritt | Ergebnis | Commit |
|---|---|---|---|---|---|
| 22:06 | 22:12 | 0:06 | Planung | Phase-1-Plan und Drizzle-Schema zur Freigabe vorgelegt; 5 Entscheidungsfragen geklaert (Cent statt Float, Migrator statt drizzle-kit im Image, Bilder oeffentlich / PDFs geschuetzt, Token gehasht, Branch pro Phase) | - |
| 22:12 | 22:20 | 0:08 | Scaffold | pnpm via npm global installiert (corepack scheiterte an Rechten in `C:\Program Files\nodejs`), Next 15.5.25 + Tailwind 4 + shadcn/ui (radix-nova) mit 20 Komponenten | `0de49a9` |
| 22:20 | 22:26 | 0:06 | Datenmodell | 19 Tabellen in Drizzle, CHECK-Constraints fuer `instruction`, `photo` und `priority`, Unique-Index `(wishlist_id, set_id)`, Migration erzeugt und angewendet, WAL verifiziert | `06268bc` |
| 22:26 | 22:34 | 0:08 | Dokumentation | CLAUDE.md (Stack, Konventionen, Datenmodell, Sicherheitsregeln), Roadmap, Changelog, Devlog | `folgt` |
| 22:06 | 22:12 | 0:06 | Planung | Phase-1-Plan und Drizzle-Schema zur Freigabe vorgelegt; fünf Entscheidungsfragen geklärt (Cent statt Float, Migrator statt drizzle-kit im Image, Bilder öffentlich / PDFs geschützt, Token gehasht, Branch pro Phase) | – |
| 22:12 | 22:20 | 0:08 | Scaffold | pnpm installiert, Next 15.5.25 + Tailwind 4 + shadcn/ui (radix-nova) mit 20 Komponenten, Touch-Größen auf 44 px angehoben | `0de49a9` |
| 22:20 | 22:25 | 0:05 | Datenmodell | 19 Tabellen in Drizzle, CHECK-Constraints, Unique-Index `(wishlist_id, set_id)`, Migration erzeugt und angewendet, WAL verifiziert | `06268bc` |
| 22:25 | 22:26 | 0:01 | Dokumentation | CLAUDE.md, Roadmap, Changelog, Devlog | `b74cecb` |
| 22:26 | 22:40 | 0:14 | Auth und App-Shell | better-auth mit Passwort und Passkeys, erster Nutzer wird Admin, Rate-Limit in der Datenbank, Konto-Seite, Bottom-Navigation, `/api/health` | `4c78c56` |
| 22:40 | 22:46 | 0:06 | Stammdaten | CRUD für Hersteller, Themen und Standorte über eine gemeinsame CrudSection; zod-Schemata, ActionResult, Slug, Geld- und Datumshelfer | `af0ae03` |
| 22:46 | 22:59 | 0:13 | Katalog | Kachelliste mit Suche und vier Filtern, Besitzlogik in `ownership.ts`, Set-Formular, Detailseite, Upload-Pipeline mit Magic-Byte-Prüfung, pdf.js-Viewer, Seed-Skript | `856800b` |
| 22:59 | 23:06 | 0:07 | Wunschlisten und Sammlung | Mehrfachauswahl mit Dubletten-Skip, Wunschlisten-Verwaltung, Teilen, Überraschungsmodus, öffentliche Ansicht mit Reservierung und Storno, Sammlungsübersicht mit Statistik | `79b0133` |
| 23:06 | 23:31 | 0:25 | Tests | 50 Vitest-Unit-Tests, Playwright-Smoke-Test über den ganzen Weg bis zur Reservierung durch einen Besucher ohne Konto | `b0152b8` |
**Summe Phase 1 bisher: 0:28**
### 07.09.2026
| Von | Bis | Dauer | Schritt | Ergebnis | Commit |
|---|---|---|---|---|---|
| 23:31 | 00:09 | 0:38 | Docker | Multi-Stage-Build, Entrypoint mit Rechteprüfung und Migration, Compose-Stack, Healthcheck, `update.sh` mit Rollback; Image gebaut und Container verifiziert | `61b1249` |
| 00:09 | 00:50 | 0:41 | Dokumentation und Screenshots | README, QUICKSTART mit Nginx-Proxy-Manager-Konfiguration, bebilderte Anleitung, 16 automatisiert erzeugte Screenshots, Changelog 0.1.0 | folgt |
**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
### Umgebung
- **pnpm**: `corepack enable` scheitert auf diesem Rechner an fehlenden
Schreibrechten in `C:\Program Files\nodejs`. Stattdessen
`npm install -g pnpm@10`; pnpm liegt unter
`%APPDATA%\npm\pnpm.ps1` und ist im PATH.
- **shadcn-CLI**: Die aktuelle Version (4.21) arbeitet mit Presets
(`-p nova`) und Base-Libraries (`-b radix`) statt der frueheren
Style-Auswahl. Sie zieht `cn` und `radix-ui` als Pakete; `clsx` und
`tailwind-merge` werden dadurch nicht mehr direkt gebraucht.
`npm install -g pnpm@10`; pnpm liegt unter `%APPDATA%\npm` und ist im PATH.
- **Port 3000** ist auf dem Entwicklungsrechner von einer anderen Anwendung
belegt. Der Dev-Server läuft deshalb lokal auf 3100, die Testläufe auf
3210 und 3220.
- **`pnpm build` unter Windows** scheitert mit `EPERM … symlink`: Der
Standalone-Output legt Symlinks in `.next/standalone` an, wofür Windows den
Entwicklermodus oder administrative Rechte braucht. Der Docker-Build unter
Linux ist davon nicht betroffen; lokal prüft man mit
`docker build -t bricktrack:test .`.
### Bibliotheken
- **shadcn-CLI 4.21** arbeitet mit Presets (`-p nova`) und Base-Libraries
(`-b radix`) statt der früheren Style-Auswahl. Sie zieht `cn` und `radix-ui`
als Pakete; `clsx` und `tailwind-merge` werden dadurch nicht mehr direkt
gebraucht und wurden wieder entfernt. Die Voreinstellungen sind auf
Desktop-Dichte ausgelegt (Buttons `h-8`) – für die Bedienung am Handy auf
44 px angehoben.
- **better-auth 1.7.3**: Das Passkey-Plugin liegt nicht mehr unter
`better-auth/plugins/passkey`, sondern im eigenen Paket
`@better-auth/passkey` (Exports `.` und `./client`). Das erwartete
Schema deckt sich exakt mit der Tabelle `passkey`.
`@better-auth/passkey`. Der Drizzle-Adapter löst Modelle über die
TypeScript-Property-Namen auf, nicht über die Spaltennamen – deshalb können
auch die Auth-Tabellen durchgehend `snake_case` verwenden.
- **Drizzle**: Selbstbezügliche Fremdschlüssel (`parent_theme_id`) brauchen
`AnySQLiteColumn` als Rückgabetyp, nicht `AnyColumn`.
- **pdf.js** wird lokal gebündelt, inklusive Worker. Kein CDN, damit der
Browser des Nutzers keine fremden Server kontaktiert.
### Stolpersteine
- **CRLF im Entrypoint**: Eine unter Windows geschriebene `entrypoint.sh` mit
CRLF scheitert im Container mit dem irreführenden
`exec /app/entrypoint.sh: no such file or directory` – der Kernel sucht
dann `/bin/sh\r`. Behoben mit einer `.gitattributes`, die für Shell-Skripte
LF erzwingt.
- **Migrations-Bundle**: Der mit esbuild als ESM gebündelte Migrations-Runner
scheiterte an `Dynamic require of "fs" is not supported` – mitgebündelte
CJS-Abhängigkeiten (dotenv) rufen `require()` für Node-Builtins auf. Als
CommonJS gebündelt läuft es.
- **Playwright und Reihenfolge**: Playwright startet den Web-Server **vor**
dem `globalSetup`. Das Löschen des Testdatenverzeichnisses im globalSetup
scheiterte deshalb unter Windows an der bereits geöffneten SQLite-Datei;
das Aufräumen läuft jetzt im npm-Skript davor.
- **Screenshots gegen den Dev-Server** waren nicht reproduzierbar: unter Last
brach der Stream mit `ChunkLoadError` beziehungsweise
`Unexpected end of JSON input` ab. Die Bilder entstehen jetzt gegen das
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**:
Das Feld `isPublic` kommt aus einer Checkbox und fiel beim programmatischen
Anlegen auf `false`. Die Schnellanlage setzt es jetzt ausdrücklich.
- Das Sternchen an Pflichtfeldern gehörte zum zugänglichen Namen des Feldes
(„Name *"). Jetzt `aria-hidden` – der Pflichtstatus steckt ohnehin im
`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`.
+42 -29
View File
@@ -2,56 +2,69 @@
Legende: `[ ]` offen · `[~]` in Arbeit · `[x]` fertig
## Phase 1 - MVP (Ziel: v0.1.0)
## Phase 1 - MVP (v0.1.0, abgeschlossen am 07.09.2026)
Alles laeuft vollstaendig im Docker-Stack.
Laeuft vollstaendig im Docker-Stack.
- [x] Projekt-Scaffold (Next 15, Tailwind 4, shadcn/ui, pnpm)
- [x] Datenmodell in Drizzle, initiale Migration
- [x] Projektdokumentation (CLAUDE.md, Roadmap, Changelog, Devlog)
- [ ] Auth: Registrierung des ersten Users (wird Admin), Login mit
- [x] Auth: Registrierung des ersten Users (wird Admin), Login mit
Passwort, Passkey anlegen und damit einloggen (Conditional UI),
Passkey-Verwaltung unter "Konto", Rate-Limit auf Login
- [ ] Mobile-first Layout-Shell mit Bottom-Navigation
- [ ] Stammdaten: Hersteller, Themen (mit Unterthemen), Standorte
- [ ] Set-Katalog: Kachelliste, Suche, Filter (Hersteller, Thema, Jahr,
- [x] Mobile-first Layout-Shell mit Bottom-Navigation
- [x] Stammdaten: Hersteller, Themen (mit Unterthemen), Standorte
- [x] Set-Katalog: Kachelliste, Suche, Filter (Hersteller, Thema, Jahr,
Besitzstatus), Anlegen und Bearbeiten
- [ ] Upload-Pipeline (PDF/JPEG/PNG/WebP, Magic-Byte-Pruefung,
- [x] Upload-Pipeline (PDF/JPEG/PNG/WebP, Magic-Byte-Pruefung,
serverseitige Dateinamen, Thumbnails)
- [ ] Set-Detailseite: Metadaten, Bild, Anleitungen mit pdf.js-Viewer,
- [x] Set-Detailseite: Metadaten, Bild, Anleitungen mit pdf.js-Viewer,
Fotos, Sammlungseintraege
- [ ] Sammlung: Uebersicht, Filter, Statistik-Kacheln
- [ ] Fehlende Sets (`/katalog?besitz=fehlt`) mit Mehrfachauswahl und
- [x] Sammlung: Uebersicht, Filter, Statistik-Kacheln
- [x] Fehlende Sets (`/katalog?besitz=fehlt`) mit Mehrfachauswahl und
Bulk-Aktion "Zur Wunschliste hinzufuegen"
- [ ] Wunschlisten: Verwaltung, Prioritaet, Sortierung, Teilen,
- [x] Wunschlisten: Verwaltung, Prioritaet, Sortierung, Teilen,
Ueberraschungsmodus, automatisches "erfuellt"
- [ ] Oeffentliche Wunschliste `/w/[shareToken]` mit Reservierung ohne
- [x] Oeffentliche Wunschliste `/w/[shareToken]` mit Reservierung ohne
Anmeldung und Storno-Link
- [ ] Seed-Skript (3 Hersteller, 10 Sets, 4 in Sammlung, 1 Wunschliste)
- [ ] Unit-Tests (Besitzstatus, Bulk-Add inkl. Dubletten, Reservierung,
- [x] Seed-Skript (3 Hersteller, 10 Sets, 4 in Sammlung, 1 Wunschliste)
- [x] Unit-Tests (Besitzstatus, Bulk-Add inkl. Dubletten, Reservierung,
Token-Erzeugung) und Playwright-Smoke-Test
- [ ] Docker: Multi-Stage-Build, Compose-Stack, Healthcheck, Entrypoint
- [ ] Dokumentation: README, QUICKSTART, bebilderte Anleitung,
- [x] Docker: Multi-Stage-Build, Compose-Stack, Healthcheck, Entrypoint
- [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.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 294 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 328 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 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

@@ -1,21 +1,21 @@
CREATE TABLE `account` (
`id` text PRIMARY KEY NOT NULL,
`accountId` text NOT NULL,
`providerId` text NOT NULL,
`userId` text NOT NULL,
`accessToken` text,
`refreshToken` text,
`idToken` text,
`accessTokenExpiresAt` integer,
`refreshTokenExpiresAt` integer,
`account_id` text NOT NULL,
`provider_id` text NOT NULL,
`user_id` text NOT NULL,
`access_token` text,
`refresh_token` text,
`id_token` text,
`access_token_expires_at` integer,
`refresh_token_expires_at` integer,
`scope` text,
`password` text,
`createdAt` integer NOT NULL,
`updatedAt` integer NOT NULL,
FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `account_user_idx` ON `account` (`userId`);--> statement-breakpoint
CREATE INDEX `account_user_idx` ON `account` (`user_id`);--> statement-breakpoint
CREATE TABLE `invite` (
`id` text PRIMARY KEY NOT NULL,
`token_hash` text NOT NULL,
@@ -34,50 +34,50 @@ CREATE UNIQUE INDEX `invite_token_hash_unique` ON `invite` (`token_hash`);--> st
CREATE TABLE `passkey` (
`id` text PRIMARY KEY NOT NULL,
`name` text,
`publicKey` text NOT NULL,
`userId` text NOT NULL,
`credentialID` text NOT NULL,
`public_key` text NOT NULL,
`user_id` text NOT NULL,
`credential_id` text NOT NULL,
`counter` integer NOT NULL,
`deviceType` text NOT NULL,
`backedUp` integer NOT NULL,
`device_type` text NOT NULL,
`backed_up` integer NOT NULL,
`transports` text,
`aaguid` text,
`createdAt` integer,
FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
`created_at` integer,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `passkey_credentialID_unique` ON `passkey` (`credentialID`);--> statement-breakpoint
CREATE INDEX `passkey_user_idx` ON `passkey` (`userId`);--> statement-breakpoint
CREATE TABLE `rateLimit` (
CREATE UNIQUE INDEX `passkey_credential_id_unique` ON `passkey` (`credential_id`);--> statement-breakpoint
CREATE INDEX `passkey_user_idx` ON `passkey` (`user_id`);--> statement-breakpoint
CREATE TABLE `rate_limit` (
`id` text PRIMARY KEY NOT NULL,
`key` text,
`count` integer,
`lastRequest` integer
`last_request` integer
);
--> statement-breakpoint
CREATE TABLE `session` (
`id` text PRIMARY KEY NOT NULL,
`token` text NOT NULL,
`userId` text NOT NULL,
`expiresAt` integer NOT NULL,
`ipAddress` text,
`userAgent` text,
`createdAt` integer NOT NULL,
`updatedAt` integer NOT NULL,
FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
`user_id` text NOT NULL,
`expires_at` integer NOT NULL,
`ip_address` text,
`user_agent` text,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `session_token_unique` ON `session` (`token`);--> statement-breakpoint
CREATE INDEX `session_user_idx` ON `session` (`userId`);--> statement-breakpoint
CREATE INDEX `session_user_idx` ON `session` (`user_id`);--> statement-breakpoint
CREATE TABLE `user` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`email` text NOT NULL,
`emailVerified` integer DEFAULT false NOT NULL,
`email_verified` integer DEFAULT false NOT NULL,
`image` text,
`role` text DEFAULT 'member' NOT NULL,
`createdAt` integer NOT NULL,
`updatedAt` integer NOT NULL
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `user_email_unique` ON `user` (`email`);--> statement-breakpoint
@@ -85,9 +85,9 @@ CREATE TABLE `verification` (
`id` text PRIMARY KEY NOT NULL,
`identifier` text NOT NULL,
`value` text NOT NULL,
`expiresAt` integer NOT NULL,
`createdAt` integer NOT NULL,
`updatedAt` integer NOT NULL
`expires_at` integer NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL
);
--> statement-breakpoint
CREATE INDEX `verification_identifier_idx` ON `verification` (`identifier`);--> statement-breakpoint
+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;
+76 -76
View File
@@ -1,7 +1,7 @@
{
"version": "6",
"dialect": "sqlite",
"id": "5d6a1e8b-5b30-4c01-8044-1db2d04615e0",
"id": "426c9bf5-ccbd-473a-a819-11f8b9252494",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"account": {
@@ -14,57 +14,57 @@
"notNull": true,
"autoincrement": false
},
"accountId": {
"name": "accountId",
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"providerId": {
"name": "providerId",
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"userId": {
"name": "userId",
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"accessToken": {
"name": "accessToken",
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"refreshToken": {
"name": "refreshToken",
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"idToken": {
"name": "idToken",
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"accessTokenExpiresAt": {
"name": "accessTokenExpiresAt",
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"refreshTokenExpiresAt": {
"name": "refreshTokenExpiresAt",
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
@@ -84,15 +84,15 @@
"notNull": false,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updatedAt": {
"name": "updatedAt",
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
@@ -103,18 +103,18 @@
"account_user_idx": {
"name": "account_user_idx",
"columns": [
"userId"
"user_id"
],
"isUnique": false
}
},
"foreignKeys": {
"account_userId_user_id_fk": {
"name": "account_userId_user_id_fk",
"account_user_id_user_id_fk": {
"name": "account_user_id_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"userId"
"user_id"
],
"columnsTo": [
"id"
@@ -253,22 +253,22 @@
"notNull": false,
"autoincrement": false
},
"publicKey": {
"name": "publicKey",
"public_key": {
"name": "public_key",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"userId": {
"name": "userId",
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"credentialID": {
"name": "credentialID",
"credential_id": {
"name": "credential_id",
"type": "text",
"primaryKey": false,
"notNull": true,
@@ -281,15 +281,15 @@
"notNull": true,
"autoincrement": false
},
"deviceType": {
"name": "deviceType",
"device_type": {
"name": "device_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"backedUp": {
"name": "backedUp",
"backed_up": {
"name": "backed_up",
"type": "integer",
"primaryKey": false,
"notNull": true,
@@ -309,8 +309,8 @@
"notNull": false,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
@@ -318,28 +318,28 @@
}
},
"indexes": {
"passkey_credentialID_unique": {
"name": "passkey_credentialID_unique",
"passkey_credential_id_unique": {
"name": "passkey_credential_id_unique",
"columns": [
"credentialID"
"credential_id"
],
"isUnique": true
},
"passkey_user_idx": {
"name": "passkey_user_idx",
"columns": [
"userId"
"user_id"
],
"isUnique": false
}
},
"foreignKeys": {
"passkey_userId_user_id_fk": {
"name": "passkey_userId_user_id_fk",
"passkey_user_id_user_id_fk": {
"name": "passkey_user_id_user_id_fk",
"tableFrom": "passkey",
"tableTo": "user",
"columnsFrom": [
"userId"
"user_id"
],
"columnsTo": [
"id"
@@ -352,8 +352,8 @@
"uniqueConstraints": {},
"checkConstraints": {}
},
"rateLimit": {
"name": "rateLimit",
"rate_limit": {
"name": "rate_limit",
"columns": {
"id": {
"name": "id",
@@ -376,8 +376,8 @@
"notNull": false,
"autoincrement": false
},
"lastRequest": {
"name": "lastRequest",
"last_request": {
"name": "last_request",
"type": "integer",
"primaryKey": false,
"notNull": false,
@@ -407,43 +407,43 @@
"notNull": true,
"autoincrement": false
},
"userId": {
"name": "userId",
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expiresAt": {
"name": "expiresAt",
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"ipAddress": {
"name": "ipAddress",
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"userAgent": {
"name": "userAgent",
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updatedAt": {
"name": "updatedAt",
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
@@ -461,18 +461,18 @@
"session_user_idx": {
"name": "session_user_idx",
"columns": [
"userId"
"user_id"
],
"isUnique": false
}
},
"foreignKeys": {
"session_userId_user_id_fk": {
"name": "session_userId_user_id_fk",
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"userId"
"user_id"
],
"columnsTo": [
"id"
@@ -509,8 +509,8 @@
"notNull": true,
"autoincrement": false
},
"emailVerified": {
"name": "emailVerified",
"email_verified": {
"name": "email_verified",
"type": "integer",
"primaryKey": false,
"notNull": true,
@@ -532,15 +532,15 @@
"autoincrement": false,
"default": "'member'"
},
"createdAt": {
"name": "createdAt",
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updatedAt": {
"name": "updatedAt",
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
@@ -585,22 +585,22 @@
"notNull": true,
"autoincrement": false
},
"expiresAt": {
"name": "expiresAt",
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updatedAt": {
"name": "updatedAt",
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
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
+23 -2
View File
@@ -5,8 +5,29 @@
{
"idx": 0,
"version": "6",
"when": 1788726280857,
"tag": "0000_clean_sumo",
"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
}
]
+193
View File
@@ -0,0 +1,193 @@
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { expect, test, type Page } from '@playwright/test';
/**
* Captures the screenshots used by docs/ANLEITUNG.md.
*
* Reproducible on purpose: whenever the UI changes, `pnpm screenshots`
* refreshes every picture in the manual from the same seeded data.
*/
const SHOT_DIR = path.resolve(process.cwd(), 'docs/bilder');
function shotPath(name: string) {
fs.mkdirSync(SHOT_DIR, { recursive: true });
return path.join(SHOT_DIR, `${name}.png`);
}
async function shoot(page: Page, name: string) {
// Let fonts and images settle so the picture is not half-rendered.
await page.waitForLoadState('networkidle');
await page.screenshot({ path: shotPath(name), fullPage: true });
}
/** Small wrapper so every navigation waits the same way. */
async function visit(page: Page, url: string) {
await page.goto(url, { waitUntil: 'domcontentloaded' });
}
test('Screenshots fuer die Anleitung', async ({ page, browser }) => {
// --- 1. registration ------------------------------------------------------
await visit(page, '/registrieren');
await expect(page.getByText('Erstes Konto anlegen')).toBeVisible();
await shoot(page, '01-registrierung');
await page.getByLabel('Name').fill('Florian');
await page.getByLabel('E-Mail').fill('papa@bricktrack.local');
await page.getByLabel('Passwort').fill('anleitung-passwort');
await page.getByRole('button', { name: 'Konto anlegen' }).click();
await expect(page.getByRole('heading', { name: 'Hallo Florian' })).toBeVisible();
// Example data needs the user to exist first (the wishlist has an owner).
execFileSync('node', ['node_modules/tsx/dist/cli.mjs', 'scripts/seed.ts'], {
stdio: 'inherit',
env: { ...process.env, DATA_DIR: './data-shots' },
});
// --- 2. dashboard ---------------------------------------------------------
await visit(page, '/');
await shoot(page, '02-start');
// --- 3. master data -------------------------------------------------------
await visit(page, '/verwaltung/hersteller');
await expect(page.getByText('Lego', { exact: true })).toBeVisible();
await shoot(page, '03-verwaltung-hersteller');
// --- 4. catalog -----------------------------------------------------------
await visit(page, '/katalog');
await expect(page.getByText('Familienhaus und Elektroauto').first()).toBeVisible();
await shoot(page, '04-katalog');
// --- 5. missing sets, selection mode --------------------------------------
await visit(page, '/katalog?besitz=fehlt');
await expect(page.getByRole('heading', { name: 'Fehlende Sets' })).toBeVisible();
await shoot(page, '05-fehlende-sets');
await page.getByRole('button', { name: 'Auswählen' }).click();
await expect(page.getByText('Tippe die Sets an')).toBeVisible();
// 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();
await shoot(page, '06-mehrfachauswahl');
await page.getByRole('button', { name: 'Zur Wunschliste hinzufügen' }).click();
await expect(page.getByText('Sets auf die Wunschliste')).toBeVisible();
await page.screenshot({ path: shotPath('07-wunschliste-dialog') });
await page.getByRole('button', { name: 'Abbrechen' }).click();
// --- 6. set detail --------------------------------------------------------
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 shoot(page, '08-set-detail');
// --- 7. collection --------------------------------------------------------
await visit(page, '/sammlung');
await expect(page.getByText('Wert nach UVP').first()).toBeVisible();
await shoot(page, '09-sammlung');
// --- 8. wishlist ----------------------------------------------------------
await visit(page, '/wunschlisten');
await expect(page.getByText('Weihnachten 2026').first()).toBeVisible();
await shoot(page, '10-wunschlisten');
const listHref = await page
.getByRole('link', { name: /Weihnachten 2026/ })
.getAttribute('href');
await visit(page, listHref!);
await expect(page.getByRole('heading', { name: 'Weihnachten 2026' })).toBeVisible();
await shoot(page, '11-wunschliste-bearbeiten');
const shareUrl = await page.getByLabel('Link zum Teilen').inputValue();
// --- 9. public view, as a visitor without an account ----------------------
const visitorContext = await browser.newContext({ locale: 'de-DE' });
const visitor = await visitorContext.newPage();
await visitor.setViewportSize({ width: 412, height: 915 });
await visit(visitor, shareUrl);
await expect(visitor.getByRole('heading', { name: 'Weihnachten 2026' })).toBeVisible();
await shoot(visitor, '12-oeffentliche-wunschliste');
await visitor.getByRole('button', { name: 'Das schenke ich' }).first().click();
await visitor.getByLabel('Dein Name').fill('Tante Anne');
await visitor.screenshot({ path: shotPath('13-reservieren') });
await visitor.getByRole('button', { name: 'Reservieren' }).click();
await expect(visitor.getByText('Reservierung gespeichert')).toBeVisible();
await visitor.getByRole('button', { name: 'Alles klar' }).click();
await shoot(visitor, '14-reserviert');
await visitorContext.close();
// --- 10. the owner sees the reservation -----------------------------------
await page.reload();
await shoot(page, '15-wunschliste-reserviert');
// --- 11. account and passkeys --------------------------------------------
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');
});
+163
View File
@@ -0,0 +1,163 @@
import { expect, test } from '@playwright/test';
/**
* The one path that has to work: register, put a set into the catalog, and
* share it on a wishlist that a visitor can reserve from without an account.
*/
test('Registrieren, Set anlegen, Wunschliste teilen und reservieren', async ({ page, browser }) => {
// --- first run: the very first visitor registers and becomes admin --------
await page.goto('/');
await expect(page).toHaveURL(/\/registrieren/);
// Card titles are plain elements, not headings - hence getByText here.
await expect(page.getByText('Erstes Konto anlegen')).toBeVisible();
await page.getByLabel('Name').fill('Florian');
await page.getByLabel('E-Mail').fill('test@bricktrack.local');
await page.getByLabel('Passwort').fill('smoketest-passwort');
await page.getByRole('button', { name: 'Konto anlegen' }).click();
await expect(page.getByRole('heading', { name: 'Hallo Florian' })).toBeVisible();
// --- master data: a set needs a manufacturer -----------------------------
await page.goto('/verwaltung/hersteller');
await page.getByRole('button', { name: 'Hersteller anlegen' }).click();
await page.getByLabel('Name').fill('Lego');
await page.getByRole('button', { name: 'Speichern' }).click();
await expect(page.getByText('Lego')).toBeVisible();
// --- catalog: create a set ------------------------------------------------
await page.goto('/katalog/neu');
await page.getByLabel('Setnummer').fill('60398');
await page.getByRole('textbox', { name: 'Name', exact: true }).fill('Familienhaus und Elektroauto');
await page.getByLabel('Jahr').fill('2023');
await page.getByLabel('UVP').fill('49,99');
await page.getByRole('button', { name: 'Set anlegen' }).click();
await expect(page.getByRole('heading', { name: 'Familienhaus und Elektroauto' })).toBeVisible();
await expect(page.getByText('Fehlt noch')).toBeVisible();
// --- wishlist: put the set on a fresh list --------------------------------
await page.getByRole('button', { name: 'Auf Wunschliste' }).click();
await page.getByRole('combobox', { name: 'Wunschliste' }).selectOption('__neu__');
await page.getByLabel('Titel der neuen Liste').fill('Weihnachten 2026');
await page.getByRole('button', { name: 'Hinzufügen' }).click();
// Wait for the confirmation, not for the button label that is on screen anyway.
await expect(page.getByText('Auf die Wunschliste gesetzt.')).toBeVisible();
// --- sharing: grab the public link ---------------------------------------
await page.goto('/wunschlisten');
// Navigate by href: a toast can still be on screen and would swallow the click.
const listHref = await page
.getByRole('link', { name: /Weihnachten 2026/ })
.getAttribute('href');
expect(listHref).toMatch(/^\/wunschlisten\//);
await page.goto(listHref!);
await expect(page.getByRole('heading', { name: 'Weihnachten 2026' })).toBeVisible();
const shareUrl = await page.getByLabel('Link zum Teilen').inputValue();
expect(shareUrl).toContain('/w/');
// --- the visitor: no account, no session ---------------------------------
const visitorContext = await browser.newContext();
const visitor = await visitorContext.newPage();
await visitor.goto(shareUrl);
await expect(visitor.getByRole('heading', { name: 'Weihnachten 2026' })).toBeVisible();
await expect(visitor.getByText('Familienhaus und Elektroauto')).toBeVisible();
await visitor.getByRole('button', { name: 'Das schenke ich' }).click();
await visitor.getByLabel('Dein Name').fill('Tante Anne');
await visitor.getByRole('button', { name: 'Reservieren' }).click();
await expect(visitor.getByText('Reservierung gespeichert')).toBeVisible();
await visitor.getByRole('button', { name: 'Alles klar' }).click();
await expect(visitor.getByText('Das schenkst du')).toBeVisible();
// --- back on the owner side: the reservation shows up ---------------------
await page.reload();
await expect(page.getByText('Reserviert von Tante Anne')).toBeVisible();
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/);
});
test('Ein unbekannter Share-Token liefert 404', async ({ page }) => {
const response = await page.goto('/w/dieser-token-existiert-nicht');
expect(response?.status()).toBe(404);
});
+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/**",
+11 -3
View File
@@ -11,7 +11,7 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"db:generate": "drizzle-kit generate",
"db:migrate": "tsx scripts/migrate.ts",
@@ -19,23 +19,29 @@
"db:studio": "drizzle-kit studio",
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test",
"screenshots": "playwright test --config=playwright.screenshots.config.ts"
"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",
"react-dom": "19.1.0",
"server-only": "^0.0.1",
"sharp": "^0.35.4",
"sonner": "^2.0.8",
"tw-animate-css": "^1.4.0",
@@ -47,10 +53,12 @@
"@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",
"drizzle-kit": "^0.31.10",
"esbuild": "^0.25.12",
"eslint": "^9",
"eslint-config-next": "15.5.25",
"shadcn": "^4.21.0",
+58
View File
@@ -0,0 +1,58 @@
import path from 'node:path';
import { defineConfig, devices } from '@playwright/test';
/**
* Smoke test setup.
*
* Runs against the production container with its own data directory
* (./data-e2e), so a test never touches the real database and never depends on
* the dev server compiling fast enough.
*/
const PORT = 3210;
const BASE_URL = `http://localhost:${PORT}`;
// Docker wants forward slashes, also on Windows.
const dataDir = path.resolve(process.cwd(), 'data-e2e').replace(/\\/g, '/');
export default defineConfig({
testDir: './e2e',
// The screenshot run has its own config, its own server and its own data
// directory - it must never join the smoke run.
testMatch: 'smoke.spec.ts',
fullyParallel: false,
workers: 1,
retries: 0,
timeout: 90_000,
expect: { timeout: 15_000 },
reporter: process.env.CI ? 'github' : 'list',
use: {
baseURL: BASE_URL,
trace: 'retain-on-failure',
locale: 'de-DE',
timezoneId: 'Europe/Berlin',
},
projects: [
{
name: 'mobile',
// The app is built for phones first, so the smoke test uses one.
use: { ...devices['Pixel 7'] },
},
],
webServer: {
command: [
'docker run --rm --name bricktrack-e2e',
`-p ${PORT}:3000`,
`-e BASE_URL=${BASE_URL}`,
'-e BETTER_AUTH_SECRET=playwright-smoke-test-secret-0123456789abcdef',
'-e TZ=Europe/Berlin',
'-e MAX_UPLOAD_MB=25',
`-v "${dataDir}:/data"`,
'bricktrack:e2e',
].join(' '),
url: `${BASE_URL}/api/health`,
reuseExistingServer: false,
timeout: 180_000,
stdout: 'pipe',
stderr: 'pipe',
},
});
+56
View File
@@ -0,0 +1,56 @@
import path from 'node:path';
import { defineConfig, devices } from '@playwright/test';
/**
* Produces the screenshots for docs/ANLEITUNG.md.
*
* Runs against the production container, not the dev server: the dev server
* compiles routes on demand, which made the capture run flaky, and the manual
* should show exactly what a user gets after `docker compose up`.
*
* pnpm screenshots
*/
const PORT = 3220;
const BASE_URL = `http://localhost:${PORT}`;
// Docker wants forward slashes, also on Windows.
const dataDir = path.resolve(process.cwd(), 'data-shots').replace(/\\/g, '/');
export default defineConfig({
testDir: './e2e',
testMatch: 'screenshots.spec.ts',
fullyParallel: false,
workers: 1,
// The run registers a user, so a second attempt would start from a state
// that no longer matches the script.
retries: 0,
timeout: 300_000,
expect: { timeout: 30_000 },
reporter: 'list',
use: {
baseURL: BASE_URL,
locale: 'de-DE',
timezoneId: 'Europe/Berlin',
// A phone, because that is how the app is actually used.
...devices['Pixel 7'],
},
webServer: {
command: [
'docker run --rm --name bricktrack-shots',
`-p ${PORT}:3000`,
`-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(' '),
url: `${BASE_URL}/api/health`,
reuseExistingServer: false,
timeout: 180_000,
stdout: 'pipe',
stderr: 'pipe',
},
});
+225 -14
View File
@@ -10,10 +10,16 @@ importers:
dependencies:
'@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.28.2)(jiti@2.7.0)(tsx@4.23.13))))(better-call@1.4.0(zod@4.5.4))(nanostores@1.5.3)
version: 1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.7.3(better-sqlite3@13.0.3)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3)(kysely@0.29.5))(next@15.5.25(@babel/core@7.29.7)(@playwright/test@1.63.0)(@types/node@20.19.43)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vitest@5.0.0(@types/node@20.19.43)(vite@8.2.2(@types/node@20.19.43)(esbuild@0.25.12)(jiti@2.7.0)(tsx@4.23.13))))(better-call@1.4.0(zod@4.5.4))(nanostores@1.5.3)
'@zxing/browser':
specifier: ^0.2.1
version: 0.2.1(@zxing/library@0.23.0)
'@zxing/library':
specifier: ^0.23.0
version: 0.23.0
better-auth:
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.28.2)(jiti@2.7.0)(tsx@4.23.13)))
version: 1.7.3(better-sqlite3@13.0.3)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3)(kysely@0.29.5))(next@15.5.25(@babel/core@7.29.7)(@playwright/test@1.63.0)(@types/node@20.19.43)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vitest@5.0.0(@types/node@20.19.43)(vite@8.2.2(@types/node@20.19.43)(esbuild@0.25.12)(jiti@2.7.0)(tsx@4.23.13)))
better-sqlite3:
specifier: ^13.0.3
version: 13.0.3
@@ -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,12 @@ 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
radix-ui:
specifier: ^1.6.7
version: 1.6.7(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@@ -47,6 +62,9 @@ importers:
react-dom:
specifier: 19.1.0
version: 19.1.0(react@19.1.0)
server-only:
specifier: ^0.0.1
version: 0.0.1
sharp:
specifier: ^0.35.4
version: 0.35.4(@types/node@20.19.43)
@@ -75,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
@@ -87,6 +108,9 @@ importers:
drizzle-kit:
specifier: ^0.31.10
version: 0.31.10
esbuild:
specifier: ^0.25.12
version: 0.25.12
eslint:
specifier: ^9
version: 9.39.5(jiti@2.7.0)
@@ -107,7 +131,7 @@ importers:
version: 5.9.3
vitest:
specifier: ^5.0.0
version: 5.0.0(@types/node@20.19.43)(vite@8.2.2(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13))
version: 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))
packages:
@@ -1083,6 +1107,81 @@ packages:
'@cfworker/json-schema':
optional: true
'@napi-rs/canvas-android-arm64@1.0.8':
resolution: {integrity: sha512-5+nkh8i3gt6lqS/d2jTZ1xAn6tdgtB4Lf1mW6T0Qm5/rXNwBuV1sAEyLEWan5o9gJPU/GuvHR3rvSeZ+FaGrbw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [android]
'@napi-rs/canvas-darwin-arm64@1.0.8':
resolution: {integrity: sha512-7jQ47gi+fZ7KJmfc/5rNyy1CYw/cu4kZ0KPIYbo9UUgSdW0bKQJpt+WihEor6s4Lyp7+xc3a+3HeyXmAEbbnPg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@napi-rs/canvas-darwin-x64@1.0.8':
resolution: {integrity: sha512-rRjDMZs9pIRKGxgijwezplKc1RnJsqUokrA9h88bbTkqQ+7ePj0ZN4ZnZDy8Vu0tXs7KRlI2tQLaK4mx9QlxHg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@napi-rs/canvas-linux-arm-gnueabihf@1.0.8':
resolution: {integrity: sha512-jGcCd+8ra6Q61xKqZeiItujTpp9a9eRLcQ0jW6qYNku+WpupqOPFPY0SrsuSnXFviJwkpKYT9p7QrB4lsf3LNQ==}
engines: {node: '>= 10'}
cpu: [arm]
os: [linux]
'@napi-rs/canvas-linux-arm64-gnu@1.0.8':
resolution: {integrity: sha512-od6I2Y7kU7i1SwZYG2EKW8rWz6JiedtPpko4WEe1DDsiikrfaotVBCRaUTM5/yeZKaZ92EatoAS+5xG+6uJlYA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@napi-rs/canvas-linux-arm64-musl@1.0.8':
resolution: {integrity: sha512-yYkPbJDJiWj6N0gASA3CAvRypZmVpJnxU0DQg3aBhneLDQde9TPLKADsQkobNoJUtTT/lj46aWpzT48PDb3Qcg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@napi-rs/canvas-linux-riscv64-gnu@1.0.8':
resolution: {integrity: sha512-PB00MSKAp4VwK/xwe6duKxRKmH8UH4GIl1pqHSbxng0jnU9Dr7FwaDypDiqwNFZ774N+8G7mJLGuLtg9NTcQsg==}
engines: {node: '>= 10'}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@napi-rs/canvas-linux-x64-gnu@1.0.8':
resolution: {integrity: sha512-TWM2XWJoitLiIPCvgJh7SriC+L/T9qkYCVzC66AidsZy0QP1hkKzBzVwshCdcA3q6fIn3yE0ISbq4lMJSy8jFw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@napi-rs/canvas-linux-x64-musl@1.0.8':
resolution: {integrity: sha512-hb20MxKXXb5IB7AAwN8UHz9WRsa2HmdZfjsDCzjElwJoeV1aotVEwFU4FrFQcYQVzsJQLeaCc/2Qdt/0Q72mMg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@napi-rs/canvas-win32-arm64-msvc@1.0.8':
resolution: {integrity: sha512-WwPN08IXE4SkL+FhJyPz/iFnycMAUkbphFIT4cmKLlvbSU0Zfn1R7BGJ3Hqky1S89QUYc0Q4IOScXb/42Re9wQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@napi-rs/canvas-win32-x64-msvc@1.0.8':
resolution: {integrity: sha512-XkrVqKb+pxyba7kjy2LJvABFVBTE0DNpEl7MrG4OYUmaWarrXH+t54z/Czj2YxCKtizYTV4mg6phNm3x24qjhQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
'@napi-rs/canvas@1.0.8':
resolution: {integrity: sha512-/SaLcvlqGWdm0HSCWMgHu7cjJiQXfP8/mOY+6dUyV9flQz7sPBBZ+ed2zYtoukojPmxOaL7bm+d/G4GeWWoN7g==}
engines: {node: '>= 10'}
'@napi-rs/wasm-runtime@1.2.3':
resolution: {integrity: sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==}
engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0}
@@ -2169,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:
@@ -2373,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'}
@@ -3246,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'}
@@ -4027,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'}
@@ -4171,6 +4292,10 @@ packages:
path-to-regexp@8.4.2:
resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==}
pdfjs-dist@6.3.289:
resolution: {integrity: sha512-ZHjSVpDa3D6izMq8/04lvkhkATUmL9px6ChPaXc1k6nU2Mrhlg1/7F0bdUqCwUjw3NsPTfPZsMDUU6ZIcRaeQw==}
engines: {node: '>=22.13.0 || >=24'}
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -4417,6 +4542,9 @@ packages:
resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==}
engines: {node: '>= 18'}
server-only@0.0.1:
resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==}
set-cookie-parser@3.1.2:
resolution: {integrity: sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==}
@@ -4654,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==}
@@ -5155,14 +5287,14 @@ snapshots:
'@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-auth/passkey@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.28.2)(jiti@2.7.0)(tsx@4.23.13))))(better-call@1.4.0(zod@4.5.4))(nanostores@1.5.3)':
'@better-auth/passkey@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)':
dependencies:
'@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
'@simplewebauthn/browser': 13.3.0
'@simplewebauthn/server': 13.3.3
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.28.2)(jiti@2.7.0)(tsx@4.23.13)))
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
zod: 4.5.4
@@ -5698,6 +5830,54 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@napi-rs/canvas-android-arm64@1.0.8':
optional: true
'@napi-rs/canvas-darwin-arm64@1.0.8':
optional: true
'@napi-rs/canvas-darwin-x64@1.0.8':
optional: true
'@napi-rs/canvas-linux-arm-gnueabihf@1.0.8':
optional: true
'@napi-rs/canvas-linux-arm64-gnu@1.0.8':
optional: true
'@napi-rs/canvas-linux-arm64-musl@1.0.8':
optional: true
'@napi-rs/canvas-linux-riscv64-gnu@1.0.8':
optional: true
'@napi-rs/canvas-linux-x64-gnu@1.0.8':
optional: true
'@napi-rs/canvas-linux-x64-musl@1.0.8':
optional: true
'@napi-rs/canvas-win32-arm64-msvc@1.0.8':
optional: true
'@napi-rs/canvas-win32-x64-msvc@1.0.8':
optional: true
'@napi-rs/canvas@1.0.8':
optionalDependencies:
'@napi-rs/canvas-android-arm64': 1.0.8
'@napi-rs/canvas-darwin-arm64': 1.0.8
'@napi-rs/canvas-darwin-x64': 1.0.8
'@napi-rs/canvas-linux-arm-gnueabihf': 1.0.8
'@napi-rs/canvas-linux-arm64-gnu': 1.0.8
'@napi-rs/canvas-linux-arm64-musl': 1.0.8
'@napi-rs/canvas-linux-riscv64-gnu': 1.0.8
'@napi-rs/canvas-linux-x64-gnu': 1.0.8
'@napi-rs/canvas-linux-x64-musl': 1.0.8
'@napi-rs/canvas-win32-arm64-msvc': 1.0.8
'@napi-rs/canvas-win32-x64-msvc': 1.0.8
optional: true
'@napi-rs/wasm-runtime@1.2.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
dependencies:
'@emnapi/core': 1.10.0
@@ -6783,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
@@ -6954,17 +7138,32 @@ snapshots:
'@unrs/resolver-binding-win32-x64-msvc@1.12.2':
optional: true
'@vitest/mocker@5.0.0(vite@8.2.2(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13))':
'@vitest/mocker@5.0.0(vite@8.2.2(@types/node@20.19.43)(esbuild@0.25.12)(jiti@2.7.0)(tsx@4.23.13))':
dependencies:
'@jridgewell/trace-mapping': 0.3.31
'@vitest/spy': 5.0.0
estree-walker: 3.0.3
magic-string: 1.2.3
optionalDependencies:
vite: 8.2.2(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)
vite: 8.2.2(@types/node@20.19.43)(esbuild@0.25.12)(jiti@2.7.0)(tsx@4.23.13)
'@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
@@ -7115,7 +7314,7 @@ snapshots:
baseline-browser-mapping@2.11.21: {}
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.28.2)(jiti@2.7.0)(tsx@4.23.13))):
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))):
dependencies:
'@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/drizzle-adapter': 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)(drizzle-orm@0.45.2(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3)(kysely@0.29.5))
@@ -7141,7 +7340,7 @@ snapshots:
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: 19.1.0
react-dom: 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.28.2)(jiti@2.7.0)(tsx@4.23.13))
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))
transitivePeerDependencies:
- '@cloudflare/workers-types'
- '@opentelemetry/api'
@@ -7955,6 +8154,8 @@ snapshots:
optionalDependencies:
picomatch: 4.0.7
fflate@0.8.3: {}
figures@6.1.0:
dependencies:
is-unicode-supported: 2.1.0
@@ -8621,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
@@ -8782,6 +8985,10 @@ snapshots:
path-to-regexp@8.4.2: {}
pdfjs-dist@6.3.289:
optionalDependencies:
'@napi-rs/canvas': 1.0.8
picocolors@1.1.1: {}
picomatch@2.3.2: {}
@@ -9111,6 +9318,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
server-only@0.0.1: {}
set-cookie-parser@3.1.2: {}
set-function-length@1.2.2:
@@ -9410,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
@@ -9564,7 +9775,7 @@ snapshots:
vary@1.1.2: {}
vite@8.2.2(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13):
vite@8.2.2(@types/node@20.19.43)(esbuild@0.25.12)(jiti@2.7.0)(tsx@4.23.13):
dependencies:
lightningcss: 1.33.0
picomatch: 4.0.7
@@ -9573,15 +9784,15 @@ snapshots:
tinyglobby: 0.2.17
optionalDependencies:
'@types/node': 20.19.43
esbuild: 0.28.2
esbuild: 0.25.12
fsevents: 2.3.3
jiti: 2.7.0
tsx: 4.23.13
vitest@5.0.0(@types/node@20.19.43)(vite@8.2.2(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)):
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)):
dependencies:
'@types/chai': 5.2.3
'@vitest/mocker': 5.0.0(vite@8.2.2(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13))
'@vitest/mocker': 5.0.0(vite@8.2.2(@types/node@20.19.43)(esbuild@0.25.12)(jiti@2.7.0)(tsx@4.23.13))
chai: 6.2.2
es-module-lexer: 2.3.2
expect-type: 1.4.0
@@ -9592,7 +9803,7 @@ snapshots:
tinybench: 6.1.4
tinyexec: 1.3.0
tinyglobby: 0.2.17
vite: 8.2.2(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)
vite: 8.2.2(@types/node@20.19.43)(esbuild@0.25.12)(jiti@2.7.0)(tsx@4.23.13)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 20.19.43
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)');
+28
View File
@@ -0,0 +1,28 @@
/**
* Prepares the screenshot run.
*
* The pictures are taken against the production image rather than the dev
* server: the dev server recompiles routes on demand, which made the capture
* run flaky, and the manual should show what people actually run.
*/
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
const dataDir = path.resolve(process.cwd(), 'data-shots');
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(`[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.');
+480
View File
@@ -0,0 +1,480 @@
/**
* Example data so a fresh instance is not an empty page.
*
* Safe to run more than once: everything is matched by its natural key
* (manufacturer slug, set number) and only inserted when missing.
*
* Run with `pnpm db:seed` after `pnpm db:migrate`.
*/
import path from 'node:path';
import Database from 'better-sqlite3';
import { config as loadEnv } from 'dotenv';
import { eq } from 'drizzle-orm';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import * as schema from '../src/db/schema';
loadEnv({ path: '.env', quiet: true });
const dataDir = path.resolve(process.env.DATA_DIR ?? './data');
const sqlite = new Database(path.join(dataDir, 'db', 'bricktrack.db'));
sqlite.pragma('foreign_keys = ON');
const db = drizzle(sqlite, { schema });
const now = new Date();
const id = () => crypto.randomUUID();
type SeedSet = {
manufacturer: string;
theme: string;
setNumber: string;
name: string;
year: number;
pieceCount: number;
minifigCount: number | null;
retailPriceCents: number;
ageRange: string;
/** 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;
};
};
const MANUFACTURERS = [
{ name: 'Lego', slug: 'lego', website: 'https://www.lego.com/de-de' },
{ name: 'Playmobil', slug: 'playmobil', website: 'https://www.playmobil.de' },
{ name: 'Cobi', slug: 'cobi', website: 'https://cobi.de' },
];
const THEMES: Array<{ manufacturer: string; name: string }> = [
{ manufacturer: 'Lego', name: 'City' },
{ manufacturer: 'Lego', name: 'Technic' },
{ manufacturer: 'Lego', name: 'Ninjago' },
{ manufacturer: 'Playmobil', name: 'Novelmore' },
{ manufacturer: 'Playmobil', name: 'City Action' },
{ manufacturer: 'Cobi', name: 'Historical Collection' },
];
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',
theme: 'City',
setNumber: '60398',
name: 'Familienhaus und Elektroauto',
year: 2023,
pieceCount: 462,
minifigCount: 4,
retailPriceCents: 4999,
ageRange: '6-12',
owned: {
status: 'built',
completeness: 'complete',
location: 'Regal Kinderzimmer',
collector: 'Max',
},
},
{
manufacturer: 'Lego',
theme: 'City',
setNumber: '60321',
name: 'Feuerwehreinsatz',
year: 2022,
pieceCount: 766,
minifigCount: 6,
retailPriceCents: 7999,
ageRange: '6-12',
owned: {
status: 'owned',
completeness: 'missing_parts',
location: 'Kiste Keller 3',
collector: 'Max',
giftedBy: 'Oma Helga',
},
},
{
manufacturer: 'Lego',
theme: 'Technic',
setNumber: '42147',
name: 'Kipplaster',
year: 2022,
pieceCount: 177,
minifigCount: null,
retailPriceCents: 999,
ageRange: '7-14',
owned: {
status: 'disassembled',
completeness: 'unknown',
location: 'Kiste Keller 3',
collector: 'Mia',
},
},
{
manufacturer: 'Lego',
theme: 'Technic',
setNumber: '42172',
name: 'McLaren P1',
year: 2024,
pieceCount: 3893,
minifigCount: null,
retailPriceCents: 44999,
ageRange: '18+',
},
{
manufacturer: 'Lego',
theme: 'Ninjago',
setNumber: '71427',
name: 'Lloyds Elementardrachen',
year: 2023,
pieceCount: 685,
minifigCount: 4,
retailPriceCents: 5999,
ageRange: '8-14',
},
{
manufacturer: 'Lego',
theme: 'Ninjago',
setNumber: '71799',
name: 'Ninjago City Märkte',
year: 2023,
pieceCount: 6163,
minifigCount: 21,
retailPriceCents: 39999,
ageRange: '18+',
},
{
manufacturer: 'Playmobil',
theme: 'Novelmore',
setNumber: '70392',
name: 'Novelmore Festung',
year: 2020,
pieceCount: 293,
minifigCount: 5,
retailPriceCents: 12999,
ageRange: '4-10',
owned: {
status: 'built',
completeness: 'complete',
location: 'Regal Kinderzimmer',
collector: 'Mia',
},
},
{
manufacturer: 'Playmobil',
theme: 'City Action',
setNumber: '70936',
name: 'Feuerwehr-Fahrzeug Ultimate Rescue Truck',
year: 2021,
pieceCount: 178,
minifigCount: 2,
retailPriceCents: 8999,
ageRange: '5-10',
},
{
manufacturer: 'Cobi',
theme: 'Historical Collection',
setNumber: '2-30500',
name: 'Ford Mustang GT',
year: 2022,
pieceCount: 250,
minifigCount: null,
retailPriceCents: 2999,
ageRange: '8+',
},
{
manufacturer: 'Cobi',
theme: 'Historical Collection',
setNumber: '2-30501',
name: 'Feuerwehrauto Magirus',
year: 2023,
pieceCount: 400,
minifigCount: 1,
retailPriceCents: 3999,
ageRange: '8+',
},
];
function seedManufacturers() {
const byName = new Map<string, string>();
for (const entry of MANUFACTURERS) {
const existing = db
.select({ id: schema.manufacturer.id })
.from(schema.manufacturer)
.where(eq(schema.manufacturer.slug, entry.slug))
.get();
if (existing) {
byName.set(entry.name, existing.id);
continue;
}
const newId = id();
db.insert(schema.manufacturer)
.values({ id: newId, ...entry, createdAt: now, updatedAt: now })
.run();
byName.set(entry.name, newId);
}
return byName;
}
function seedThemes(manufacturers: Map<string, string>) {
const byKey = new Map<string, string>();
for (const entry of THEMES) {
const manufacturerId = manufacturers.get(entry.manufacturer);
if (!manufacturerId) continue;
const existing = db
.select({ id: schema.theme.id, name: schema.theme.name })
.from(schema.theme)
.where(eq(schema.theme.manufacturerId, manufacturerId))
.all()
.find((row) => row.name === entry.name);
if (existing) {
byKey.set(`${entry.manufacturer}/${entry.name}`, existing.id);
continue;
}
const newId = id();
db.insert(schema.theme)
.values({
id: newId,
manufacturerId,
name: entry.name,
createdAt: now,
updatedAt: now,
})
.run();
byKey.set(`${entry.manufacturer}/${entry.name}`, newId);
}
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>();
for (const name of LOCATIONS) {
const existing = db
.select({ id: schema.location.id })
.from(schema.location)
.where(eq(schema.location.name, name))
.get();
if (existing) {
byName.set(name, existing.id);
continue;
}
const newId = id();
db.insert(schema.location)
.values({ id: newId, name, createdAt: now, updatedAt: now })
.run();
byName.set(name, newId);
}
return byName;
}
function seedSets(
manufacturers: Map<string, string>,
themes: Map<string, string>,
locations: Map<string, string>,
collectors: Map<string, string>,
) {
const setIds = new Map<string, string>();
for (const entry of SETS) {
const manufacturerId = manufacturers.get(entry.manufacturer);
if (!manufacturerId) continue;
const existing = db
.select({ id: schema.set.id, setNumber: schema.set.setNumber })
.from(schema.set)
.where(eq(schema.set.manufacturerId, manufacturerId))
.all()
.find((row) => row.setNumber === entry.setNumber);
if (existing) {
setIds.set(entry.setNumber, existing.id);
continue;
}
const newId = id();
db.insert(schema.set)
.values({
id: newId,
manufacturerId,
themeId: themes.get(`${entry.manufacturer}/${entry.theme}`) ?? null,
setNumber: entry.setNumber,
name: entry.name,
year: entry.year,
pieceCount: entry.pieceCount,
minifigCount: entry.minifigCount,
ageRange: entry.ageRange,
retailPriceCents: entry.retailPriceCents,
createdAt: now,
updatedAt: now,
})
.run();
setIds.set(entry.setNumber, newId);
if (entry.owned) {
db.insert(schema.collectionItem)
.values({
id: id(),
setId: newId,
status: entry.owned.status,
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,
createdAt: now,
updatedAt: now,
})
.run();
}
}
return setIds;
}
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.');
console.log('[seed] Registriere dich zuerst in der App und starte den Seed danach erneut.');
return;
}
const existing = db
.select({ id: schema.wishlist.id })
.from(schema.wishlist)
.all()
.find((row) => row.id.length > 0);
if (existing) {
console.log('[seed] Es gibt bereits eine Wunschliste - unveraendert gelassen.');
return;
}
const wishlistId = id();
db.insert(schema.wishlist)
.values({
id: wishlistId,
ownerUserId: owner.id,
collectorId: collectors.get('Max') ?? null,
title: 'Weihnachten 2026',
occasion: 'christmas',
eventDate: new Date(2026, 11, 24),
shareToken: Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url'),
isPublic: true,
surpriseMode: false,
note: 'Bitte keine Doppelungen - was reserviert ist, steht schon fest.',
createdAt: now,
updatedAt: now,
})
.run();
const wanted = ['71427', '42172', '70936'];
wanted.forEach((setNumber, index) => {
const setId = setIds.get(setNumber);
if (!setId) return;
db.insert(schema.wishlistItem)
.values({
id: id(),
wishlistId,
setId,
priority: index === 0 ? 1 : 2,
sortOrder: index,
createdAt: now,
})
.onConflictDoNothing()
.run();
});
}
console.log('[seed] Beispieldaten werden eingespielt …');
const manufacturers = seedManufacturers();
const themes = seedThemes(manufacturers);
const locations = seedLocations();
const collectors = seedCollectors();
const setIds = seedSets(manufacturers, themes, locations, collectors);
seedWishlist(setIds, collectors);
console.log(
`[seed] Fertig: ${collectors.size} Sammler, ${manufacturers.size} Hersteller, ` +
`${themes.size} Themen, ${setIds.size} Sets.`,
);
sqlite.close();
@@ -0,0 +1,58 @@
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { SetForm } from '@/components/katalog/set-form';
import { getSetById } from '@/lib/queries/catalog';
import { listManufacturers, listThemes } from '@/lib/queries/master-data';
import { requireUser } from '@/lib/session';
export const metadata: Metadata = { title: 'Set bearbeiten' };
export default async function SetBearbeitenPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
await requireUser();
const { id } = await params;
const set = getSetById(id);
if (!set) notFound();
const manufacturers = listManufacturers().map((row) => ({ id: row.id, name: row.name }));
const themes = listThemes().map((row) => ({
id: row.id,
name: row.name,
manufacturerId: row.manufacturerId,
}));
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Set bearbeiten</h1>
<p className="text-sm text-muted-foreground">
{set.setNumber} · {set.name}
</p>
</div>
<SetForm
manufacturers={manufacturers}
themes={themes}
set={{
id: set.id,
manufacturerId: set.manufacturerId,
themeId: set.themeId,
setNumber: set.setNumber,
name: set.name,
year: set.year,
pieceCount: set.pieceCount,
minifigCount: set.minifigCount,
ean: set.ean,
ageRange: set.ageRange,
imageUrl: set.imageUrl,
imagePath: set.imagePath,
retailPriceCents: set.retailPriceCents,
notes: set.notes,
}}
/>
</div>
);
}
+231
View File
@@ -0,0 +1,231 @@
import type { Metadata } from 'next';
import Link from 'next/link';
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';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { formatNumber } from '@/lib/format';
import { setImageSrc } from '@/lib/images';
import { formatEuro } from '@/lib/money';
import {
getSetById,
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 }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
};
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, searchParams }: Props) {
await requireUser();
const { id } = await params;
const query = await searchParams;
// 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);
const owned = set.ownedCopies > 0;
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]> = [
['Hersteller', set.manufacturerName],
['Thema', set.themeName ?? '–'],
['Setnummer', set.setNumber],
['Jahr', set.year ? String(set.year) : '–'],
['Teile', formatNumber(set.pieceCount)],
['Minifiguren', formatNumber(set.minifigCount)],
['Alter', set.ageRange ?? '–'],
['EAN', set.ean ?? '–'],
['UVP', formatEuro(set.retailPriceCents)],
];
return (
<div className="space-y-6">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline" className="font-mono">
{set.setNumber}
</Badge>
<Badge variant="secondary">{set.manufacturerName}</Badge>
{owned ? (
<Badge className="gap-1 bg-emerald-600 text-white hover:bg-emerald-600">
<Check className="size-3" aria-hidden />
{set.ownedCopies > 1 ? `${set.ownedCopies}× vorhanden` : 'Vorhanden'}
</Badge>
) : (
<Badge variant="outline">Fehlt noch</Badge>
)}
{set.wishlistCount > 0 ? (
<Badge variant="secondary" className="gap-1">
<Gift className="size-3" aria-hidden />
Auf Wunschliste
</Badge>
) : null}
</div>
<h1 className="mt-2 text-2xl font-semibold tracking-tight">{set.name}</h1>
</div>
<Button asChild variant="outline" size="icon-lg" aria-label="Set bearbeiten">
<Link href={`/katalog/${set.id}/bearbeiten`}>
<Pencil />
</Link>
</Button>
</div>
<div className="grid gap-6 md:grid-cols-[minmax(0,320px)_1fr]">
<div className="overflow-hidden rounded-xl border border-border bg-muted">
{image ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={image} alt="" className="aspect-square w-full object-contain p-3" />
) : (
<div className="flex aspect-square items-center justify-center text-muted-foreground">
<ImageOff className="size-10" aria-hidden />
</div>
)}
</div>
<div className="space-y-4">
<SetActions
setId={set.id}
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">
{facts.map(([label, value]) => (
<div key={label}>
<dt className="text-xs text-muted-foreground">{label}</dt>
<dd className="font-medium">{value}</dd>
</div>
))}
</dl>
{set.manufacturerWebsite ? (
<Button asChild variant="link" size="sm" className="px-0">
<a href={set.manufacturerWebsite} target="_blank" rel="noreferrer noopener">
Zum Hersteller
<ExternalLink />
</a>
</Button>
) : null}
{set.notes ? (
<p className="rounded-lg bg-muted p-3 text-sm whitespace-pre-line">{set.notes}</p>
) : null}
</div>
</div>
<Card>
<CardHeader>
<CardTitle>In der Sammlung</CardTitle>
</CardHeader>
<CardContent>
<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>
<Card>
<CardHeader>
<CardTitle>Anleitungen</CardTitle>
</CardHeader>
<CardContent>
<InstructionsSection setId={set.id} instructions={listInstructionsForSet(set.id)} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Fotos</CardTitle>
</CardHeader>
<CardContent>
<PhotosSection setId={set.id} photos={listPhotosForSet(set.id)} />
</CardContent>
</Card>
<WishlistMemberships setId={set.id} />
</div>
);
}
function WishlistMemberships({ setId }: { setId: string }) {
const lists = listWishlistsForSet(setId);
if (lists.length === 0) return null;
return (
<Card>
<CardHeader>
<CardTitle>Auf diesen Wunschlisten</CardTitle>
</CardHeader>
<CardContent className="flex flex-wrap gap-2">
{lists.map((list) => (
<Button key={list.id} asChild variant="outline" size="sm">
<Link href={`/wunschlisten/${list.id}`}>
<Gift />
{list.title}
</Link>
</Button>
))}
</CardContent>
</Card>
);
}
+291
View File
@@ -0,0 +1,291 @@
'use server';
import { revalidatePath } from 'next/cache';
import { and, eq, ne } from 'drizzle-orm';
import { db } from '@/db';
import { instruction, photo, set } from '@/db/schema';
import { actionData, actionError, actionOk, formToObject, parseForm, type ActionResult } from '@/lib/action-result';
import { requireUser } from '@/lib/session';
import { deleteStoredFile, storeImage, storePdf, UploadError } from '@/lib/uploads';
import { optionalText, optionalUrl, setSchema } from '@/lib/validation';
import { z } from 'zod';
function now() {
return new Date();
}
function revalidateSet(id?: string) {
revalidatePath('/katalog');
revalidatePath('/sammlung');
if (id) revalidatePath(`/katalog/${id}`);
}
function fileFrom(formData: FormData, field: string): File | null {
const value = formData.get(field);
return value instanceof File && value.size > 0 ? value : null;
}
// ---------------------------------------------------------------------------
// Sets
// ---------------------------------------------------------------------------
export async function createSet(formData: FormData): Promise<ActionResult<{ id: string }>> {
await requireUser();
const parsed = parseForm(setSchema, formToObject(formData));
if (!parsed.ok) return parsed;
const clash = db
.select({ id: set.id })
.from(set)
.where(
and(
eq(set.manufacturerId, parsed.data.manufacturerId),
eq(set.setNumber, parsed.data.setNumber),
),
)
.get();
if (clash) {
return actionError('Dieses Set gibt es beim gewählten Hersteller schon.');
}
const id = crypto.randomUUID();
let imagePath: string | null = null;
const image = fileFrom(formData, 'image');
if (image) {
try {
imagePath = (await storeImage(image, 'set-image', id)).filePath;
} catch (error) {
if (error instanceof UploadError) return actionError(error.message);
throw error;
}
}
db.insert(set)
.values({
id,
...parsed.data,
imagePath,
createdAt: now(),
updatedAt: now(),
})
.run();
revalidateSet(id);
return actionData({ id });
}
export async function updateSet(id: string, formData: FormData): Promise<ActionResult> {
await requireUser();
const parsed = parseForm(setSchema, formToObject(formData));
if (!parsed.ok) return parsed;
const existing = db.select().from(set).where(eq(set.id, id)).get();
if (!existing) return actionError('Das Set gibt es nicht mehr.');
const clash = db
.select({ id: set.id })
.from(set)
.where(
and(
eq(set.manufacturerId, parsed.data.manufacturerId),
eq(set.setNumber, parsed.data.setNumber),
ne(set.id, id),
),
)
.get();
if (clash) return actionError('Ein anderes Set des Herstellers hat diese Nummer bereits.');
let imagePath = existing.imagePath;
const image = fileFrom(formData, 'image');
if (image) {
try {
const stored = await storeImage(image, 'set-image', id);
await deleteStoredFile(existing.imagePath);
imagePath = stored.filePath;
} catch (error) {
if (error instanceof UploadError) return actionError(error.message);
throw error;
}
} else if (formData.get('removeImage') === 'on') {
await deleteStoredFile(existing.imagePath);
imagePath = null;
}
db.update(set)
.set({ ...parsed.data, imagePath, updatedAt: now() })
.where(eq(set.id, id))
.run();
revalidateSet(id);
return actionOk();
}
export async function deleteSet(id: string): Promise<ActionResult> {
await requireUser();
const existing = db.select().from(set).where(eq(set.id, id)).get();
if (!existing) return actionOk();
const files = [
existing.imagePath,
...db
.select({ path: photo.filePath, thumb: photo.thumbnailPath })
.from(photo)
.where(eq(photo.setId, id))
.all()
.flatMap((row) => [row.path, row.thumb]),
...db
.select({ path: instruction.filePath })
.from(instruction)
.where(eq(instruction.setId, id))
.all()
.map((row) => row.path),
];
// Rows go first: if a file is already gone the delete still has to succeed.
db.delete(set).where(eq(set.id, id)).run();
await Promise.all(files.map((file) => deleteStoredFile(file)));
revalidateSet(id);
return actionOk();
}
// ---------------------------------------------------------------------------
// Instructions
// ---------------------------------------------------------------------------
const instructionUrlSchema = z.object({
label: optionalText(80),
url: optionalUrl,
});
export async function addInstructionPdf(setId: string, formData: FormData): Promise<ActionResult> {
await requireUser();
const file = fileFrom(formData, 'file');
if (!file) return actionError('Bitte wähle eine PDF-Datei aus.');
const parsed = parseForm(z.object({ label: optionalText(80) }), formToObject(formData));
if (!parsed.ok) return parsed;
try {
const stored = await storePdf(file, setId);
db.insert(instruction)
.values({
id: crypto.randomUUID(),
setId,
kind: 'pdf_upload',
filePath: stored.filePath,
fileSizeBytes: stored.fileSizeBytes,
label: parsed.data.label,
sortOrder: nextInstructionSortOrder(setId),
createdAt: now(),
})
.run();
} catch (error) {
if (error instanceof UploadError) return actionError(error.message);
throw error;
}
revalidateSet(setId);
return actionOk();
}
export async function addInstructionUrl(setId: string, input: unknown): Promise<ActionResult> {
await requireUser();
const parsed = parseForm(instructionUrlSchema, input);
if (!parsed.ok) return parsed;
if (!parsed.data.url) return actionError('Bitte gib die Adresse der Anleitung an.');
db.insert(instruction)
.values({
id: crypto.randomUUID(),
setId,
kind: 'external_url',
url: parsed.data.url,
label: parsed.data.label,
sortOrder: nextInstructionSortOrder(setId),
createdAt: now(),
})
.run();
revalidateSet(setId);
return actionOk();
}
export async function deleteInstruction(id: string): Promise<ActionResult> {
await requireUser();
const existing = db.select().from(instruction).where(eq(instruction.id, id)).get();
if (!existing) return actionOk();
db.delete(instruction).where(eq(instruction.id, id)).run();
await deleteStoredFile(existing.filePath);
revalidateSet(existing.setId);
return actionOk();
}
function nextInstructionSortOrder(setId: string): number {
const rows = db
.select({ sortOrder: instruction.sortOrder })
.from(instruction)
.where(eq(instruction.setId, setId))
.all();
return rows.reduce((max, row) => Math.max(max, row.sortOrder), -1) + 1;
}
// ---------------------------------------------------------------------------
// Photos
// ---------------------------------------------------------------------------
export async function addSetPhoto(setId: string, formData: FormData): Promise<ActionResult> {
await requireUser();
const file = fileFrom(formData, 'file');
if (!file) return actionError('Bitte wähle ein Bild aus.');
const parsed = parseForm(z.object({ caption: optionalText(160) }), formToObject(formData));
if (!parsed.ok) return parsed;
try {
const stored = await storeImage(file, 'photo', setId);
db.insert(photo)
.values({
id: crypto.randomUUID(),
setId,
filePath: stored.filePath,
thumbnailPath: stored.thumbnailPath,
caption: parsed.data.caption,
sortOrder: 0,
createdAt: now(),
})
.run();
} catch (error) {
if (error instanceof UploadError) return actionError(error.message);
throw error;
}
revalidateSet(setId);
return actionOk();
}
export async function deletePhoto(id: string): Promise<ActionResult> {
await requireUser();
const existing = db.select().from(photo).where(eq(photo.id, id)).get();
if (!existing) return actionOk();
db.delete(photo).where(eq(photo.id, id)).run();
await Promise.all([
deleteStoredFile(existing.filePath),
deleteStoredFile(existing.thumbnailPath),
]);
if (existing.setId) revalidateSet(existing.setId);
return actionOk();
}
+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,
},
});
}
+68
View File
@@ -0,0 +1,68 @@
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' };
type SearchParams = Promise<Record<string, string | string[] | undefined>>;
export default async function NeuesSetPage({ searchParams }: { searchParams: SearchParams }) {
await requireUser();
const params = await searchParams;
const ean = typeof params.ean === 'string' ? params.ean : undefined;
const manufacturers = listManufacturers().map((row) => ({ id: row.id, name: row.name }));
const themes = listThemes().map((row) => ({
id: row.id,
name: row.name,
manufacturerId: row.manufacturerId,
}));
const importEnabled = isRebrickableEnabled();
if (manufacturers.length === 0) {
return (
<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">
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">
<Link href="/verwaltung/hersteller">Hersteller anlegen</Link>
</Button>
</div>
);
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Neues Set</h1>
<p className="text-sm text-muted-foreground">
Ein Set im Katalog heißt noch nicht, dass es da ist – das legst du danach über „Zur
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>
);
}
+100
View File
@@ -0,0 +1,100 @@
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';
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';
export const metadata: Metadata = { title: 'Katalog' };
type SearchParams = Promise<Record<string, string | string[] | undefined>>;
function single(value: string | string[] | undefined): string | undefined {
const raw = Array.isArray(value) ? value[0] : value;
return raw && raw.length > 0 ? raw : undefined;
}
export default async function KatalogPage({ searchParams }: { searchParams: SearchParams }) {
await requireUser();
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;
const filters = {
q: single(params.q),
manufacturerId: single(params.hersteller),
themeId: single(params.thema),
year: yearParam ? Number(yearParam) : undefined,
ownership,
collectorId,
page,
};
const { rows, total } = listSets(filters);
const manufacturers = listManufacturers().map((row) => ({ id: row.id, name: row.name }));
const themes = listThemes().map((row) => ({
id: row.id,
name: row.name,
manufacturerId: row.manufacturerId,
}));
const wishlists = listWishlistTargets();
return (
<div className="space-y-5">
<div className="flex items-start justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold tracking-tight">
{ownership === 'fehlt' ? 'Fehlende Sets' : 'Katalog'}
</h1>
<p className="text-sm text-muted-foreground">
{ownership === 'fehlt'
? 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>
<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">
{pluralize(total, 'Set', 'Sets')}
{ownership !== 'alle' ? ` · ${OWNERSHIP_FILTER_LABELS[ownership]}` : ''}
</p>
<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 });
}
+45
View File
@@ -0,0 +1,45 @@
'use server';
import { revalidatePath } from 'next/cache';
import { headers } from 'next/headers';
import { z } from 'zod';
import { auth } from '@/lib/auth';
const idSchema = z.object({ id: z.string().min(1) });
const renameSchema = idSchema.extend({ name: z.string().trim().min(1).max(60) });
export type ActionResult = { ok: true } | { ok: false; error: string };
export async function deletePasskeyAction(input: unknown): Promise<ActionResult> {
const parsed = idSchema.safeParse(input);
if (!parsed.success) return { ok: false, error: 'Ungültige Anfrage.' };
try {
await auth.api.deletePasskey({
body: { id: parsed.data.id },
headers: await headers(),
});
} catch {
return { ok: false, error: 'Der Passkey konnte nicht gelöscht werden.' };
}
revalidatePath('/konto');
return { ok: true };
}
export async function renamePasskeyAction(input: unknown): Promise<ActionResult> {
const parsed = renameSchema.safeParse(input);
if (!parsed.success) return { ok: false, error: 'Bitte gib einen Namen an (max. 60 Zeichen).' };
try {
await auth.api.updatePasskey({
body: { id: parsed.data.id, name: parsed.data.name },
headers: await headers(),
});
} catch {
return { ok: false, error: 'Der Passkey konnte nicht umbenannt werden.' };
}
revalidatePath('/konto');
return { ok: true };
}
+62
View File
@@ -0,0 +1,62 @@
import type { Metadata } from 'next';
import { headers } from 'next/headers';
import { PasskeyManager, type PasskeyRow } from '@/components/auth/passkey-manager';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { auth } from '@/lib/auth';
import { requireUser } from '@/lib/session';
export const metadata: Metadata = { title: 'Konto' };
export default async function AccountPage() {
const user = await requireUser();
const passkeys = await auth.api.listPasskeys({ headers: await headers() });
const rows: PasskeyRow[] = passkeys.map((passkey) => ({
id: passkey.id,
name: passkey.name ?? null,
createdAt: passkey.createdAt ? new Date(passkey.createdAt) : null,
}));
return (
<div className="space-y-6">
<h1 className="text-2xl font-semibold tracking-tight">Konto</h1>
<Card>
<CardHeader>
<CardTitle>Profil</CardTitle>
<CardDescription>Diese Angaben stammen aus deiner Registrierung.</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<div className="flex items-center justify-between gap-4">
<span className="text-muted-foreground">Name</span>
<span className="font-medium">{user.name}</span>
</div>
<div className="flex items-center justify-between gap-4">
<span className="text-muted-foreground">E-Mail</span>
<span className="truncate font-medium">{user.email}</span>
</div>
<div className="flex items-center justify-between gap-4">
<span className="text-muted-foreground">Rolle</span>
<Badge variant={user.role === 'admin' ? 'default' : 'secondary'}>
{user.role === 'admin' ? 'Administrator' : 'Mitglied'}
</Badge>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Passkeys</CardTitle>
<CardDescription>
Passkeys sind an diese Domain gebunden und funktionieren nur über HTTPS. Du kannst
beliebig viele Geräte hinterlegen.
</CardDescription>
</CardHeader>
<CardContent>
<PasskeyManager passkeys={rows} />
</CardContent>
</Card>
</div>
);
}
+21
View File
@@ -0,0 +1,21 @@
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 (
<div className="flex min-h-dvh flex-col">
<AppHeader user={user} />
{/* Bottom padding keeps the mobile nav from covering the last row. */}
<main className="mx-auto w-full max-w-5xl flex-1 px-4 pt-4 pb-24 md:pb-10">{children}</main>
<BottomNav />
</div>
);
}
+58
View File
@@ -0,0 +1,58 @@
import Link from 'next/link';
import { Boxes, Gift, LayoutGrid, PackageSearch } from 'lucide-react';
import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { requireUser } from '@/lib/session';
const shortcuts = [
{
href: '/katalog',
icon: LayoutGrid,
title: 'Katalog',
description: 'Alle bekannten Sets durchsuchen und neue anlegen.',
},
{
href: '/katalog?besitz=fehlt',
icon: PackageSearch,
title: 'Fehlende Sets',
description: 'Was noch nicht in der Sammlung ist – direkt auf die Wunschliste.',
},
{
href: '/sammlung',
icon: Boxes,
title: 'Sammlung',
description: 'Alles, was tatsächlich da ist – mit Standort und Zustand.',
},
{
href: '/wunschlisten',
icon: Gift,
title: 'Wunschlisten',
description: 'Listen anlegen und per Link mit Familie und Freunden teilen.',
},
] as const;
export default async function DashboardPage() {
const user = await requireUser();
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Hallo {user.name}</h1>
<p className="text-sm text-muted-foreground">Was möchtest du tun?</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
{shortcuts.map((shortcut) => (
<Link key={shortcut.href} href={shortcut.href} className="group">
<Card className="h-full transition-colors group-hover:border-primary/40">
<CardHeader>
<shortcut.icon className="size-6 text-primary" aria-hidden />
<CardTitle>{shortcut.title}</CardTitle>
<CardDescription>{shortcut.description}</CardDescription>
</CardHeader>
</Card>
</Link>
))}
</div>
</div>
);
}
+234
View File
@@ -0,0 +1,234 @@
'use server';
import { revalidatePath } from 'next/cache';
import { and, eq } from 'drizzle-orm';
import { db } from '@/db';
import { collectionItem, missingPart, set } from '@/db/schema';
import {
actionData,
actionError,
actionOk,
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, missingPartSchema } from '@/lib/validation';
import type { Condition } from '@/db/types';
function now() {
return new Date();
}
function revalidateCollection(setId?: string) {
revalidatePath('/sammlung');
revalidatePath('/katalog');
revalidatePath('/wunschlisten');
revalidatePath('/');
if (setId) revalidatePath(`/katalog/${setId}`);
}
/**
* Quick add from the set detail page: one tap, sensible defaults, details can
* be filled in later.
*/
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(),
updatedAt: now(),
})
.run();
// A wish that just came true is marked as fulfilled right away.
reconcileFulfilledWishlistItems();
revalidateCollection(setId);
return actionData({ id });
}
export async function updateCollectionItem(id: string, input: unknown): Promise<ActionResult> {
await requireUser();
const parsed = parseForm(collectionItemSchema, input);
if (!parsed.ok) return parsed;
const existing = db.select().from(collectionItem).where(eq(collectionItem.id, id)).get();
if (!existing) return actionError('Dieser Sammlungseintrag gibt es nicht mehr.');
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,
locationId: parsed.data.locationId,
acquiredAt: parsed.data.acquiredAt,
acquiredPriceCents: parsed.data.acquiredPriceCents,
acquiredFrom: parsed.data.acquiredFrom,
giftedBy: parsed.data.giftedBy,
lentOutTo: parsed.data.lentOutTo,
notes: parsed.data.notes,
updatedAt: now(),
})
.where(eq(collectionItem.id, id))
.run();
// Selling the last copy puts the set back into "fehlt" - and back onto the
// wishlists as an open wish.
reconcileFulfilledWishlistItems();
revalidateCollection(existing.setId);
return actionOk();
}
export async function deleteCollectionItem(id: string): Promise<ActionResult> {
await requireUser();
const existing = db.select().from(collectionItem).where(eq(collectionItem.id, id)).get();
if (!existing) return actionOk();
db.delete(collectionItem).where(eq(collectionItem.id, id)).run();
reconcileFulfilledWishlistItems();
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();
}
+182
View File
@@ -0,0 +1,182 @@
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';
export const metadata: Metadata = { title: 'Sammlung' };
type SearchParams = Promise<Record<string, string | string[] | undefined>>;
function single(value: string | string[] | undefined): string | undefined {
const raw = Array.isArray(value) ? value[0] : value;
return raw && raw.length > 0 ? raw : undefined;
}
export default async function SammlungPage({ searchParams }: { searchParams: SearchParams }) {
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 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,
// 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;
return (
<div className="space-y-5">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Sammlung</h1>
<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}>
<CardContent className="space-y-1 p-4">
<tile.icon className="size-4 text-muted-foreground" aria-hidden />
<p className="text-xl font-semibold tabular-nums">{tile.value}</p>
<p className="text-xs text-muted-foreground">{tile.label}</p>
</CardContent>
</Card>
))}
</div>
{stats.byManufacturer.length > 0 ? (
<div className="flex flex-wrap gap-2">
{stats.byManufacturer.map((row) => (
<Badge key={row.name} variant="secondary">
{row.name}: {formatNumber(row.count)}
</Badge>
))}
</div>
) : null}
<CollectionFilters
locations={listLocations().map((row) => ({ id: row.id, name: row.name }))}
manufacturers={listManufacturers().map((row) => ({ id: row.id, name: row.name }))}
/>
<p className="text-sm text-muted-foreground">
{pluralize(items.length, 'Eintrag', 'Einträge')}
</p>
{items.length === 0 ? (
<div className="space-y-3 rounded-lg border border-dashed border-border p-8 text-center">
<p className="text-sm text-muted-foreground">
Hier ist noch nichts. Sets kommen über den Katalog in die Sammlung.
</p>
<Link href="/katalog" className="text-sm underline underline-offset-4">
Zum Katalog
</Link>
</div>
) : (
<ul className="divide-y divide-border rounded-lg border border-border">
{items.map((item) => {
const image = setImageSrc(item);
return (
<li key={item.id}>
<Link
href={`/katalog/${item.setId}`}
className="flex items-center gap-3 p-3 transition-colors hover:bg-muted/50"
>
<div className="size-14 shrink-0 overflow-hidden rounded-lg border border-border bg-muted">
{image ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={image} alt="" loading="lazy" className="size-full object-contain" />
) : (
<div className="flex size-full items-center justify-center text-muted-foreground">
<ImageOff className="size-5" aria-hidden />
</div>
)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{item.setName}</p>
<p className="truncate text-xs text-muted-foreground">
{[item.setNumber, item.manufacturerName, item.locationName]
.filter(Boolean)
.join(' · ')}
</p>
<div className="mt-1 flex flex-wrap items-center gap-1">
<Badge variant={item.status === 'sold' ? 'outline' : 'default'}>
{statusLabel(item.status)}
</Badge>
{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)}
</span>
) : null}
</div>
</div>
</Link>
</li>
);
})}
</ul>
)}
</div>
);
}
+236
View File
@@ -0,0 +1,236 @@
'use server';
import { revalidatePath } from 'next/cache';
import { and, eq, ne } from 'drizzle-orm';
import { db } from '@/db';
import { location, manufacturer, set, theme } from '@/db/schema';
import { actionError, actionOk, parseForm, type ActionResult } from '@/lib/action-result';
import { requireUser } from '@/lib/session';
import { uniqueSlug } from '@/lib/slug';
import { locationSchema, manufacturerSchema, themeSchema } from '@/lib/validation';
function now() {
return new Date();
}
function revalidateMasterData() {
revalidatePath('/verwaltung/hersteller');
revalidatePath('/verwaltung/themen');
revalidatePath('/verwaltung/standorte');
revalidatePath('/katalog');
}
// ---------------------------------------------------------------------------
// Manufacturers
// ---------------------------------------------------------------------------
export async function createManufacturer(input: unknown): Promise<ActionResult> {
await requireUser();
const parsed = parseForm(manufacturerSchema, input);
if (!parsed.ok) return parsed;
const taken = db.select({ slug: manufacturer.slug }).from(manufacturer).all();
const exists = db
.select({ id: manufacturer.id })
.from(manufacturer)
.where(eq(manufacturer.name, parsed.data.name))
.get();
if (exists) return actionError('Diesen Hersteller gibt es schon.');
db.insert(manufacturer)
.values({
id: crypto.randomUUID(),
name: parsed.data.name,
slug: uniqueSlug(
parsed.data.name,
taken.map((row) => row.slug),
),
website: parsed.data.website,
createdAt: now(),
updatedAt: now(),
})
.run();
revalidateMasterData();
return actionOk();
}
export async function updateManufacturer(id: string, input: unknown): Promise<ActionResult> {
await requireUser();
const parsed = parseForm(manufacturerSchema, input);
if (!parsed.ok) return parsed;
const clash = db
.select({ id: manufacturer.id })
.from(manufacturer)
.where(and(eq(manufacturer.name, parsed.data.name), ne(manufacturer.id, id)))
.get();
if (clash) return actionError('Ein anderer Hersteller heißt bereits so.');
db.update(manufacturer)
.set({ name: parsed.data.name, website: parsed.data.website, updatedAt: now() })
.where(eq(manufacturer.id, id))
.run();
revalidateMasterData();
return actionOk();
}
export async function deleteManufacturer(id: string): Promise<ActionResult> {
await requireUser();
const used = db.select({ id: set.id }).from(set).where(eq(set.manufacturerId, id)).get();
if (used) {
return actionError(
'Der Hersteller wird noch von mindestens einem Set verwendet und kann deshalb nicht gelöscht werden.',
);
}
db.delete(manufacturer).where(eq(manufacturer.id, id)).run();
revalidateMasterData();
return actionOk();
}
// ---------------------------------------------------------------------------
// Themes
// ---------------------------------------------------------------------------
export async function createTheme(input: unknown): Promise<ActionResult> {
await requireUser();
const parsed = parseForm(themeSchema, input);
if (!parsed.ok) return parsed;
const exists = db
.select({ id: theme.id })
.from(theme)
.where(
and(eq(theme.manufacturerId, parsed.data.manufacturerId), eq(theme.name, parsed.data.name)),
)
.get();
if (exists) return actionError('Dieses Thema gibt es beim Hersteller schon.');
db.insert(theme)
.values({
id: crypto.randomUUID(),
manufacturerId: parsed.data.manufacturerId,
name: parsed.data.name,
parentThemeId: parsed.data.parentThemeId,
createdAt: now(),
updatedAt: now(),
})
.run();
revalidateMasterData();
return actionOk();
}
export async function updateTheme(id: string, input: unknown): Promise<ActionResult> {
await requireUser();
const parsed = parseForm(themeSchema, input);
if (!parsed.ok) return parsed;
if (parsed.data.parentThemeId === id) {
return actionError('Ein Thema kann nicht sein eigenes Oberthema sein.');
}
const clash = db
.select({ id: theme.id })
.from(theme)
.where(
and(
eq(theme.manufacturerId, parsed.data.manufacturerId),
eq(theme.name, parsed.data.name),
ne(theme.id, id),
),
)
.get();
if (clash) return actionError('Ein anderes Thema dieses Herstellers heißt bereits so.');
db.update(theme)
.set({
manufacturerId: parsed.data.manufacturerId,
name: parsed.data.name,
parentThemeId: parsed.data.parentThemeId,
updatedAt: now(),
})
.where(eq(theme.id, id))
.run();
revalidateMasterData();
return actionOk();
}
export async function deleteTheme(id: string): Promise<ActionResult> {
await requireUser();
// Sets keep existing without a theme (ON DELETE SET NULL), but silently
// unthemeing a dozen sets would be a surprise - so we ask for a decision.
const used = db.select({ id: set.id }).from(set).where(eq(set.themeId, id)).get();
if (used) {
return actionError(
'Dem Thema sind noch Sets zugeordnet. Ordne sie zuerst um, dann lässt sich das Thema löschen.',
);
}
db.delete(theme).where(eq(theme.id, id)).run();
revalidateMasterData();
return actionOk();
}
// ---------------------------------------------------------------------------
// Locations
// ---------------------------------------------------------------------------
export async function createLocation(input: unknown): Promise<ActionResult> {
await requireUser();
const parsed = parseForm(locationSchema, input);
if (!parsed.ok) return parsed;
db.insert(location)
.values({
id: crypto.randomUUID(),
name: parsed.data.name,
parentLocationId: parsed.data.parentLocationId,
createdAt: now(),
updatedAt: now(),
})
.run();
revalidatePath('/verwaltung/standorte');
revalidatePath('/sammlung');
return actionOk();
}
export async function updateLocation(id: string, input: unknown): Promise<ActionResult> {
await requireUser();
const parsed = parseForm(locationSchema, input);
if (!parsed.ok) return parsed;
if (parsed.data.parentLocationId === id) {
return actionError('Ein Standort kann nicht in sich selbst liegen.');
}
db.update(location)
.set({
name: parsed.data.name,
parentLocationId: parsed.data.parentLocationId,
updatedAt: now(),
})
.where(eq(location.id, id))
.run();
revalidatePath('/verwaltung/standorte');
revalidatePath('/sammlung');
return actionOk();
}
export async function deleteLocation(id: string): Promise<ActionResult> {
await requireUser();
// Collection items only lose their location (ON DELETE SET NULL).
db.delete(location).where(eq(location.id, id)).run();
revalidatePath('/verwaltung/standorte');
revalidatePath('/sammlung');
return actionOk();
}
+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}
/>
);
}
@@ -0,0 +1,20 @@
import type { Metadata } from 'next';
import { ManufacturerList, type ManufacturerRow } from '@/components/verwaltung/manufacturer-list';
import { pluralize } from '@/lib/format';
import { listManufacturers } from '@/lib/queries/master-data';
export const metadata: Metadata = { title: 'Hersteller' };
export default function HerstellerPage() {
const rows: ManufacturerRow[] = listManufacturers().map((row) => ({
id: row.id,
title: row.name,
subtitle: [pluralize(row.setCount, 'Set', 'Sets'), row.website]
.filter(Boolean)
.join(' · '),
website: row.website,
setCount: row.setCount,
}));
return <ManufacturerList manufacturers={rows} />;
}
+22
View File
@@ -0,0 +1,22 @@
import type { Metadata } from 'next';
import { VerwaltungNav } from '@/components/verwaltung/verwaltung-nav';
import { requireUser } from '@/lib/session';
export const metadata: Metadata = { title: 'Verwaltung' };
export default async function VerwaltungLayout({ children }: { children: React.ReactNode }) {
const user = await requireUser();
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Verwaltung</h1>
<p className="text-sm text-muted-foreground">
Stammdaten, die im Katalog und in der Sammlung ausgewählt werden.
</p>
</div>
<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>
);
}
@@ -0,0 +1,16 @@
import type { Metadata } from 'next';
import { LocationList, type LocationRow } from '@/components/verwaltung/location-list';
import { listLocations } from '@/lib/queries/master-data';
export const metadata: Metadata = { title: 'Standorte' };
export default function StandortePage() {
const rows: LocationRow[] = listLocations().map((row) => ({
id: row.id,
title: row.name,
subtitle: row.parentName ? `in ${row.parentName}` : null,
parentLocationId: row.parentLocationId,
}));
return <LocationList locations={rows} />;
}
+29
View File
@@ -0,0 +1,29 @@
import type { Metadata } from 'next';
import { ThemeList, type ThemeRow } from '@/components/verwaltung/theme-list';
import { listManufacturers, listThemes } from '@/lib/queries/master-data';
export const metadata: Metadata = { title: 'Themen' };
export default function ThemenPage() {
const manufacturers = listManufacturers().map((row) => ({ id: row.id, name: row.name }));
const rows: ThemeRow[] = listThemes().map((row) => ({
id: row.id,
title: row.name,
subtitle: [row.manufacturerName, row.parentName ? `in ${row.parentName}` : null]
.filter(Boolean)
.join(' · '),
manufacturerId: row.manufacturerId,
parentThemeId: row.parentThemeId,
}));
if (manufacturers.length === 0) {
return (
<p className="rounded-lg border border-dashed border-border p-6 text-center text-sm text-muted-foreground">
Themen gehören immer zu einem Hersteller. Leg zuerst einen Hersteller an.
</p>
);
}
return <ThemeList themes={rows} manufacturers={manufacturers} />;
}
+150
View File
@@ -0,0 +1,150 @@
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { CatalogFilters } from '@/components/katalog/catalog-filters';
import { CatalogGrid } from '@/components/katalog/catalog-grid';
import { Pagination } from '@/components/katalog/pagination';
import { ShareCard } from '@/components/wunschlisten/share-card';
import { WishlistHeaderActions } from '@/components/wunschlisten/wishlist-header-actions';
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';
type Props = {
params: Promise<{ id: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
};
function single(value: string | string[] | undefined): string | undefined {
const raw = Array.isArray(value) ? value[0] : value;
return raw && raw.length > 0 ? raw : undefined;
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { id } = await params;
const list = getWishlist(id);
return { title: list?.title ?? 'Wunschliste' };
}
export default async function WunschlisteDetailPage({ params, searchParams }: Props) {
const user = await requireUser();
const { id } = await params;
const query = await searchParams;
const list = getWishlist(id);
if (!list || list.ownerUserId !== user.id) notFound();
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.
const yearParam = single(query.jahr);
const page = Number(single(query.seite) ?? '1') || 1;
const missing = listSets({
q: single(query.q),
manufacturerId: single(query.hersteller),
themeId: single(query.thema),
year: yearParam ? Number(yearParam) : undefined,
ownership: 'fehlt',
excludeWishlistId: list.id,
collectorId: list.collectorId ?? undefined,
page,
});
return (
<div className="space-y-6">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<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">
{pluralize(items.length, 'Wunsch', 'Wünsche')}
</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,
note: list.note,
isPublic: list.isPublic,
surpriseMode: list.surpriseMode,
}}
/>
</div>
<ShareCard
wishlistId={list.id}
shareToken={list.shareToken}
isPublic={list.isPublic}
title={list.title}
/>
{list.note ? (
<p className="rounded-lg bg-muted p-3 text-sm whitespace-pre-line">{list.note}</p>
) : null}
<WishlistItems wishlistId={list.id} items={items} surpriseMode={list.surpriseMode} />
<Card>
<CardHeader>
<CardTitle>Aus fehlenden Sets hinzufügen</CardTitle>
<CardDescription>
{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>
<CardContent className="space-y-4">
<CatalogFilters
options={{
manufacturers: listManufacturers().map((row) => ({ id: row.id, name: row.name })),
themes: listThemes().map((row) => ({
id: row.id,
name: row.name,
manufacturerId: row.manufacturerId,
})),
years: listSetYears(),
}}
/>
<p className="text-sm text-muted-foreground">
{pluralize(missing.total, 'Set kommt infrage', 'Sets kommen infrage')}
</p>
<CatalogGrid
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."
/>
<Pagination page={page} total={missing.total} pageSize={CATALOG_PAGE_SIZE} />
</CardContent>
</Card>
</div>
);
}
+257
View File
@@ -0,0 +1,257 @@
'use server';
import { revalidatePath } from 'next/cache';
import { and, eq, inArray } from 'drizzle-orm';
import { db } from '@/db';
import { set, wishlist, wishlistItem } from '@/db/schema';
import {
actionData,
actionError,
actionOk,
parseForm,
type ActionResult,
} from '@/lib/action-result';
import { ownedCopiesSql } from '@/lib/ownership';
import { defaultCollectorId } from '@/lib/queries/collectors';
import {
findExistingWishlistSetIds,
getWishlist,
nextWishlistSortOrder,
} from '@/lib/queries/wishlists';
import { requireUser } from '@/lib/session';
import { createToken } from '@/lib/tokens';
import { describeBulkAdd, planBulkAdd } from '@/lib/wishlist-logic';
import {
addSetsToWishlistSchema,
wishlistItemSchema,
wishlistSchema,
} from '@/lib/validation';
function now() {
return new Date();
}
function revalidateWishlist(id?: string) {
revalidatePath('/wunschlisten');
revalidatePath('/katalog');
if (id) revalidatePath(`/wunschlisten/${id}`);
}
/** Every wishlist action first proves the current user owns the list. */
async function requireOwnedWishlist(id: string) {
const user = await requireUser();
const list = getWishlist(id);
if (!list || list.ownerUserId !== user.id) return null;
return list;
}
// ---------------------------------------------------------------------------
// Lists
// ---------------------------------------------------------------------------
export async function createWishlist(input: unknown): Promise<ActionResult<{ id: string }>> {
const user = await requireUser();
const parsed = parseForm(wishlistSchema, input);
if (!parsed.ok) return parsed;
const id = crypto.randomUUID();
db.insert(wishlist)
.values({
id,
ownerUserId: user.id,
collectorId: parsed.data.collectorId ?? defaultCollectorId(),
title: parsed.data.title,
occasion: parsed.data.occasion,
eventDate: parsed.data.eventDate,
note: parsed.data.note,
isPublic: parsed.data.isPublic,
surpriseMode: parsed.data.surpriseMode,
shareToken: createToken(),
createdAt: now(),
updatedAt: now(),
})
.run();
revalidateWishlist(id);
return actionData({ id });
}
export async function updateWishlist(id: string, input: unknown): Promise<ActionResult> {
const list = await requireOwnedWishlist(id);
if (!list) return actionError('Diese Wunschliste gibt es nicht.');
const parsed = parseForm(wishlistSchema, input);
if (!parsed.ok) return parsed;
db.update(wishlist)
.set({
collectorId: parsed.data.collectorId,
title: parsed.data.title,
occasion: parsed.data.occasion,
eventDate: parsed.data.eventDate,
note: parsed.data.note,
isPublic: parsed.data.isPublic,
surpriseMode: parsed.data.surpriseMode,
updatedAt: now(),
})
.where(eq(wishlist.id, id))
.run();
revalidateWishlist(id);
return actionOk();
}
export async function deleteWishlist(id: string): Promise<ActionResult> {
const list = await requireOwnedWishlist(id);
if (!list) return actionError('Diese Wunschliste gibt es nicht.');
db.delete(wishlist).where(eq(wishlist.id, id)).run();
revalidateWishlist(id);
return actionOk();
}
/**
* Issues a fresh share token. The old link stops working immediately - the way
* out when a list was shared with the wrong people.
*/
export async function rotateShareToken(id: string): Promise<ActionResult<{ token: string }>> {
const list = await requireOwnedWishlist(id);
if (!list) return actionError('Diese Wunschliste gibt es nicht.');
const token = createToken();
db.update(wishlist)
.set({ shareToken: token, updatedAt: now() })
.where(eq(wishlist.id, id))
.run();
revalidateWishlist(id);
return actionData({ token });
}
// ---------------------------------------------------------------------------
// Items
// ---------------------------------------------------------------------------
/**
* The central path from "fehlende Sets" onto a wishlist.
*
* Sets that are already on the list are skipped and named in the result, and
* so are sets that are already in the collection. The unique index on
* (wishlist_id, set_id) makes that a guarantee rather than a hope.
*/
export async function addSetsToWishlist(
input: unknown,
): Promise<ActionResult<{ added: number; message: string }>> {
await requireUser();
const parsed = parseForm(addSetsToWishlistSchema, input);
if (!parsed.ok) return parsed;
const list = await requireOwnedWishlist(parsed.data.wishlistId);
if (!list) return actionError('Diese Wunschliste gibt es nicht.');
const setIds = parsed.data.setIds;
const known = db
.select({
id: set.id,
name: set.name,
setNumber: set.setNumber,
ownedCopies: ownedCopiesSql(set.id, list.collectorId).mapWith(Number),
})
.from(set)
.where(inArray(set.id, setIds))
.all();
if (known.length === 0) return actionError('Die ausgewählten Sets gibt es nicht mehr.');
const names = new Map(known.map((row) => [row.id, `${row.setNumber} ${row.name}`]));
const ownedSetIds = new Set(known.filter((row) => row.ownedCopies > 0).map((row) => row.id));
const existing = findExistingWishlistSetIds(list.id, setIds);
const plan = planBulkAdd(
setIds.filter((id) => names.has(id)),
existing,
ownedSetIds,
);
if (plan.toAdd.length > 0) {
let sortOrder = nextWishlistSortOrder(list.id);
db.insert(wishlistItem)
.values(
plan.toAdd.map((setId) => ({
id: crypto.randomUUID(),
wishlistId: list.id,
setId,
priority: parsed.data.priority,
sortOrder: sortOrder++,
createdAt: now(),
})),
)
// Belt and braces: a concurrent add must not blow up the whole batch.
.onConflictDoNothing()
.run();
}
revalidateWishlist(list.id);
return actionData({ added: plan.toAdd.length, message: describeBulkAdd(plan, names) });
}
export async function updateWishlistItem(itemId: string, input: unknown): Promise<ActionResult> {
const user = await requireUser();
const parsed = parseForm(wishlistItemSchema, input);
if (!parsed.ok) return parsed;
const item = db
.select({ id: wishlistItem.id, wishlistId: wishlistItem.wishlistId })
.from(wishlistItem)
.innerJoin(wishlist, eq(wishlist.id, wishlistItem.wishlistId))
.where(and(eq(wishlistItem.id, itemId), eq(wishlist.ownerUserId, user.id)))
.get();
if (!item) return actionError('Dieser Eintrag gehört nicht zu deinen Wunschlisten.');
db.update(wishlistItem)
.set({ priority: parsed.data.priority, note: parsed.data.note })
.where(eq(wishlistItem.id, itemId))
.run();
revalidateWishlist(item.wishlistId);
return actionOk();
}
export async function removeWishlistItem(itemId: string): Promise<ActionResult> {
const user = await requireUser();
const item = db
.select({ id: wishlistItem.id, wishlistId: wishlistItem.wishlistId })
.from(wishlistItem)
.innerJoin(wishlist, eq(wishlist.id, wishlistItem.wishlistId))
.where(and(eq(wishlistItem.id, itemId), eq(wishlist.ownerUserId, user.id)))
.get();
if (!item) return actionOk();
db.delete(wishlistItem).where(eq(wishlistItem.id, itemId)).run();
revalidateWishlist(item.wishlistId);
return actionOk();
}
/** Persists the order after drag and drop or the move buttons. */
export async function reorderWishlistItems(
wishlistId: string,
orderedItemIds: string[],
): Promise<ActionResult> {
const list = await requireOwnedWishlist(wishlistId);
if (!list) return actionError('Diese Wunschliste gibt es nicht.');
db.transaction((tx) => {
orderedItemIds.forEach((itemId, index) => {
tx.update(wishlistItem)
.set({ sortOrder: index })
.where(and(eq(wishlistItem.id, itemId), eq(wishlistItem.wishlistId, wishlistId)))
.run();
});
});
revalidateWishlist(wishlistId);
return actionOk();
}
+96
View File
@@ -0,0 +1,96 @@
import type { Metadata } from 'next';
import Link from 'next/link';
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';
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">
<div className="flex items-start justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Wunschlisten</h1>
<p className="text-sm text-muted-foreground">
Zum Teilen mit Familie und Freunden – ganz ohne Konto für die anderen.
</p>
</div>
<NewWishlistButton
collectors={collectors.map((row) => ({ id: row.id, name: row.name }))}
/>
</div>
{lists.length === 0 ? (
<div className="space-y-2 rounded-lg border border-dashed border-border p-8 text-center">
<p className="text-sm text-muted-foreground">
Noch keine Wunschliste. Der schnellste Weg: im Katalog auf „Fehlt noch“ filtern,
mehrere Sets auswählen und daraus eine Liste anlegen.
</p>
<Link href="/katalog?besitz=fehlt" className="text-sm underline underline-offset-4">
Zu den fehlenden Sets
</Link>
</div>
) : (
<ul className="grid gap-3 sm:grid-cols-2">
{lists.map((list) => (
<li key={list.id}>
<Link href={`/wunschlisten/${list.id}`} className="group block h-full">
<Card className="h-full transition-colors group-hover:border-primary/40">
<CardHeader>
<div className="flex items-center gap-2">
<Gift className="size-5 text-primary" aria-hidden />
<CardTitle className="truncate">{list.title}</CardTitle>
</div>
<CardDescription>
{occasionLabel(list.occasion)}
{list.eventDate ? ` · ${formatDate(list.eventDate)}` : ''}
</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>
) : list.itemCount > 0 ? (
<Badge className="bg-emerald-600 text-white hover:bg-emerald-600">
Alles vergeben
</Badge>
) : null}
{list.isPublic ? null : (
<Badge variant="outline" className="gap-1">
<Link2Off className="size-3" aria-hidden />
Link deaktiviert
</Badge>
)}
{list.eventDate ? (
<span className="ml-auto flex items-center gap-1 text-muted-foreground">
<CalendarDays className="size-3" aria-hidden />
{formatDate(list.eventDate)}
</span>
) : null}
</CardContent>
</Card>
</Link>
</li>
))}
</ul>
)}
</div>
);
}
+38
View File
@@ -0,0 +1,38 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { Suspense } from 'react';
import { LoginForm } from '@/components/auth/login-form';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { isFirstRun } from '@/lib/auth';
import { getSession } from '@/lib/session';
export const metadata: Metadata = { title: 'Anmelden' };
export default async function LoginPage() {
if (await getSession()) redirect('/');
// Nobody registered yet: send the very first visitor straight to sign-up.
if (isFirstRun()) redirect('/registrieren');
return (
<Card>
<CardHeader>
<CardTitle>Anmelden</CardTitle>
<CardDescription>Melde dich an, um die Sammlung zu verwalten.</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<Suspense fallback={<Skeleton className="h-64 w-full" />}>
<LoginForm />
</Suspense>
<p className="text-center text-xs text-muted-foreground">
Noch kein Konto?{' '}
<Link href="/registrieren" className="underline underline-offset-4">
Registrierung
</Link>
</p>
</CardContent>
</Card>
);
}
@@ -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>
);
}
+17
View File
@@ -0,0 +1,17 @@
import Link from 'next/link';
import { Blocks } from 'lucide-react';
export default function AuthLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex min-h-dvh flex-col items-center justify-center gap-8 px-4 py-10">
<Link href="/" className="flex items-center gap-2 text-xl font-semibold tracking-tight">
<Blocks className="size-6 text-primary" aria-hidden />
Bricktrack
</Link>
<main className="w-full max-w-sm">{children}</main>
<p className="text-center text-xs text-muted-foreground">
Die Sammlung und die Wunschlisten deiner Familie – auf deinem eigenen Server.
</p>
</div>
);
}

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