34 Commits
Author SHA1 Message Date
unknown 76af719fac Merge: Mailserver in der App einrichten 2026-10-25 22:47:40 +01:00
unknownandClaude Opus 5.5 ebab5627de docs: mailserver in der app in anleitung und changelog
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 22:40:36 +01:00
unknownandClaude Opus 5.5 01bbb3ba9a feat(erinnerungen): mailserver in der app einrichten
SMTP-Server, Port, Verschluesselung, Zugang und Absender unter Mehr ->
Erinnerungen, gespeichert in der neuen Tabelle app_setting. Das Passwort
liegt mit AES-256-GCM verschluesselt (Schluessel aus BETTER_AUTH_SECRET),
wird nie angezeigt und nicht exportiert. Die .env hat weiter Vorrang. Der
Zeitplan laeuft jetzt immer und prueft je Durchlauf, ob ein Server da ist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 22:40:36 +01:00
unknown ebdd6c313a Merge: Mehr-Button beschriften 2026-10-25 21:54:19 +01:00
unknownandClaude Opus 5.5 7d75e4345a fix(navigation): mehr-button beschriften statt nur initialen
Das Kaestchen mit den Initialen oben rechts war als Weg zu Konto, Design
und Verwaltung nicht zu erkennen. Jetzt steht dort "Mehr" mit Symbol.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 21:54:18 +01:00
unknown 7d7711eb8d Merge: Neue Navigation mit Plus, Mehr und Sammler-Umschalter 2026-10-25 20:34:46 +01:00
unknown a8d933bf5b Merge: Set aus einem Link übernehmen 2026-10-25 20:34:46 +01:00
unknownandClaude Opus 5.5 295da414bb docs: neue navigation in anleitung, schnellstart und screenshots
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 18:50:13 +01:00
unknownandClaude Opus 5.5 d5ffcbae60 feat(navigation): plus in der mitte, mehr-seite und sammler-umschalter
Unten Start, Katalog, Sammlung, Wuensche und ein Plus mit allen Wegen zu
einem neuen Set. Konto, Design und Stammdaten auf der Seite "Mehr" statt
im Konto-Menue und in sieben Reitern. Bei mehreren Sammlern waehlt "Fuer
wen?" in der Kopfzeile das Kind fuer die ganze App, gemerkt pro Geraet.
Behebt nebenbei, dass Mitglieder die Stammdaten nicht erreichten.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 18:50:13 +01:00
unknownandClaude Opus 5.5 d578eac743 docs: link-import in anleitung, changelog und screenshots
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 16:03:00 +01:00
unknownandClaude Opus 5.5 95a28640e1 feat(katalog): set aus einem link uebernehmen
Amazon, idealo, geizhals, Hersteller-Shops und Set-Datenbanken: Produkt-
daten aus der Seite (schema.org, Open Graph, Amazon-Produktseite), sonst
aus dem Link selbst, ergaenzt ueber Rebrickable (Lego) und Merlins Bricks
(BlueBrixx, Cobi). Fuellt das Formular vor, das Bild wird beim Speichern
lokal abgelegt. Abrufe nur an oeffentliche Adressen (safeFetch).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 16:03:00 +01:00
unknown 498a5c6226 Merge: Release 0.4.0 - Design und Sicherheit 2026-10-25 15:39:55 +01:00
unknownandClaude Opus 5.5 78ae755761 chore(release): version 0.4.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 15:39:55 +01:00
unknownandClaude Opus 5.5 62045468ce fix(deps): abhaengigkeiten mit sicherheitsluecken aktualisieren
Next.js 15.5.28, nodemailer 10.2, better-auth 1.7.12, sharp 0.35.5,
zod 4.6.7 und drizzle-orm 0.45.4. Transitive Pakete der
Entwicklungswerkzeuge (postcss, vite, shadcn-CLI) per pnpm.overrides.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 15:39:55 +01:00
unknown bac948fcb8 Merge: Designauswahl mit Klemmbaustein-Look 2026-10-25 15:26:27 +01:00
unknown 2d3ddc4dae Merge: update.sh ausfuehrbar einchecken 2026-10-25 15:26:27 +01:00
unknownandClaude Opus 5.5 604a8ac113 fix(sammlung): status-badge gruen statt in der aktionsfarbe
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 15:26:27 +01:00
unknownandClaude Opus 5.5 f2304b28e5 fix(update): update.sh als ausfuehrbar einchecken
Bisher lag das Skript mit Modus 100644 im Repo. Ein chmod +x auf dem
Server galt dann als lokale Aenderung, und update.sh brach vor dem Pull
mit "Es gibt lokale Aenderungen" ab.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 15:20:53 +01:00
unknownandClaude Opus 5.5 d009230dc2 docs: anleitung, changelog und screenshots zum neuen design
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 15:01:33 +01:00
unknownandClaude Opus 5.5 1b825b44c4 feat(design): designauswahl mit klemmbaustein-look als standard
Sechs Designs (Klemmbaustein, Warm, Nachtblau, Bonbon, Bauklotz,
Schlicht) als Tokens je data-design, gewaehlt unter Konto und pro Geraet
im Cookie gespeichert. Dazu Noppen, Steinkontur, Platzhalter fuer Sets
ohne Bild, neues Logo, lokal gebuendelte Schriften und eine Startseite
mit Steinreihe und naechstem Termin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 15:01:33 +01:00
unknownandClaude Opus 5 e9093d0fad Merge: Phase 3 - Nice to have (v0.3.0)
Mehrere Sammler mit gemeinsamem Katalog und getrennter Sammlung,
Preisbeobachtung mit Wertentwicklung, E-Mail-Erinnerungen vor
Wunschlisten-Terminen und die Dublettenpruefung beim Anlegen von Sets.

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

Dazu zwei Korrekturen aus dem Testlauf:

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

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

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

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

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

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

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

Refs Phase 3

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

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

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

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

Refs Phase 3

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

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

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

Refs Phase 3

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

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

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

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

Refs Phase 3

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

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

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

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

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

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

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

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

Refs Phase 2

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

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

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

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

Refs Phase 2

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

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

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

Refs Phase 2

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

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

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

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

Refs Phase 2

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

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

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

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

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

Refs Phase 2

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

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

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

Refs Phase 2

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 07:01:55 +02:00
189 changed files with 20089 additions and 1361 deletions
+17 -2
View File
@@ -27,6 +27,21 @@ TZ=Europe/Berlin
# BIND_ADDRESS=127.0.0.1 # BIND_ADDRESS=127.0.0.1
# HOST_PORT=3000 # HOST_PORT=3000
# Optional (ab Phase 2): Rebrickable-API-Key fuer den Lego-Import. # Optional: Rebrickable-API-Key fuer den Lego-Import. Damit reicht auf der
# Ohne Key funktioniert alles manuell weiter. # Seite "Neues Set" die Setnummer, und Name, Jahr, Teile, Minifiguren, Thema
# und Bild werden uebernommen. Ohne Key funktioniert alles manuell weiter.
# Kostenlos anlegen: https://rebrickable.com/api/ (Konto -> Settings -> API)
# REBRICKABLE_API_KEY= # REBRICKABLE_API_KEY=
# Optional: Mailversand fuer Erinnerungen an Wunschlisten-Termine. Einfacher
# geht es in der App unter Mehr -> Erinnerungen -> Mailserver. Steht der
# Server hier, hat die .env Vorrang und die App zeigt ihn nur an.
# SMTP_HOST=mail.example.de
# SMTP_PORT=587
# SMTP_SECURE=false
# SMTP_USER=bricktrack@example.de
# SMTP_PASSWORD=
# SMTP_FROM=Bricktrack <bricktrack@example.de>
# Wie viele Tage vor dem Termin erinnert wird (Standard: 14).
# REMINDER_DAYS_BEFORE=14
+2
View File
@@ -23,6 +23,8 @@ next-env.d.ts
/data-shots /data-shots
/data-docker /data-docker
/backups /backups
/data-test
/data-test-export
# production # production
*.tsbuildinfo *.tsbuildinfo
+191 -1
View File
@@ -7,6 +7,193 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/).
## [Unreleased] ## [Unreleased]
### Hinzugefügt
- **Mailserver in der App einrichten**: Unter Mehr → Erinnerungen → Mailserver
lassen sich Server, Port, Verschlüsselung, Zugang und Absender eintragen –
ohne `.env` und ohne Neustart. Das Passwort wird verschlüsselt gespeichert
(AES-256-GCM, Schlüssel aus `BETTER_AUTH_SECRET`), nie wieder angezeigt und
nicht exportiert. Steht der Server in der `.env`, hat sie weiter Vorrang.
- **Set aus einem Link übernehmen**, für alle Hersteller: Amazon, idealo,
geizhals, Hersteller-Shops, Brickfact oder Merlins Bricks. Bricktrack liest
Produktdaten (schema.org, Open Graph, bei Amazon die Produktseite) und, wenn
die Seite blockt, den Link selbst. Lego wird über Rebrickable ergänzt,
BlueBrixx und Cobi über Merlins Bricks. Das Ergebnis füllt das Formular vor,
das Bild wird beim Speichern lokal abgelegt.
### Geändert
- Der Zeitplan für Erinnerungen läuft immer und prüft bei jedem Durchlauf, ob
ein Mailserver eingerichtet ist. Vorher wurde er nur gestartet, wenn SMTP
schon beim Containerstart in der `.env` stand.
- **Neue Navigation**: unten Start, Katalog, Sammlung, Wünsche und in der
Mitte ein +-Stein mit allen Wegen zu einem neuen Set (Scannen, aus Link,
Lego-Nummer, von Hand).
- **„Mehr" statt Konto-Menü und Verwaltungsreitern**: Konto, Design,
Sammler, Einladungen, Erinnerungen, Hersteller, Themen, Standorte und
Export/Import als Kacheln in Gruppen, hinter dem Button „Mehr" oben rechts.
- **„Für wen?" in der Kopfzeile**: Bei mehreren Sammlern wird das Kind einmal
oben gewählt und gilt für Startseite, Katalog und Sammlung. Jedes Gerät
merkt sich die Wahl; ein `?sammler=` im Link hat weiterhin Vorrang.
### Behoben
- Mitglieder (nicht Admin) kamen nicht an Sammler, Hersteller, Themen und
Standorte heran, obwohl sie diese pflegen dürfen: Der Eintrag
„Verwaltung" stand nur im Menü des Administrators.
### Sicherheit
- Abrufe im Auftrag eines Nutzers gehen nur an öffentliche Adressen: Jede
Adresse wird vor dem Verbindungsaufbau geprüft, Weiterleitungen ebenso,
Größe und Zeit sind begrenzt.
## [0.4.0] - 2026-10-25
Ein neues Gesicht: Bricktrack sieht jetzt aus wie das, was es verwaltet.
Dazu alle bekannten Sicherheitslücken in den Abhängigkeiten geschlossen.
### Sicherheit
- Next.js 15.5.28: schließt unter anderem eine Denial-of-Service-Lücke über
Server Actions und Cache-Poisoning bei selbst gehosteten Instanzen. Die
kritische Lücke in der Bildoptimierung war hier nicht ausnutzbar
(`images.unoptimized`), ist aber ebenfalls behoben.
- nodemailer 10.2: mehrere Fehler im Adressparser (Erinnerungs-Mails).
- better-auth 1.7.12, sharp 0.35.5 (librsvg), zod 4.6.7 (Speicherverbrauch
bei übergroßen Eingaben), drizzle-orm 0.45.4.
- Entwicklungswerkzeuge: vitest, vite, postcss, Playwright und die
shadcn-CLI aktualisiert, transitive Pakete per `pnpm.overrides` angehoben.
Übrig bleibt ein mittlerer Fund in einem alten esbuild, das drizzle-kit
intern mitbringt - nur beim Entwickeln, nicht im Image.
### Hinzugefügt
- **Designauswahl** unter Konto → Design: Klemmbaustein (neuer Standard),
Warm, Nachtblau (dunkel), Bonbon, Bauklotz und Schlicht (der bisherige
Look). Die Wahl gilt pro Gerät und wirkt sofort.
- **Platzhalter für Sets ohne Bild**: Farbe des Herstellers, gezeichneter
Stein und Setnummer statt eines grauen „Bild fehlt".
- **Startseite als Überblick**: Steinreihe für den Fortschritt (bei mehreren
Sammlern in deren Farben), Teile, Wert, nächster Wunschlisten-Termin mit
Countdown und Abkürzungen.
- Neues Logo als Baustein, Schriften lokal gebündelt (kein CDN).
### Geändert
- Karten, Buttons und Badges tragen im Standard-Design Konturen, eine feste
Unterkante und Noppen. In der Sammlung und auf der öffentlichen
Wunschliste zeigen die Noppen den Sammler bzw. ob ein Wunsch reserviert ist.
- Die öffentliche Wunschliste hat einen farbigen Kopf mit Anlass und Datum.
- Screenshots der Anleitung zeigen die feste Navigation nicht mehr mitten
im Bild.
## [0.3.0] - 2026-09-07
Phase 3: mehrere Kinder, Wertentwicklung, Erinnerungen und ein Schutz vor
doppelten Katalogeintraegen.
### Hinzugefuegt
**Mehrere Sammler**
- Der Katalog bleibt gemeinsam, Sammlung und Wunschlisten gehoeren je einem
Sammler. "Hat er das schon?" beantwortet damit die Frage fuer *ihn*: ein Set,
das nur die Schwester besitzt, faellt fuer den Bruder unter "fehlt noch".
- Umschalter in Katalog und Sammlung, Farbmarkierung je Sammler, Zuordnung im
Sammlungseintrag und je Wunschliste.
- Solange es nur einen Sammler gibt, ist davon nichts zu sehen - die App
bleibt genauso einfach wie vorher.
**Preisbeobachtung**
- Notierte Preise je Set mit Betrag, Zustand, Datum, Quelle und Notiz.
- Wertentwicklung gegen die UVP, in der Sammlung als Kachel "Aktueller Wert".
**Erinnerungen**
- E-Mail an den Besitzer, wenn der Termin einer Wunschliste naeher rueckt und
noch Wuensche offen sind - standardmaessig 14 Tage vorher, hoechstens einmal
pro Woche je Liste.
- Verwaltungsseite mit Verbindungstest, Testnachricht und sofortiger Pruefung.
- Ohne SMTP-Konfiguration bleibt die Funktion vollstaendig aus.
**Dublettenpruefung**
- Vor dem Anlegen eines Sets warnt die App vor gleicher Setnummer beim
gleichen Hersteller, sehr aehnlichem Namen und gleicher Nummer bei einem
anderen Hersteller - mit Link zum Verdaechtigen und der Moeglichkeit,
trotzdem anzulegen.
### Geaendert
- Die Migration legt einen Sammler "Sammlung" an und ordnet ihm alle
vorhandenen Sammlungseintraege und Wunschlisten zu. Umbenennen unter
Verwaltung -> Sammler.
- Der Smoke-Test laeuft gegen das Docker-Image statt gegen den Dev-Server.
### Nicht umgesetzt
- Der BrickLink-Preisfuehrer war im Plan als optional vermerkt und wurde
bewusst weggelassen: OAuth1 mit einer pro Nutzer registrierten Anwendung ist
viel Apparat fuer eine Zahl, die sich in zehn Sekunden eintippen laesst. Das
Feld "Wo" nimmt jede Quelle auf, die Datenstruktur bleibt offen.
## [0.2.0] - 2026-09-07
Phase 2: die Handgriffe, die den Alltag schneller machen.
### Hinzugefuegt
**Lego-Import (Rebrickable)**
- Auf "Neues Set" reicht die Setnummer von der Packung: Name, Jahr, Teile,
Minifiguren und Thema werden uebernommen, das Bild lokal gespeichert.
- Hersteller und Thema werden bei Bedarf angelegt, ein Oberthema inklusive.
- Alle Aufrufe laufen serverseitig. Ohne `REBRICKABLE_API_KEY` bleibt der
Import sichtbar, aber abgeschaltet - von Hand anlegen geht unveraendert.
**Barcode-Scanner**
- Knopf im Katalog: Strichcode mit der Handykamera scannen. Ist das Set
bekannt, oeffnet es sich; sonst das Formular mit ausgefuellter EAN.
- Jeder Code wird gegen seine Pruefziffer geprueft; dekodiert wird im
Browser, nichts verlaesst das Geraet.
**Fehlteile**
- Fehlende Teile je Sammlungseintrag mit Teilenummer, Farbe, Anzahl und
Notiz, abhakbar wenn nachgekauft.
- Die Vollstaendigkeit pflegt sich daraus selbst: der erste offene Eintrag
setzt "Teile fehlen", der letzte erledigte zurueck auf "vollstaendig".
**Einladungen**
- Der Admin erzeugt Registrierungslinks, die genau einmal funktionieren,
optional an eine E-Mail-Adresse gebunden, mit waehlbarer Rolle und
Gueltigkeit. In der Datenbank liegt nur der Hash des Tokens.
**Export und Import**
- Alles als ZIP (Daten plus hochgeladene Dateien), nur die Daten als JSON,
die Sammlung als CSV mit Semikolon und BOM fuer deutsches Excel.
- Import ersetzt die Anwendungsdaten in einer Transaktion, nur fuer
Administratoren und nur nach getipptem ERSETZEN.
- Nicht im Export: Konten, Passwoerter, Passkeys, Reservierungen und
Teilen-Links.
**PWA**
- Manifest, Icons und Service Worker: installierbar auf dem Startbildschirm,
mit Verknuepfungen zu "Fehlende Sets" und zur Sammlung.
- Einmal geladene Seiten bleiben ohne Verbindung lesbar; Set-Bilder und
Build-Dateien kommen aus dem Cache. Beim Abmelden wird der Cache geraeumt.
### Geaendert
- `src/env.ts` behandelt leere Werte wie nicht gesetzt: ein `BASE_URL=` in der
`.env` fuehrte bisher zum Start-Abbruch statt zum Standardwert.
- Unit-Tests koennen jetzt auch Server-Module laden (`server-only` wird in der
Testumgebung durch ein leeres Modul ersetzt).
### Bekannte Einschraenkungen
- Rebrickable liefert keine Anleitungsdateien. Der Import legt stattdessen
einen Link auf die LEGO-Serviceseite zur Setnummer an.
- Der Barcode-Scanner braucht wie Passkeys HTTPS.
- Beim Import wandern Wunschlisten an den importierenden Admin und sind
zunaechst nicht geteilt.
## [0.1.0] - 2026-09-07 ## [0.1.0] - 2026-09-07
Erste lauffaehige Fassung: Phase 1 vollstaendig, im Docker-Stack verifiziert. Erste lauffaehige Fassung: Phase 1 vollstaendig, im Docker-Stack verifiziert.
@@ -88,5 +275,8 @@ Erste lauffaehige Fassung: Phase 1 vollstaendig, im Docker-Stack verifiziert.
- Unter Windows scheitert `pnpm build` an fehlenden Symlink-Rechten. Der - Unter Windows scheitert `pnpm build` an fehlenden Symlink-Rechten. Der
Docker-Build unter Linux ist davon nicht betroffen. Docker-Build unter Linux ist davon nicht betroffen.
[Unreleased]: https://git.wilde-server.de/florian.wilde/bricktrack/compare/v0.1.0...HEAD [Unreleased]: https://git.wilde-server.de/florian.wilde/bricktrack/compare/v0.4.0...HEAD
[0.4.0]: https://git.wilde-server.de/florian.wilde/bricktrack/releases/tag/v0.4.0
[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 [0.1.0]: https://git.wilde-server.de/florian.wilde/bricktrack/releases/tag/v0.1.0
+72 -4
View File
@@ -19,6 +19,9 @@ teilbare Wunschlisten.
| Validierung | zod (serverseitig, immer) | | | Validierung | zod (serverseitig, immer) | |
| Bilder | sharp (nur serverseitig, Thumbnails) | | | Bilder | sharp (nur serverseitig, Thumbnails) | |
| Tests | Vitest (Unit), Playwright (E2E, Screenshots) | | | Tests | Vitest (Unit), Playwright (E2E, Screenshots) | |
| PDF | pdfjs-dist, lokal gebuendelt | kein CDN |
| Barcode | @zxing/browser, lazy geladen | nur beim Scannen |
| Archive | fflate | Export/Import als ZIP |
| Paketmanager | pnpm 10 | | | Paketmanager | pnpm 10 | |
Bewusst **kein** Prisma (Standalone-Build im Container), **keine** Bewusst **kein** Prisma (Standalone-Build im Container), **keine**
@@ -43,6 +46,39 @@ Bild-URLs, die der Nutzer selbst hinterlegt hat.
- Commits: Conventional Commits, Betreff auf Deutsch, Imperativ, - Commits: Conventional Commits, Betreff auf Deutsch, Imperativ,
erste Zeile <= 72 Zeichen. erste Zeile <= 72 Zeichen.
## Wichtige Module
| Datei | Zustaendig fuer |
|---|---|
| `src/lib/ownership.ts` | die eine Definition von "hat er" / "hat er nicht" |
| `src/lib/wishlist-logic.ts` | Mehrfachauswahl, Dubletten, Prioritaeten (rein, testbar) |
| `src/lib/tokens.ts` | Share-, Storno- und Einladungs-Token |
| `src/lib/invites.ts` | Einladungen inklusive der Freigabe fuer die Registrierung |
| `src/lib/rebrickable.ts` | Lego-Import, ausschliesslich serverseitig |
| `src/lib/set-number.ts`, `src/lib/ean.ts` | Setnummern und Barcodes (rein, testbar) |
| `src/lib/uploads.ts` | Datei-Uploads, Magic Bytes, Thumbnails |
| `src/lib/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/mail-settings.ts`, `src/lib/secret-box.ts` | SMTP aus .env oder App (`app_setting`), Passwort verschluesselt |
| `src/lib/export.ts` | Export und Import des gesamten Bestands |
| `src/lib/money.ts`, `src/lib/format.ts` | Cent-Umrechnung, dd.MM.yyyy |
| `src/components/layout/more-items.ts` | Eintraege der Seite „Mehr" (Konto, Design, Stammdaten), auch Titel der Verwaltungsseiten |
| `src/lib/collector-choice.ts` | Aktiver Sammler: `?sammler=` vor Cookie aus dem Umschalter „Fuer wen?" |
| `src/lib/link-import.ts` | Set-Daten aus Link und Seite lesen (rein, testbar) |
| `src/lib/safe-fetch.ts`, `src/lib/net-guard.ts` | Abruf fremder URLs nur an oeffentliche Adressen |
| `src/lib/design.ts`, `src/app/designs.css` | Designs: Namen und Cookie bzw. Tokens je `data-design` |
| `src/components/brick/` | Noppen, Logo, Platzhalter fuer Sets ohne Bild |
### Designs
Das Design steht als `data-design` am `<html>` (Cookie `bricktrack-design`,
pro Geraet, Standard `klemmbaustein`). Komponenten benutzen nur Tokens
(`bg-primary`, `border-edge`, `--edge-w`, `--lift`, `bg-tone-1` ...), nie feste
Farben - sonst stimmt ein Design nicht mehr. Karten mit Noppen: `<Studs />`
neben die Karte in einen `relative`-Wrapper, die Karte selbst mit `brick`.
## Befehle ## Befehle
```bash ```bash
@@ -58,8 +94,22 @@ pnpm db:studio # Drizzle Studio
pnpm test # Vitest pnpm test # Vitest
pnpm test:e2e # Playwright pnpm test:e2e # Playwright
pnpm screenshots # Screenshots fuer docs/ANLEITUNG.md erzeugen pnpm screenshots # Screenshots fuer docs/ANLEITUNG.md erzeugen
node scripts/generate-icons.mjs # PWA-Icons aus der SVG-Quelle rendern
``` ```
### Testkonventionen
- Unit-Tests liegen in `tests/unit`, laufen unter Node und duerfen auch
Server-Module laden: `server-only` ist in `vitest.config.mts` auf einen
leeren Stub gemappt.
- `tests/setup-env.ts` setzt Platzhalter fuer die Umgebung. `BASE_URL` wird
dort **immer** ueberschrieben, weil Vite denselben Namen fuer seinen
Public-Base-Path benutzt und ihn auf `/` setzt.
- Der Playwright-Smoke-Test nutzt `./data-e2e`, der Screenshot-Lauf
`./data-shots` gegen das Produktions-Image. Beide Verzeichnisse werden vor
jedem Lauf geleert und liegen nie im Git.
## Datenmodell ## Datenmodell
Kernregel: Ein **`set`** ist ein *Katalogeintrag* - es existiert, Kernregel: Ein **`set`** ist ein *Katalogeintrag* - es existiert,
@@ -83,8 +133,16 @@ Diese Bedingung steht an genau einer Stelle: `src/lib/ownership.ts`.
### Besitz ### 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 | | 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` | | `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` | | `location` | Aufbewahrungsort, hierarchisch | `parent_location_id` |
| `missing_part` | Fehlteile je Exemplar | `part_number`, `color_name`, `quantity`, `resolved` | | `missing_part` | Fehlteile je Exemplar | `part_number`, `color_name`, `quantity`, `resolved` |
@@ -142,18 +200,28 @@ Session.
nichts preis ausser den Wunschlisten-Daten - keine Nutzernamen, nichts preis ausser den Wunschlisten-Daten - keine Nutzernamen,
keine Sammlungsdaten, keine IDs anderer Objekte. keine Sammlungsdaten, keine IDs anderer Objekte.
4. Externe API-Aufrufe (Rebrickable, ab Phase 2) laufen ausschliesslich 4. Externe API-Aufrufe (Rebrickable, ab Phase 2) laufen ausschliesslich
serverseitig. serverseitig. URLs, die ein Nutzer eingibt (Link-Import, Bild-Uebernahme),
werden nur ueber `safeFetch` abgerufen - nie mit `fetch` direkt, sonst
erreicht der Server auch Geraete im Heimnetz.
5. Session-Cookies: `httpOnly`, `secure`, `sameSite=lax`. 5. Session-Cookies: `httpOnly`, `secure`, `sameSite=lax`.
Rate-Limiting auf den Login-Endpunkten. Rate-Limiting auf den Login-Endpunkten.
6. Der Export enthaelt **keine** Konten, Passwoerter oder Passkeys - und
keine Reservierungen, Teilen-Token oder `app_setting` (SMTP-Zugang). Das Backup ist `./data`, nicht der
Export.
7. Der Service Worker beantwortet nur GET-Anfragen aus dem Cache und niemals
`/api/auth`, `/api/export` oder den Healthcheck. Beim Abmelden wird der
Cache geleert.
## Phasenplan ## Phasenplan
- **Phase 1 (MVP)** - Auth mit Passkeys, Stammdaten, Katalog, Sammlung, - **Phase 1 (MVP)** - Auth mit Passkeys, Stammdaten, Katalog, Sammlung,
Wunschlisten inkl. oeffentlicher Ansicht, Docker-Stack. Wunschlisten inkl. oeffentlicher Ansicht, Docker-Stack. *Fertig in 0.1.0.*
- **Phase 2 (Komfort)** - Rebrickable-Import, Barcode-Scanner, Fehlteile, - **Phase 2 (Komfort)** - Rebrickable-Import, Barcode-Scanner, Fehlteile,
Einladungen, Export/Import, PWA. Einladungen, Export/Import, PWA. *Fertig in 0.2.0.*
- **Phase 3 (Nice to have)** - mehrere Sammler, Preisbeobachtung, - **Phase 3 (Nice to have)** - mehrere Sammler, Preisbeobachtung,
E-Mail-Erinnerungen, Dublettenpruefung. E-Mail-Erinnerungen, Dublettenpruefung. *Fertig in 0.3.0.*
- **Design und Sicherheit** - Designauswahl (Standard Klemmbaustein),
Abhaengigkeiten ohne bekannte Luecken. *Fertig in 0.4.0.*
Details und Stand: `docs/ROADMAP.md`. Zeitaufwaende: `docs/DEVLOG.md`. Details und Stand: `docs/ROADMAP.md`. Zeitaufwaende: `docs/DEVLOG.md`.
+5 -5
View File
@@ -144,16 +144,16 @@ tut.
## 7. Passkey hinterlegen ## 7. Passkey hinterlegen
Unter **Konto → Passkeys → Passkey hinzufügen**. Ab dann meldest du dich per Unter **Mehr → Konto & Passkeys → Passkey hinzufügen** (der Button „Mehr" oben rechts). Ab dann meldest du dich per
Fingerabdruck, Gesichtserkennung oder Geräte-PIN an. Für jedes Gerät ein Fingerabdruck, Gesichtserkennung oder Geräte-PIN an. Für jedes Gerät ein
eigener Passkey – Handy, Tablet, Laptop. eigener Passkey – Handy, Tablet, Laptop.
## 8. Loslegen ## 8. Loslegen
1. **Verwaltung → Hersteller**: Lego, Playmobil, Cobi … anlegen. 1. **Mehr → Hersteller**: Lego, Playmobil, Cobi … anlegen.
2. **Verwaltung → Themen**: „City", „Technic", „Novelmore" … 2. **Mehr → Themen**: „City", „Technic", „Novelmore" …
3. **Verwaltung → Standorte**: „Regal Kinderzimmer", „Kiste Keller 3" … 3. **Mehr → Standorte**: „Regal Kinderzimmer", „Kiste Keller 3" …
4. **Katalog → Set anlegen**: Setnummer, Name, Bild. 4. **+ in der Navigation → Von Hand**: Setnummer, Name, Bild.
5. Auf der Set-Seite **„Zur Sammlung hinzufügen"**, wenn das Set da ist. 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, 6. **Katalog → Filter „Fehlt noch"**: Kachel lange drücken, mehrere auswählen,
**„Zur Wunschliste hinzufügen"**. **„Zur Wunschliste hinzufügen"**.
+17 -2
View File
@@ -29,6 +29,9 @@ du per Link mit Familie und Freunden teilst. Die reservieren dort ihr Geschenk
| **Fotos** | Eigene Bilder je Set – direkt aus der Handykamera | | **Fotos** | Eigene Bilder je Set – direkt aus der Handykamera |
| **Wunschlisten** | Mehrfachauswahl aus den fehlenden Sets, Priorität, Reihenfolge, Termin | | **Wunschlisten** | Mehrfachauswahl aus den fehlenden Sets, Priorität, Reihenfolge, Termin |
| **Teilen** | Öffentlicher Link ohne Anmeldung, Reservierung mit Namen, Storno-Link, Überraschungsmodus | | **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 | | **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 Mobil zuerst gebaut: Die App wird im Laden und im Kinderzimmer benutzt, nicht
@@ -81,7 +84,9 @@ Alle Werte kommen aus `.env` (Vorlage: `.env.example`).
| `TZ` | nein | `Europe/Berlin` | Zeitzone des Containers. | | `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. | | `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. | | `HOST_PORT` | nein | `3000` | Port auf dem Host, falls 3000 belegt ist. |
| `REBRICKABLE_API_KEY` | nein | – | Ab Version 0.2.0: Lego-Import. Ohne Key läuft alles manuell. | | `REBRICKABLE_API_KEY` | nein | – | Lego-Import. Ohne Key läuft alles manuell. |
| `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`, `SMTP_FROM` | nein | – | Mailversand für Wunschlisten-Erinnerungen. Ohne `SMTP_HOST` und `SMTP_FROM` ist die Funktion aus. |
| `REMINDER_DAYS_BEFORE` | nein | `14` | Wie viele Tage vor dem Termin erinnert wird. |
### Reverse Proxy ### Reverse Proxy
@@ -172,10 +177,16 @@ pnpm dev # http://localhost:3000
| `pnpm build` | Produktionsbuild (standalone) | | `pnpm build` | Produktionsbuild (standalone) |
| `pnpm lint` / `pnpm typecheck` | ESLint / TypeScript – beide müssen sauber sein | | `pnpm lint` / `pnpm typecheck` | ESLint / TypeScript – beide müssen sauber sein |
| `pnpm test` | Unit-Tests (Vitest) | | `pnpm test` | Unit-Tests (Vitest) |
| `pnpm test:e2e` | Playwright-Smoke-Test gegen ein eigenes `./data-e2e` | | `pnpm test:e2e` | Playwright-Smoke-Test gegen das Docker-Image (eigenes `./data-e2e`) |
| `pnpm db:generate` | SQL-Migration aus dem Drizzle-Schema erzeugen | | `pnpm db:generate` | SQL-Migration aus dem Drizzle-Schema erzeugen |
| `pnpm db:migrate` | Migrationen anwenden | | `pnpm db:migrate` | Migrationen anwenden |
| `pnpm db:studio` | Drizzle Studio | | `pnpm db:studio` | Drizzle Studio |
| `pnpm screenshots` | Bilder für `docs/ANLEITUNG.md` neu erzeugen |
`pnpm test:e2e` und `pnpm screenshots` bauen jeweils das Docker-Image und
fahren es hoch. Sie brauchen also einen laufenden Docker-Daemon, testen dafür
aber genau das Artefakt, das später auch deployt wird – der Dev-Server hat
unter der Last eines ganzen Durchlaufs reproduzierbar Chunks verloren.
> **Unter Windows** schlägt `pnpm build` mit `EPERM … symlink` fehl: der > **Unter Windows** schlägt `pnpm build` mit `EPERM … symlink` fehl: der
> Standalone-Output legt Symlinks an, wofür Windows den Entwicklermodus oder > Standalone-Output legt Symlinks an, wofür Windows den Entwicklermodus oder
@@ -202,6 +213,10 @@ Ein verkauftes Exemplar zählt also nicht mehr als Besitz – das Set taucht
wieder unter „fehlt noch" auf und darf zurück auf eine Wunschliste. Diese 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`. 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). Das vollständige Modell inklusive aller Tabellen: [CLAUDE.md](CLAUDE.md#datenmodell).
## Sicherheit ## Sicherheit
+327 -15
View File
@@ -22,7 +22,17 @@ Kinderzimmer, nicht für den Schreibtisch.
8. [Wunschlisten](#8-wunschlisten) 8. [Wunschlisten](#8-wunschlisten)
9. [Teilen – so sehen es Oma und Tante Anne](#9-teilen) 9. [Teilen – so sehen es Oma und Tante Anne](#9-teilen)
10. [Passkeys einrichten](#10-passkeys-einrichten) 10. [Passkeys einrichten](#10-passkeys-einrichten)
11. [Antworten auf die zwei wichtigen Fragen](#11-die-zwei-fragen) 11. [Sets im Schnellverfahren: Import und Scanner](#11-import-und-scanner)
12. [Fehlteile](#12-fehlteile)
13. [Noch ein Konto für die Familie](#13-einladungen)
14. [Daten sichern, umziehen, auswerten](#14-daten)
15. [Auf den Startbildschirm legen](#15-startbildschirm)
16. [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)
21. [Ein anderes Design](#21-design)
--- ---
@@ -43,17 +53,34 @@ gleich danach unter „Konto" (siehe [Abschnitt 10](#10-passkeys-einrichten)).
![Startseite](bilder/02-start.png) ![Startseite](bilder/02-start.png)
Die Startseite ist die Abzweigung zu den vier Bereichen: Die Startseite zeigt auf einen Blick, wo die Sammlung steht:
| | | | | |
|---|---| |---|---|
| **Katalog** | Alle bekannten Sets – egal ob vorhanden oder nicht | | **Steinreihe** | Zehn Plätze für den ganzen Katalog. Jeder volle Stein ist ein Zehntel, das schon da ist – bei mehreren Kindern in deren Farbe |
| **Fehlende Sets** | Der direkte Weg zu allem, was noch fehlt | | **Teile und Wert** | Alle verbauten Teile und was die Sammlung heute wert ist |
| **Sammlung** | Was tatsächlich da ist | | **Nächster Termin** | Die nächste Wunschliste mit Datum, wie viele Tage noch bleiben und wie viel offen ist |
| **Wunschlisten** | Zum Teilen mit Familie und Freunden | | **Abkürzungen** | Direkt zu den fehlenden Sets, zu den Wunschlisten oder zu einem neuen Set |
Unten am Bildschirmrand liegt die Navigation – auf dem Handy mit dem Daumen Unten am Bildschirmrand liegt die Navigation – auf dem Handy mit dem Daumen
erreichbar. erreichbar: **Start, Katalog, Sammlung, Wünsche** und in der Mitte der rote
**+-Stein**. Der aktive Bereich sitzt auf einer kleinen Platte.
![Das Plus-Menü](bilder/26-plus-menue.png)
Das **+** bündelt alle Wege, wie ein Set in den Katalog kommt: Strichcode
scannen, aus einem Link übernehmen, Lego-Nummer holen oder von Hand eintragen.
![Die Mehr-Seite](bilder/27-mehr.png)
Alles, was man nicht täglich braucht, liegt hinter dem Button
**„Mehr"** oben rechts: Konto und Passkeys, Design,
Sammler, Einladungen, Erinnerungen, Hersteller, Themen, Standorte sowie
Export und Import. Was nur der Administrator darf, sehen Mitglieder dort gar
nicht erst.
Sets ohne Bild bekommen einen farbigen Platzhalter mit der Setnummer. Jeder
Hersteller hat seine eigene Farbe – Lego gelb, Playmobil blau, Cobi grün.
--- ---
@@ -61,8 +88,9 @@ erreichbar.
![Hersteller verwalten](bilder/03-verwaltung-hersteller.png) ![Hersteller verwalten](bilder/03-verwaltung-hersteller.png)
Bevor du Sets anlegst, brauchst du mindestens einen **Hersteller**. Zu finden Bevor du Sets von Hand anlegst, brauchst du mindestens einen **Hersteller**.
über das Konto-Menü oben rechts → **Verwaltung**. Zu finden unter **Mehr → Hersteller**. Der Lego-Import und die Übernahme aus
einem Link legen fehlende Hersteller selbst an.
Drei Arten von Stammdaten: Drei Arten von Stammdaten:
@@ -104,7 +132,7 @@ Die Kacheln zeigen auf einen Blick:
- **„Auf Wunschliste"** – steht auf mindestens einer Liste - **„Auf Wunschliste"** – steht auf mindestens einer Liste
- Setnummer, Hersteller, Thema, Jahr und UVP - Setnummer, Hersteller, Thema, Jahr und UVP
Mit **„Set anlegen"** oben rechts kommt ein neues Set dazu. Pflicht sind nur Über das **+** in der Navigation kommt ein neues Set dazu. Pflicht sind nur
Hersteller, Setnummer und Name; alles andere kannst du nachtragen. Ein Bild Hersteller, Setnummer und Name; alles andere kannst du nachtragen. Ein Bild
lädst du entweder hoch (es wird verkleinert und lokal gespeichert) oder du lädst du entweder hoch (es wird verkleinert und lokal gespeichert) oder du
hinterlegst eine Bild-URL. hinterlegst eine Bild-URL.
@@ -273,7 +301,7 @@ zweites Mal.
![Konto und Passkeys](bilder/16-konto-passkeys.png) ![Konto und Passkeys](bilder/16-konto-passkeys.png)
Unter **Konto → Passkeys → Passkey hinzufügen** hinterlegst du Fingerabdruck, Unter **Mehr → Konto & Passkeys → Passkey hinzufügen** hinterlegst du Fingerabdruck,
Gesichtserkennung oder die Geräte-PIN als Anmeldung. Danach reicht auf der 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 Anmeldeseite ein Tipp auf „Mit Passkey anmelden" – auf vielen Geräten schlägt
der Browser den Passkey sogar von selbst vor. der Browser den Passkey sogar von selbst vor.
@@ -291,7 +319,269 @@ gib ihnen sprechende Namen.
--- ---
## 11. Die zwei Fragen ## 11. Import und Scanner
Sets von Hand einzutippen ist der langsame Weg. Es gibt drei schnellere.
### Aus einem Link übernehmen – für alle Hersteller
Über **+ → Aus Link** landest du im Feld „Aus einem Link übernehmen".
Dort passt jeder Link zu einem Set hinein: Amazon, idealo, geizhals, der Shop
des Herstellers oder eine Set-Datenbank wie
[Brickfact](https://brickfact.com) (Lego) und
[Merlins Bricks](https://www.merlinsbricks.com) (BlueBrixx, Cobi und viele
mehr). „Übernehmen" füllt das Formular darunter aus – **gespeichert wird erst,
wenn du „Set anlegen" drückst**. Ein Blick auf Name und Nummer lohnt sich,
denn Shops schreiben gern das halbe Sortiment in den Titel.
Was dabei passiert:
1. Bricktrack liest die Seite und übernimmt Name, Setnummer, EAN, Bild,
Teile und Jahr, soweit sie dort stehen.
2. Manche Shops lassen sich nicht abfragen. Dann reicht oft schon der Link
selbst: In `amazon.de/LEGO-60398-Familienhaus-…` stecken Hersteller,
Nummer und Name.
3. Mit Hersteller und Nummer ergänzt Bricktrack den Rest: Lego über
Rebrickable (mit API-Key), BlueBrixx und Cobi über Merlins Bricks.
Das Bild wird beim Speichern lokal abgelegt, die Quelle steht in den Notizen.
Gibt es den Hersteller noch nicht, wird er angelegt. Steht das Set schon im
Katalog, kommt statt des Formulars ein Link zum vorhandenen Eintrag.
> Bricktrack ruft dafür nur öffentliche Adressen ab. Links ins eigene Netz –
> Router, NAS, andere Container – werden abgewiesen.
### Lego-Import über die Setnummer
![Lego-Import](bilder/17-lego-import.png)
Über **+ → Lego-Nummer** landest du im 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 **Mehr → 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 **Mehr → Export & Import** 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 **Mehr → Sammler**. Jeder bekommt einen Namen und eine
Farbe zur Unterscheidung.
![Nach Sammler filtern](bilder/22-sammler-filter.png)
Danach steht oben unter dem Logo der Umschalter **„Für wen?"**. „Alle"
zeigt den Haushalt als Ganzes, ein Name zeigt genau dessen Sicht – auf der
Startseite, im Katalog und in der Sammlung, inklusive der Frage, was
**diesem** Kind noch fehlt. Die Wahl merkt sich jedes Gerät: Das Tablet im
Kinderzimmer öffnet direkt bei Max.
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 **Mehr → 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 Mailserver (SMTP). Den trägst du direkt in der App ein:
**Mehr → Erinnerungen → Mailserver**. Die Angaben – Server, Port,
Verschlüsselung, Benutzername, Passwort und Absender – findest du bei deinem
Mail-Anbieter unter „SMTP" oder „Postausgangsserver". Ein Neustart ist nicht
nötig.
Das Passwort wird verschlüsselt gespeichert und nie wieder angezeigt; zum
Ändern einfach ein neues eintragen. Es landet auch nicht im Export.
> Wer lieber mit der `.env` arbeitet, kann den Mailserver weiterhin dort
> eintragen (`SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`,
> `SMTP_FROM`). Dann hat die `.env` Vorrang, und die App zeigt die Werte nur
> an. Wie viele Tage vorher erinnert wird, steht in `REMINDER_DAYS_BEFORE`
> (Standard 14).
Unter **Mehr → Erinnerungen** kannst du außerdem 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 Mailserver 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: Dafür ist die ganze App gebaut:
@@ -300,10 +590,32 @@ Dafür ist die ganze App gebaut:
**„Welche Sets hat er noch nicht?"** **„Welche Sets hat er noch nicht?"**
→ Katalog, Filter „Fehlt noch". Von dort in zwei Handgriffen auf eine → Katalog, Filter „Fehlt noch". Von dort in zwei Handgriffen auf eine
Wunschliste und per Link zu Oma. Wunschliste und per Link zu Oma. Bei mehreren Kindern beantwortet der
Umschalter oben die Frage für jedes einzeln.
Im Laden vor dem Regal: Katalog öffnen, Setnummer von der Packung eintippen. Im Laden vor dem Regal: Scanner öffnen, Strichcode einlesen. Steht ein grüner
Steht ein grüner Haken dran, habt ihr es schon. Haken dran, habt ihr es schon.
---
## 21. Design
![Startseite im Design Nachtblau](bilder/25-design-nachtblau.png)
Unter **Mehr → Design** gibt es sechs Looks zur Auswahl:
| Design | |
|---|---|
| **Klemmbaustein** | Der Standard: Karten mit Noppen, dunkle Konturen, kräftige Steinfarben |
| **Warm** | Ruhiger Sandton mit Ziegelrot |
| **Nachtblau** | Dunkel mit gelbem Akzent – angenehm am Abend |
| **Bonbon** | Weiches Lila mit Pastellfarben |
| **Bauklotz** | Primärfarben und dicke schwarze Konturen |
| **Schlicht** | Neutrales Grau, so wie Bricktrack bis Version 0.3 aussah |
Die Auswahl wirkt sofort und gilt **für das Gerät**, nicht für das Konto: Das
Tablet im Kinderzimmer kann bunt sein, während das Handy dunkel bleibt.
Öffentliche Wunschlisten sehen Gäste immer im Standard-Design.
--- ---
+68
View File
@@ -29,6 +29,36 @@ Commit-Spalte verweist auf den Stand am Ende des Schritts.
**Summe Phase 1: 2:44** **Summe Phase 1: 2:44**
## Phase 2 – Komfort (v0.2.0)
### 07.09.2026
| Von | Bis | Dauer | Schritt | Ergebnis | Commit |
|---|---|---|---|---|---|
| 06:50 | 07:01 | 0:11 | Einladungen | Admin erzeugt einmal gültige Registrierungslinks; Freigabe über AsyncLocalStorage, damit die Rolle nicht aus dem Formular kommen kann. Smoke-Test deckt den ganzen Weg ab | `52d69e5` |
| 07:01 | 07:12 | 0:11 | Rebrickable-Import | Setnummer genügt: Metadaten, Thema und lokal gespeichertes Bild; 14 Tests gegen gestubbtes `fetch` inklusive aller Fehlerfälle | `4e3cbb8` |
| 07:12 | 07:15 | 0:03 | Barcode-Scanner | EAN mit der Handykamera, Prüfziffer-Kontrolle, zxing wird erst beim Öffnen geladen | `baa94bf` |
| 07:15 | 07:18 | 0:03 | Fehlteile | Liste je Sammlungseintrag; Vollständigkeit pflegt sich daraus selbst | `5c5c521` |
| 07:18 | 07:23 | 0:05 | Export und Import | ZIP, JSON und CSV; Import ersetzt in einer Transaktion, 10 Tests für den Rundweg | `45aa8f1` |
| 07:23 | 07:27 | 0:04 | PWA | Manifest, Icons aus SVG-Quelle, Service Worker mit Offline-Katalog | `39271f8` |
| 07:27 | 07:45 | 0:18 | Verifikation und Dokumentation | Smoke-Test, Docker-Build, Changelog 0.2.0, Anleitung und Roadmap | folgt |
**Summe Phase 2: 0:55**
## 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 ## Notizen und Entscheidungen
@@ -86,6 +116,39 @@ Commit-Spalte verweist auf den Stand am Ende des Schritts.
Produktions-Image im Container – verlässlich und zugleich näher an dem, was Produktions-Image im Container – verlässlich und zugleich näher an dem, was
Nutzer wirklich sehen. Nutzer wirklich sehen.
### Phase 2
- **`server-only` in Tests**: Das Paket wirft beim Import ausserhalb einer
Server-Komponente, wodurch sich Servermodule nicht testen liessen. In
`vitest.config.mts` ist es jetzt auf einen leeren Stub gemappt – der Import
ist eine Build-Zeit-Absicherung, kein Verhalten.
- **Namenskonflikt `BASE_URL`**: Vite benutzt genau diesen Namen für seinen
Public-Base-Path und setzt ihn auf `/`. Die Tests überschreiben ihn deshalb
ausdrücklich. Dabei ist aufgefallen, dass `src/env.ts` bei einem leeren Wert
abbrach statt auf den Standard zurückzufallen – behoben, denn ein
`BASE_URL=` in der `.env` ist ein realistischer Tippfehler.
- **Rebrickable und Anleitungen**: Die API v3 stellt keine Anleitungsdateien
bereit. Statt das zu verschleiern, legt der Import einen externen Link auf
die LEGO-Serviceseite zur Setnummer an.
- **Import statt Zusammenführen**: Zwei Sammlungen zu mischen bräuchte
Konfliktregeln für jede Tabelle und wäre im Ergebnis nicht vorhersagbar.
Der Import ersetzt deshalb – dafür mit getipptem `ERSETZEN` als Bremse.
### 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 ### Beim Testen gefunden und behoben
- Eine spontan aus dem Katalog angelegte Wunschliste war **nicht teilbar**: - Eine spontan aus dem Katalog angelegte Wunschliste war **nicht teilbar**:
@@ -96,3 +159,8 @@ Commit-Spalte verweist auf den Stand am Ende des Schritts.
`required` des Eingabefeldes. `required` des Eingabefeldes.
- In der Mehrfachauswahl verdeckte die Aktionsleiste die letzte Kachelreihe; - In der Mehrfachauswahl verdeckte die Aktionsleiste die letzte Kachelreihe;
das Raster bekommt im Auswahlmodus zusätzliches Polster nach unten. 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`.
+36 -13
View File
@@ -34,24 +34,47 @@ Laeuft vollstaendig im Docker-Stack.
- [x] Dokumentation: README, QUICKSTART, bebilderte Anleitung, - [x] Dokumentation: README, QUICKSTART, bebilderte Anleitung,
update.sh, Nginx-Proxy-Manager-Konfiguration update.sh, Nginx-Proxy-Manager-Konfiguration
## Phase 2 - Komfort (Ziel: v0.2.0) ## Phase 2 - Komfort (v0.2.0, abgeschlossen am 07.09.2026)
- [ ] Rebrickable-Import: Setnummer eingeben, Metadaten und Bild - [x] Rebrickable-Import: Setnummer eingeben, Metadaten und Bild
uebernehmen, Bild lokal cachen. Ohne API-Key bleibt alles manuell. uebernehmen, Bild lokal cachen. Ohne API-Key bleibt alles manuell.
- [ ] Barcode-Scanner (EAN) per Handykamera, Suche nach EAN, bei - [x] Barcode-Scanner (EAN) per Handykamera, Suche nach EAN, bei
Unbekanntem direkt ins Formular "Neues Set" Unbekanntem direkt ins Formular "Neues Set"
- [ ] Fehlteile je Sammlungseintrag pflegen - [x] Fehlteile je Sammlungseintrag pflegen
- [ ] Einladungslinks durch den Admin - [x] Einladungslinks durch den Admin
- [ ] Export/Import: gesamte Datenbank als JSON, Dateien als ZIP, - [x] Export/Import: gesamte Datenbank als JSON, Dateien als ZIP,
Sammlung zusaetzlich als CSV Sammlung zusaetzlich als CSV
- [ ] PWA: Manifest, Service Worker, Katalog offline lesbar - [x] PWA: Manifest, Service Worker, Katalog offline lesbar
## Phase 3 - Nice to have (Ziel: v0.3.0) ## Phase 3 - Nice to have (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 gemeinsamer Katalog
- [ ] Preisbeobachtung und Wertentwicklung (manuell gepflegt, - [x] Preisbeobachtung und Wertentwicklung (manuell gepflegt).
optional BrickLink-Preisfuehrer) Der BrickLink-Preisfuehrer war als optional vermerkt und wurde
- [ ] E-Mail-Erinnerung (SMTP), wenn ein Wunschlisten-Termin naeher 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 rueckt und Eintraege unreserviert sind
- [ ] Dublettenpruefung beim Anlegen von Sets - [x] Dublettenpruefung beim Anlegen von Sets
## Design und Sicherheit (v0.4.0, abgeschlossen am 25.10.2026)
- [x] Abhängigkeiten mit bekannten Sicherheitslücken aktualisiert
- [x] Designauswahl pro Gerät, sechs Designs, Standard „Klemmbaustein"
- [x] Platzhalter für Sets ohne Bild, neues Logo, Schriften lokal
- [x] Startseite als Überblick mit Steinreihe und nächstem Termin
- [ ] Filter im Katalog als Reiter statt Auswahlfelder
- [ ] Scan-Button mittig in der unteren Navigation
## Danach
Phase 1 bis 3 sind umgesetzt. Was noch offen ist, steht hier - ohne Termin
und ohne Anspruch auf Vollstaendigkeit:
- [ ] BrickLink-Preisfuehrer anbinden, falls der Aufwand sich doch lohnt
- [ ] Mehrere Fotos je Sammlungseintrag (bisher nur je Set)
- [ ] Anleitungen im PDF-Viewer durchsuchen
- [ ] Statistik ueber die Zeit: was kam wann dazu
Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 285 KiB

After

Width:  |  Height:  |  Size: 445 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 202 KiB

After

Width:  |  Height:  |  Size: 331 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 212 KiB

After

Width:  |  Height:  |  Size: 371 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 252 KiB

After

Width:  |  Height:  |  Size: 490 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 183 KiB

After

Width:  |  Height:  |  Size: 350 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

After

Width:  |  Height:  |  Size: 331 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 292 KiB

After

Width:  |  Height:  |  Size: 497 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 155 KiB

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 244 KiB

After

Width:  |  Height:  |  Size: 426 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 187 KiB

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 294 KiB

After

Width:  |  Height:  |  Size: 498 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 383 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 380 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 451 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 271 KiB

+28
View File
@@ -0,0 +1,28 @@
CREATE TABLE `collector` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`color` text DEFAULT 'blue' NOT NULL,
`birthday` integer,
`notes` text,
`sort_order` integer DEFAULT 0 NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL
);
--> statement-breakpoint
CREATE INDEX `collector_sort_idx` ON `collector` (`sort_order`,`name`);--> statement-breakpoint
ALTER TABLE `collection_item` ADD `collector_id` text REFERENCES collector(id);--> statement-breakpoint
CREATE INDEX `collection_item_collector_idx` ON `collection_item` (`collector_id`);--> statement-breakpoint
ALTER TABLE `wishlist` ADD `collector_id` text REFERENCES collector(id);--> statement-breakpoint
-- Von Hand ergaenzt: Bestandsdaten nachziehen.
-- Vor dieser Migration gab es genau eine Sammlung. Sie bekommt einen Sammler,
-- damit alles Vorhandene zugeordnet bleibt; umbenennen laesst er sich unter
-- Verwaltung -> Sammler.
INSERT INTO `collector` (`id`, `name`, `color`, `sort_order`, `created_at`, `updated_at`)
SELECT 'c0000000-0000-4000-8000-000000000001', 'Sammlung', 'blue', 0, unixepoch(), unixepoch()
WHERE NOT EXISTS (SELECT 1 FROM `collector`);--> statement-breakpoint
UPDATE `collection_item`
SET `collector_id` = (SELECT `id` FROM `collector` ORDER BY `sort_order`, `name` LIMIT 1)
WHERE `collector_id` IS NULL;--> statement-breakpoint
UPDATE `wishlist`
SET `collector_id` = (SELECT `id` FROM `collector` ORDER BY `sort_order`, `name` LIMIT 1)
WHERE `collector_id` IS NULL;
+14
View File
@@ -0,0 +1,14 @@
CREATE TABLE `price_point` (
`id` text PRIMARY KEY NOT NULL,
`set_id` text NOT NULL,
`price_cents` integer NOT NULL,
`condition` text DEFAULT 'new' NOT NULL,
`source` text,
`observed_at` integer NOT NULL,
`note` text,
`created_at` integer NOT NULL,
FOREIGN KEY (`set_id`) REFERENCES `set`(`id`) ON UPDATE no action ON DELETE cascade,
CONSTRAINT "price_point_positive" CHECK("price_point"."price_cents" >= 0)
);
--> statement-breakpoint
CREATE INDEX `price_point_set_idx` ON `price_point` (`set_id`,`observed_at`);
+1
View File
@@ -0,0 +1 @@
ALTER TABLE `wishlist` ADD `last_reminder_at` integer;
+5
View File
@@ -0,0 +1,5 @@
CREATE TABLE `app_setting` (
`key` text PRIMARY KEY NOT NULL,
`value` text NOT NULL,
`updated_at` integer NOT NULL
);
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
File diff suppressed because it is too large Load Diff
+28
View File
@@ -8,6 +8,34 @@
"when": 1788726503038, "when": 1788726503038,
"tag": "0000_rapid_gargoyle", "tag": "0000_rapid_gargoyle",
"breakpoints": true "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
},
{
"idx": 4,
"version": "6",
"when": 1792963951232,
"tag": "0004_modern_agent_brand",
"breakpoints": true
} }
] ]
} }
-13
View File
@@ -1,13 +0,0 @@
import { execFileSync } from 'node:child_process';
/**
* Migrates the smoke-test database. The directory itself is wiped earlier by
* scripts/reset-e2e.mjs, because Playwright starts its web server before this
* hook runs and the open database file would block the delete on Windows.
*/
export default function globalSetup() {
execFileSync('node', ['node_modules/tsx/dist/cli.mjs', 'scripts/migrate.ts'], {
stdio: 'inherit',
env: { ...process.env, DATA_DIR: './data-e2e' },
});
}
+95 -1
View File
@@ -19,6 +19,9 @@ function shotPath(name: string) {
async function shoot(page: Page, name: string) { async function shoot(page: Page, name: string) {
// Let fonts and images settle so the picture is not half-rendered. // Let fonts and images settle so the picture is not half-rendered.
await page.waitForLoadState('networkidle'); await page.waitForLoadState('networkidle');
// A fixed bottom bar would be painted where the first viewport ends, in the
// middle of a full-page picture. In flow it lands at the end, where it belongs.
await page.addStyleTag({ content: '.fixed.bottom-0 { position: static !important; }' });
await page.screenshot({ path: shotPath(name), fullPage: true }); await page.screenshot({ path: shotPath(name), fullPage: true });
} }
@@ -49,6 +52,17 @@ test('Screenshots fuer die Anleitung', async ({ page, browser }) => {
await visit(page, '/'); await visit(page, '/');
await shoot(page, '02-start'); await shoot(page, '02-start');
// The plus in the middle of the navigation, opened.
await page.getByRole('button', { name: 'Neues Set' }).click();
await expect(page.getByText('Was kommt dazu?')).toBeVisible();
await page.waitForTimeout(400); // let the sheet finish sliding in
await page.screenshot({ path: shotPath('26-plus-menue') });
await page.keyboard.press('Escape');
await visit(page, '/mehr');
await expect(page.getByText('Katalog pflegen')).toBeVisible();
await shoot(page, '27-mehr');
// --- 3. master data ------------------------------------------------------- // --- 3. master data -------------------------------------------------------
await visit(page, '/verwaltung/hersteller'); await visit(page, '/verwaltung/hersteller');
await expect(page.getByText('Lego', { exact: true })).toBeVisible(); await expect(page.getByText('Lego', { exact: true })).toBeVisible();
@@ -66,7 +80,8 @@ test('Screenshots fuer die Anleitung', async ({ page, browser }) => {
await page.getByRole('button', { name: 'Auswählen' }).click(); await page.getByRole('button', { name: 'Auswählen' }).click();
await expect(page.getByText('Tippe die Sets an')).toBeVisible(); await expect(page.getByText('Tippe die Sets an')).toBeVisible();
const cards = page.locator('button[aria-pressed]'); // data-set-card, not aria-pressed: the collector switcher uses that too.
const cards = page.locator('button[data-set-card]');
await cards.nth(0).click(); await cards.nth(0).click();
await cards.nth(1).click(); await cards.nth(1).click();
await expect(page.getByText('2 ausgewählt')).toBeVisible(); await expect(page.getByText('2 ausgewählt')).toBeVisible();
@@ -116,6 +131,8 @@ test('Screenshots fuer die Anleitung', async ({ page, browser }) => {
await visitor.getByRole('button', { name: 'Reservieren' }).click(); await visitor.getByRole('button', { name: 'Reservieren' }).click();
await expect(visitor.getByText('Reservierung gespeichert')).toBeVisible(); await expect(visitor.getByText('Reservierung gespeichert')).toBeVisible();
await visitor.getByRole('button', { name: 'Alles klar' }).click(); await visitor.getByRole('button', { name: 'Alles klar' }).click();
// Otherwise the picture catches the dialog halfway through fading out.
await expect(visitor.getByRole('dialog')).toBeHidden();
await shoot(visitor, '14-reserviert'); await shoot(visitor, '14-reserviert');
await visitorContext.close(); await visitorContext.close();
@@ -127,4 +144,81 @@ test('Screenshots fuer die Anleitung', async ({ page, browser }) => {
await visit(page, '/konto'); await visit(page, '/konto');
await expect(page.getByText('Passkeys', { exact: true })).toBeVisible(); await expect(page.getByText('Passkeys', { exact: true })).toBeVisible();
await shoot(page, '16-konto-passkeys'); await shoot(page, '16-konto-passkeys');
// The same start page in the dark design, then back to the default.
await page.getByText('Nachtblau').click();
await expect(page.locator('html')).toHaveAttribute('data-design', 'nacht');
// The look switches at once; the cookie follows with the server action.
await page.waitForLoadState('networkidle');
await visit(page, '/');
await expect(page.locator('html')).toHaveAttribute('data-design', 'nacht');
await shoot(page, '25-design-nachtblau');
await visit(page, '/konto');
await page.getByText('Klemmbaustein').click();
await expect(page.locator('html')).toHaveAttribute('data-design', 'klemmbaustein');
// --- 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('radio', { name: /Max/ })).toBeVisible();
await page.getByRole('radio', { name: /Max/ }).click();
await expect(page.getByText('Sets, die Max noch nicht hat')).toBeVisible();
await shoot(page, '22-sammler-filter');
// Back to everybody, so the following pictures show the whole family.
await page.getByRole('radio', { name: /Alle/ }).click();
await expect(page.getByText('Sets, die noch nicht in der Sammlung sind')).toBeVisible();
// --- 17. prices -----------------------------------------------------------
await visit(page, '/katalog?q=60398');
await page.getByText('Familienhaus und Elektroauto').first().click();
await expect(page.getByText('Preis und Wertentwicklung')).toBeVisible();
await page.getByRole('button', { name: 'Preis notieren' }).click();
// Role, not label: the label text carries the required marker.
await page.getByRole('textbox', { name: 'Preis', exact: true }).fill('79,99');
await page.getByLabel('Wo').fill('Amazon');
await page.getByRole('button', { name: 'Speichern' }).click();
await expect(page.getByText('Amazon')).toBeVisible();
await shoot(page, '23-preise');
// --- 18. reminders --------------------------------------------------------
await visit(page, '/verwaltung/erinnerungen');
await expect(page.getByText('Erinnerungen').first()).toBeVisible();
await shoot(page, '24-erinnerungen');
}); });
+71
View File
@@ -81,6 +81,77 @@ test('Registrieren, Set anlegen, Wunschliste teilen und reservieren', async ({ p
await visitorContext.close(); 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 }) => { test('Ohne Anmeldung fuehrt jeder geschuetzte Pfad zur Anmeldung', async ({ page }) => {
await page.goto('/sammlung'); await page.goto('/sammlung');
await expect(page).toHaveURL(/\/anmelden|\/registrieren/); await expect(page).toHaveURL(/\/anmelden|\/registrieren/);
+11
View File
@@ -11,6 +11,17 @@ const compat = new FlatCompat({
const eslintConfig = [ const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"), ...compat.extends("next/core-web-vitals", "next/typescript"),
{
rules: {
// Destructuring a key out in order to drop it is how we omit fields
// (share tokens, reservations) from an export - the binding is
// deliberately unused.
"@typescript-eslint/no-unused-vars": [
"warn",
{ ignoreRestSiblings: true, argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
],
},
},
{ {
ignores: [ ignores: [
"node_modules/**", "node_modules/**",
+41 -15
View File
@@ -1,6 +1,6 @@
{ {
"name": "bricktrack", "name": "bricktrack",
"version": "0.1.0", "version": "0.4.0",
"private": true, "private": true,
"description": "Self-hosted Verwaltung fuer Spielzeug-Set-Sammlungen (Lego, Playmobil, Cobi, ...)", "description": "Self-hosted Verwaltung fuer Spielzeug-Set-Sammlungen (Lego, Playmobil, Cobi, ...)",
"packageManager": "pnpm@10.34.5", "packageManager": "pnpm@10.34.5",
@@ -19,48 +19,60 @@
"db:studio": "drizzle-kit studio", "db:studio": "drizzle-kit studio",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"test:e2e": "node scripts/reset-e2e.mjs && playwright test", "test:e2e": "node scripts/e2e-prepare.mjs && playwright test",
"screenshots": "node scripts/screenshots-prepare.mjs && playwright test --config=playwright.screenshots.config.ts" "screenshots": "node scripts/screenshots-prepare.mjs && playwright test --config=playwright.screenshots.config.ts"
}, },
"dependencies": { "dependencies": {
"@better-auth/passkey": "^1.7.3", "@better-auth/passkey": "^1.7.12",
"better-auth": "^1.7.3", "@fontsource-variable/archivo": "^5.3.0",
"@fontsource-variable/bricolage-grotesque": "^5.3.0",
"@fontsource-variable/fredoka": "^5.3.0",
"@fontsource-variable/inter": "^5.3.0",
"@fontsource-variable/nunito": "^5.3.0",
"@fontsource-variable/outfit": "^5.3.0",
"@zxing/browser": "^0.2.1",
"@zxing/library": "^0.23.0",
"better-auth": "^1.7.12",
"better-sqlite3": "^13.0.3", "better-sqlite3": "^13.0.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"cn": "^0.2.6", "cn": "^0.2.6",
"date-fns": "^4.4.0", "date-fns": "^4.4.0",
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.4",
"fflate": "^0.8.3",
"lucide-react": "^1.41.0", "lucide-react": "^1.41.0",
"next": "15.5.25", "next": "15.5.28",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"nodemailer": "^10.2.1",
"pdfjs-dist": "^6.3.289", "pdfjs-dist": "^6.3.289",
"radix-ui": "^1.6.7", "radix-ui": "^1.6.7",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"server-only": "^0.0.1", "server-only": "^0.0.1",
"sharp": "^0.35.4", "sharp": "^0.35.5",
"sonner": "^2.0.8", "sonner": "^2.0.8",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"zod": "^4.5.4" "zod": "^4.6.7"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3", "@eslint/eslintrc": "^3",
"@playwright/test": "^1.63.0", "@playwright/test": "^1.64.1",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/better-sqlite3": "^9.6.0", "@types/better-sqlite3": "^9.6.0",
"@types/node": "^20", "@types/node": "^20",
"@types/nodemailer": "^8.0.1",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"drizzle-kit": "^0.31.10", "drizzle-kit": "^0.31.11",
"esbuild": "^0.25.12", "esbuild": "^0.25.12",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "15.5.25", "eslint-config-next": "15.5.28",
"shadcn": "^4.21.0", "shadcn": "^4.22.1",
"tailwindcss": "^4", "tailwindcss": "^4",
"tsx": "^4.23.13", "tsx": "^4.23.15",
"typescript": "^5", "typescript": "^5",
"vitest": "^5.0.0" "vite": "^8.3.5",
"vitest": "^5.0.5"
}, },
"pnpm": { "pnpm": {
"onlyBuiltDependencies": [ "onlyBuiltDependencies": [
@@ -68,6 +80,20 @@
"esbuild", "esbuild",
"sharp", "sharp",
"unrs-resolver" "unrs-resolver"
] ],
"overrides": {
"postcss": "^8.5.31",
"vite": "^8.2.4",
"source-map-js": "^1.2.2",
"brace-expansion@<2": "^1.1.21",
"brace-expansion@>=4 <6": "^5.0.12",
"@modelcontextprotocol/sdk": "^1.32.2",
"undici@7": "^7.30.1",
"proxy-addr": "^2.0.8",
"body-parser@2": "^2.4.0",
"fast-uri@3": "^3.1.9",
"hono": "^4.13.11",
"ip-address": "^10.7.4"
}
} }
} }
+19 -16
View File
@@ -1,28 +1,29 @@
import path from 'node:path';
import { defineConfig, devices } from '@playwright/test'; import { defineConfig, devices } from '@playwright/test';
/** /**
* Smoke test setup. * Smoke test setup.
* *
* Runs against its own data directory (./data-e2e) so a test never touches the * Runs against the production container with its own data directory
* real database. The global setup wipes and migrates it before each run. * (./data-e2e), so a test never touches the real database and never depends on
* the dev server compiling fast enough.
*/ */
const PORT = 3210; const PORT = 3210;
const BASE_URL = `http://localhost:${PORT}`; const BASE_URL = `http://localhost:${PORT}`;
// Docker wants forward slashes, also on Windows.
const dataDir = path.resolve(process.cwd(), 'data-e2e').replace(/\\/g, '/');
export default defineConfig({ export default defineConfig({
testDir: './e2e', testDir: './e2e',
// The screenshot run has its own config, its own server and its own data // The screenshot run has its own config, its own server and its own data
// directory - it must never join the smoke run. // directory - it must never join the smoke run.
testMatch: 'smoke.spec.ts', testMatch: 'smoke.spec.ts',
globalSetup: './e2e/global-setup.ts',
fullyParallel: false, fullyParallel: false,
workers: 1, workers: 1,
retries: 0, retries: 0,
// Generous: the dev server compiles each route on first visit. timeout: 90_000,
timeout: 240_000, expect: { timeout: 15_000 },
// A first navigation to a route the dev server has not compiled yet can
// take well over ten seconds on a cold cache.
expect: { timeout: 30_000 },
reporter: process.env.CI ? 'github' : 'list', reporter: process.env.CI ? 'github' : 'list',
use: { use: {
baseURL: BASE_URL, baseURL: BASE_URL,
@@ -38,18 +39,20 @@ export default defineConfig({
}, },
], ],
webServer: { webServer: {
command: `pnpm exec next dev -p ${PORT}`, command: [
'docker run --rm --name bricktrack-e2e',
`-p ${PORT}:3000`,
`-e BASE_URL=${BASE_URL}`,
'-e BETTER_AUTH_SECRET=playwright-smoke-test-secret-0123456789abcdef',
'-e TZ=Europe/Berlin',
'-e MAX_UPLOAD_MB=25',
`-v "${dataDir}:/data"`,
'bricktrack:e2e',
].join(' '),
url: `${BASE_URL}/api/health`, url: `${BASE_URL}/api/health`,
reuseExistingServer: false, reuseExistingServer: false,
timeout: 180_000, timeout: 180_000,
stdout: 'pipe', stdout: 'pipe',
stderr: 'pipe', stderr: 'pipe',
env: {
NODE_ENV: 'development',
DATA_DIR: './data-e2e',
BASE_URL,
BETTER_AUTH_SECRET: 'playwright-smoke-test-secret-0123456789abcdef',
MAX_UPLOAD_MB: '25',
},
}, },
}); });
+3
View File
@@ -41,6 +41,9 @@ export default defineConfig({
`-e BASE_URL=${BASE_URL}`, `-e BASE_URL=${BASE_URL}`,
'-e BETTER_AUTH_SECRET=screenshot-run-secret-0123456789abcdefgh', '-e BETTER_AUTH_SECRET=screenshot-run-secret-0123456789abcdefgh',
'-e TZ=Europe/Berlin', '-e TZ=Europe/Berlin',
// A placeholder key so the manual shows the import form rather than the
// "not configured" hint. The screenshot run never submits it.
'-e REBRICKABLE_API_KEY=screenshot-placeholder',
`-v "${dataDir}:/data"`, `-v "${dataDir}:/data"`,
'bricktrack:shots', 'bricktrack:shots',
].join(' '), ].join(' '),
+716 -633
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 332 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

+45
View File
@@ -0,0 +1,45 @@
{
"name": "Bricktrack",
"short_name": "Bricktrack",
"description": "Sammlung und Wunschlisten für Spielzeug-Sets",
"lang": "de",
"dir": "ltr",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "portrait",
"background_color": "#ffffff",
"theme_color": "#0a0a0a",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"shortcuts": [
{
"name": "Fehlende Sets",
"short_name": "Fehlt noch",
"url": "/katalog?besitz=fehlt"
},
{
"name": "Sammlung",
"short_name": "Sammlung",
"url": "/sammlung"
}
]
}
+122
View File
@@ -0,0 +1,122 @@
/**
* Bricktrack service worker.
*
* Goal: the catalog stays readable in the cellar, in the shop, and wherever
* else the phone loses its connection. Anything that changes data still needs
* the network - a wish reserved offline would be a lie.
*
* Caches are versioned; bumping VERSION drops the old ones on activation.
*/
const VERSION = 'v1';
const PAGE_CACHE = `bricktrack-pages-${VERSION}`;
const ASSET_CACHE = `bricktrack-assets-${VERSION}`;
const IMAGE_CACHE = `bricktrack-images-${VERSION}`;
const OFFLINE_URL = '/offline';
self.addEventListener('install', (event) => {
event.waitUntil(
caches
.open(PAGE_CACHE)
.then((cache) => cache.addAll([OFFLINE_URL, '/manifest.webmanifest']))
.then(() => self.skipWaiting()),
);
});
self.addEventListener('activate', (event) => {
const keep = new Set([PAGE_CACHE, ASSET_CACHE, IMAGE_CACHE]);
event.waitUntil(
caches
.keys()
.then((keys) => Promise.all(keys.filter((key) => !keep.has(key)).map((key) => caches.delete(key))))
.then(() => self.clients.claim()),
);
});
/**
* Signing out must not leave readable pages behind on a shared device, so the
* app tells the worker to drop everything it holds.
*/
self.addEventListener('message', (event) => {
if (event.data === 'bricktrack:clear-cache') {
event.waitUntil(caches.keys().then((keys) => Promise.all(keys.map((key) => caches.delete(key)))));
}
});
/** Long-lived, content-addressed build output. */
function isStaticAsset(url) {
return url.pathname.startsWith('/_next/static/') || url.pathname.startsWith('/icons/');
}
/** Set pictures; their file names contain a UUID and never change. */
function isCacheableImage(url) {
return url.pathname.startsWith('/api/files/images/');
}
/** Everything that must never be answered from a cache. */
function isNeverCached(url) {
return (
url.pathname.startsWith('/api/auth/') ||
url.pathname.startsWith('/api/export/') ||
url.pathname.startsWith('/api/health')
);
}
async function cacheFirst(request, cacheName) {
const cache = await caches.open(cacheName);
const hit = await cache.match(request);
if (hit) return hit;
const response = await fetch(request);
if (response.ok) cache.put(request, response.clone());
return response;
}
/**
* Network first, cache as a fallback: the catalog is always current when there
* is a connection, and still readable when there is not.
*/
async function networkFirst(request, cacheName) {
const cache = await caches.open(cacheName);
try {
const response = await fetch(request);
if (response.ok) cache.put(request, response.clone());
return response;
} catch (error) {
const hit = await cache.match(request);
if (hit) return hit;
if (request.mode === 'navigate') {
const offline = await cache.match(OFFLINE_URL);
if (offline) return offline;
}
throw error;
}
}
self.addEventListener('fetch', (event) => {
const { request } = event;
// Only plain GETs are safe to serve from a cache.
if (request.method !== 'GET') return;
const url = new URL(request.url);
if (url.origin !== self.location.origin) return;
if (isNeverCached(url)) return;
if (isStaticAsset(url)) {
event.respondWith(cacheFirst(request, ASSET_CACHE));
return;
}
if (isCacheableImage(url)) {
event.respondWith(cacheFirst(request, IMAGE_CACHE));
return;
}
// Page loads and the RSC payloads behind client-side navigation.
if (request.mode === 'navigate' || url.searchParams.has('_rsc')) {
event.respondWith(networkFirst(request, PAGE_CACHE));
}
});
+29
View File
@@ -0,0 +1,29 @@
/**
* Prepares the smoke run.
*
* The tests run against the production image, not the dev server: the dev
* server compiles routes on demand and, under the load of a full run, would
* occasionally drop a chunk mid-navigation. Testing the artefact that people
* actually deploy is also simply more useful.
*/
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
const dataDir = path.resolve(process.cwd(), 'data-e2e');
fs.rmSync(dataDir, { recursive: true, force: true });
fs.mkdirSync(path.join(dataDir, 'db'), { recursive: true });
fs.mkdirSync(path.join(dataDir, 'files'), { recursive: true });
console.log(`[e2e] ${dataDir} zurueckgesetzt.`);
// A container left behind by an aborted run would hold the port.
try {
execFileSync('docker', ['rm', '-f', 'bricktrack-e2e'], { stdio: 'ignore' });
} catch {
// Nothing to remove - the normal case.
}
console.log('[e2e] Image wird gebaut (bricktrack:e2e) …');
execFileSync('docker', ['build', '-t', 'bricktrack:e2e', '.'], { stdio: 'inherit' });
console.log('[e2e] Image ist bereit.');
+67
View File
@@ -0,0 +1,67 @@
/**
* Renders the app icons from one SVG source.
*
* Run with `node scripts/generate-icons.mjs` after changing the mark; the
* results are committed so neither the build nor the container needs to draw
* anything at runtime.
*/
import fs from 'node:fs';
import path from 'node:path';
import sharp from 'sharp';
const OUT_DIR = path.resolve(process.cwd(), 'public/icons');
// Four studs on a rounded plate - readable down to 32px, which a detailed
// brick drawing would not be.
const mark = (background, foreground) => `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="112" fill="${background}"/>
<g fill="${foreground}">
<rect x="112" y="112" width="128" height="128" rx="28"/>
<rect x="272" y="112" width="128" height="128" rx="28"/>
<rect x="112" y="272" width="128" height="128" rx="28"/>
<rect x="272" y="272" width="128" height="128" rx="28"/>
</g>
</svg>`;
/**
* A maskable icon has to survive being cropped to a circle, so the mark sits
* on a full-bleed background with generous padding.
*/
const maskable = (background, foreground) => `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" fill="${background}"/>
<g fill="${foreground}">
<rect x="146" y="146" width="100" height="100" rx="22"/>
<rect x="266" y="146" width="100" height="100" rx="22"/>
<rect x="146" y="266" width="100" height="100" rx="22"/>
<rect x="266" y="266" width="100" height="100" rx="22"/>
</g>
</svg>`;
const DARK = '#0a0a0a';
const LIGHT = '#fafafa';
const targets = [
{ file: 'icon-192.png', size: 192, svg: mark(DARK, LIGHT) },
{ file: 'icon-512.png', size: 512, svg: mark(DARK, LIGHT) },
{ file: 'icon-maskable-512.png', size: 512, svg: maskable(DARK, LIGHT) },
{ file: 'apple-touch-icon.png', size: 180, svg: mark(DARK, LIGHT) },
];
fs.mkdirSync(OUT_DIR, { recursive: true });
for (const target of targets) {
await sharp(Buffer.from(target.svg))
.resize(target.size, target.size)
.png({ compressionLevel: 9 })
.toFile(path.join(OUT_DIR, target.file));
console.log(`[icons] ${target.file} (${target.size}px)`);
}
// The favicon keeps the same mark so the browser tab matches the home screen.
await sharp(Buffer.from(mark(DARK, LIGHT)))
.resize(32, 32)
.png({ compressionLevel: 9 })
.toFile(path.resolve(process.cwd(), 'public/favicon.png'));
console.log('[icons] favicon.png (32px)');
-13
View File
@@ -1,13 +0,0 @@
/**
* Wipes the Playwright data directory before the smoke run.
*
* Runs before Playwright starts, because Playwright boots its web server
* before global setup - on Windows the open SQLite file would then block the
* delete.
*/
import fs from 'node:fs';
import path from 'node:path';
const dataDir = path.resolve(process.cwd(), 'data-e2e');
fs.rmSync(dataDir, { recursive: true, force: true });
console.log(`[e2e] ${dataDir} zurueckgesetzt.`);
+7
View File
@@ -16,6 +16,13 @@ fs.mkdirSync(path.join(dataDir, 'db'), { recursive: true });
fs.mkdirSync(path.join(dataDir, 'files'), { recursive: true }); fs.mkdirSync(path.join(dataDir, 'files'), { recursive: true });
console.log(`[screenshots] ${dataDir} zurueckgesetzt.`); 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) …'); console.log('[screenshots] Image wird gebaut (bricktrack:shots) …');
execFileSync('docker', ['build', '-t', 'bricktrack:shots', '.'], { stdio: 'inherit' }); execFileSync('docker', ['build', '-t', 'bricktrack:shots', '.'], { stdio: 'inherit' });
console.log('[screenshots] Image ist bereit.'); console.log('[screenshots] Image ist bereit.');
+90 -8
View File
@@ -33,11 +33,12 @@ type SeedSet = {
minifigCount: number | null; minifigCount: number | null;
retailPriceCents: number; retailPriceCents: number;
ageRange: string; ageRange: string;
/** Copies owned - 0 means the set is only in the catalog. */ /** Copies owned - absent means the set is only in the catalog. */
owned?: { owned?: {
status: 'owned' | 'built' | 'disassembled' | 'lent_out' | 'sold'; status: 'owned' | 'built' | 'disassembled' | 'lent_out' | 'sold';
completeness: 'complete' | 'missing_parts' | 'unknown'; completeness: 'complete' | 'missing_parts' | 'unknown';
location: string; location: string;
collector: string;
giftedBy?: string; giftedBy?: string;
}; };
}; };
@@ -59,6 +60,13 @@ const THEMES: Array<{ manufacturer: string; name: string }> = [
const LOCATIONS = ['Regal Kinderzimmer', 'Kiste Keller 3', 'Vitrine Wohnzimmer']; 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[] = [ const SETS: SeedSet[] = [
{ {
manufacturer: 'Lego', manufacturer: 'Lego',
@@ -70,7 +78,12 @@ const SETS: SeedSet[] = [
minifigCount: 4, minifigCount: 4,
retailPriceCents: 4999, retailPriceCents: 4999,
ageRange: '6-12', ageRange: '6-12',
owned: { status: 'built', completeness: 'complete', location: 'Regal Kinderzimmer' }, owned: {
status: 'built',
completeness: 'complete',
location: 'Regal Kinderzimmer',
collector: 'Max',
},
}, },
{ {
manufacturer: 'Lego', manufacturer: 'Lego',
@@ -86,6 +99,7 @@ const SETS: SeedSet[] = [
status: 'owned', status: 'owned',
completeness: 'missing_parts', completeness: 'missing_parts',
location: 'Kiste Keller 3', location: 'Kiste Keller 3',
collector: 'Max',
giftedBy: 'Oma Helga', giftedBy: 'Oma Helga',
}, },
}, },
@@ -99,7 +113,12 @@ const SETS: SeedSet[] = [
minifigCount: null, minifigCount: null,
retailPriceCents: 999, retailPriceCents: 999,
ageRange: '7-14', ageRange: '7-14',
owned: { status: 'disassembled', completeness: 'unknown', location: 'Kiste Keller 3' }, owned: {
status: 'disassembled',
completeness: 'unknown',
location: 'Kiste Keller 3',
collector: 'Mia',
},
}, },
{ {
manufacturer: 'Lego', manufacturer: 'Lego',
@@ -144,7 +163,12 @@ const SETS: SeedSet[] = [
minifigCount: 5, minifigCount: 5,
retailPriceCents: 12999, retailPriceCents: 12999,
ageRange: '4-10', ageRange: '4-10',
owned: { status: 'built', completeness: 'complete', location: 'Regal Kinderzimmer' }, owned: {
status: 'built',
completeness: 'complete',
location: 'Regal Kinderzimmer',
collector: 'Mia',
},
}, },
{ {
manufacturer: 'Playmobil', manufacturer: 'Playmobil',
@@ -241,6 +265,59 @@ function seedThemes(manufacturers: Map<string, string>) {
return byKey; 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() { function seedLocations() {
const byName = new Map<string, string>(); const byName = new Map<string, string>();
@@ -270,6 +347,7 @@ function seedSets(
manufacturers: Map<string, string>, manufacturers: Map<string, string>,
themes: Map<string, string>, themes: Map<string, string>,
locations: Map<string, string>, locations: Map<string, string>,
collectors: Map<string, string>,
) { ) {
const setIds = new Map<string, string>(); const setIds = new Map<string, string>();
@@ -317,6 +395,7 @@ function seedSets(
completeness: entry.owned.completeness, completeness: entry.owned.completeness,
condition: 'good', condition: 'good',
locationId: locations.get(entry.owned.location) ?? null, locationId: locations.get(entry.owned.location) ?? null,
collectorId: collectors.get(entry.owned.collector) ?? null,
acquiredAt: new Date(entry.year, 11, 24), acquiredAt: new Date(entry.year, 11, 24),
acquiredPriceCents: entry.retailPriceCents, acquiredPriceCents: entry.retailPriceCents,
giftedBy: entry.owned.giftedBy ?? null, giftedBy: entry.owned.giftedBy ?? null,
@@ -330,7 +409,7 @@ function seedSets(
return setIds; return setIds;
} }
function seedWishlist(setIds: Map<string, string>) { function seedWishlist(setIds: Map<string, string>, collectors: Map<string, string>) {
const owner = db.select({ id: schema.user.id }).from(schema.user).get(); const owner = db.select({ id: schema.user.id }).from(schema.user).get();
if (!owner) { if (!owner) {
console.log('[seed] Kein Nutzer vorhanden - die Beispiel-Wunschliste wird uebersprungen.'); console.log('[seed] Kein Nutzer vorhanden - die Beispiel-Wunschliste wird uebersprungen.');
@@ -353,6 +432,7 @@ function seedWishlist(setIds: Map<string, string>) {
.values({ .values({
id: wishlistId, id: wishlistId,
ownerUserId: owner.id, ownerUserId: owner.id,
collectorId: collectors.get('Max') ?? null,
title: 'Weihnachten 2026', title: 'Weihnachten 2026',
occasion: 'christmas', occasion: 'christmas',
eventDate: new Date(2026, 11, 24), eventDate: new Date(2026, 11, 24),
@@ -389,10 +469,12 @@ console.log('[seed] Beispieldaten werden eingespielt …');
const manufacturers = seedManufacturers(); const manufacturers = seedManufacturers();
const themes = seedThemes(manufacturers); const themes = seedThemes(manufacturers);
const locations = seedLocations(); const locations = seedLocations();
const setIds = seedSets(manufacturers, themes, locations); const collectors = seedCollectors();
seedWishlist(setIds); const setIds = seedSets(manufacturers, themes, locations, collectors);
seedWishlist(setIds, collectors);
console.log( console.log(
`[seed] Fertig: ${manufacturers.size} Hersteller, ${themes.size} Themen, ${setIds.size} Sets.`, `[seed] Fertig: ${collectors.size} Sammler, ${manufacturers.size} Hersteller, ` +
`${themes.size} Themen, ${setIds.size} Sets.`,
); );
sqlite.close(); sqlite.close();
+38
View File
@@ -0,0 +1,38 @@
'use server';
import { revalidatePath } from 'next/cache';
import { cookies } from 'next/headers';
import { z } from 'zod';
import { env } from '@/env';
import { actionError, actionOk, type ActionResult } from '@/lib/action-result';
import { COLLECTOR_COOKIE } from '@/lib/collector-choice';
import { resolveCollectorId } from '@/lib/queries/collectors';
import { requireUser } from '@/lib/session';
const schema = z.object({ id: z.string().max(64) });
/** Remembers the collector chosen in the header; an empty id means "alle". */
export async function chooseCollectorAction(input: unknown): Promise<ActionResult> {
await requireUser();
const parsed = schema.safeParse(input);
if (!parsed.success) return actionError('Ungültige Auswahl.');
const store = await cookies();
if (parsed.data.id === '') {
store.delete(COLLECTOR_COOKIE);
} else {
const id = resolveCollectorId(parsed.data.id);
if (!id) return actionError('Diesen Sammler gibt es nicht mehr.');
store.set(COLLECTOR_COOKIE, id, {
httpOnly: true,
secure: env.isProduction,
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 24 * 365,
});
}
revalidatePath('/', 'layout');
return actionOk();
}
+63 -15
View File
@@ -1,9 +1,12 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import Link from 'next/link'; import Link from 'next/link';
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
import { Check, ExternalLink, Gift, ImageOff, Pencil } from 'lucide-react'; import { Check, ExternalLink, Gift, Pencil } from 'lucide-react';
import { SetPlaceholder } from '@/components/brick/set-placeholder';
import { Studs } from '@/components/brick/studs';
import { InstructionsSection } from '@/components/katalog/instructions-section'; import { InstructionsSection } from '@/components/katalog/instructions-section';
import { PhotosSection } from '@/components/katalog/photos-section'; import { PhotosSection } from '@/components/katalog/photos-section';
import { PriceSection } from '@/components/katalog/price-section';
import { SetActions } from '@/components/katalog/set-actions'; import { SetActions } from '@/components/katalog/set-actions';
import { CollectionItemsSection } from '@/components/sammlung/collection-items-section'; import { CollectionItemsSection } from '@/components/sammlung/collection-items-section';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@@ -17,24 +20,38 @@ import {
listCollectionItemsForSet, listCollectionItemsForSet,
listInstructionsForSet, listInstructionsForSet,
listPhotosForSet, listPhotosForSet,
listPricePoints,
} from '@/lib/queries/catalog'; } from '@/lib/queries/catalog';
import { listMissingParts } from '@/lib/queries/collection';
import { activeCollectorId } from '@/lib/collector-choice';
import { listCollectors } from '@/lib/queries/collectors';
import { listLocations } from '@/lib/queries/master-data'; import { listLocations } from '@/lib/queries/master-data';
import { listWishlistsForSet, listWishlistTargets } from '@/lib/queries/wishlists'; import { listWishlistsForSet, listWishlistTargets } from '@/lib/queries/wishlists';
import { requireUser } from '@/lib/session'; import { requireUser } from '@/lib/session';
type Props = { params: Promise<{ id: string }> }; type Props = {
params: Promise<{ id: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
};
export async function generateMetadata({ params }: Props): Promise<Metadata> { export async function generateMetadata({ params }: Pick<Props, 'params'>): Promise<Metadata> {
const { id } = await params; const { id } = await params;
const set = getSetById(id); const set = getSetById(id);
return { title: set ? `${set.setNumber} · ${set.name}` : 'Set' }; return { title: set ? `${set.setNumber} · ${set.name}` : 'Set' };
} }
export default async function SetDetailPage({ params }: Props) { export default async function SetDetailPage({ params, searchParams }: Props) {
await requireUser(); await requireUser();
const { id } = await params; const { id } = await params;
const query = await searchParams;
const set = getSetById(id); // Keeps the "vorhanden / fehlt" badge in step with the collector the user
// was looking at in the catalog.
const rawCollector = Array.isArray(query.sammler) ? query.sammler[0] : query.sammler;
const collectorId = await activeCollectorId(rawCollector);
const collectors = listCollectors();
const set = getSetById(id, collectorId);
if (!set) notFound(); if (!set) notFound();
const image = setImageSrc(set); const image = setImageSrc(set);
@@ -42,9 +59,16 @@ export default async function SetDetailPage({ params }: Props) {
const locations = listLocations().map((row) => ({ id: row.id, name: row.name })); const locations = listLocations().map((row) => ({ id: row.id, name: row.name }));
const locationNames = new Map(locations.map((row) => [row.id, 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) => ({ const collectionItems = listCollectionItemsForSet(set.id).map((item) => ({
...item, ...item,
locationName: item.locationId ? (locationNames.get(item.locationId) ?? null) : null, locationName: item.locationId ? (locationNames.get(item.locationId) ?? null) : null,
setName: `${set.setNumber} · ${set.name}`,
missingParts: listMissingParts(item.id),
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]> = [ const facts: Array<[string, string]> = [
@@ -93,15 +117,21 @@ export default async function SetDetailPage({ params }: Props) {
</div> </div>
<div className="grid gap-6 md:grid-cols-[minmax(0,320px)_1fr]"> <div className="grid gap-6 md:grid-cols-[minmax(0,320px)_1fr]">
<div className="overflow-hidden rounded-xl border border-border bg-muted"> <div className="relative self-start">
{image ? ( <Studs count={4} />
// eslint-disable-next-line @next/next/no-img-element <div className="brick overflow-hidden rounded-xl bg-muted">
<img src={image} alt="" className="aspect-square w-full object-contain p-3" /> {image ? (
) : ( // eslint-disable-next-line @next/next/no-img-element
<div className="flex aspect-square items-center justify-center text-muted-foreground"> <img src={image} alt="" className="aspect-square w-full object-contain p-3" />
<ImageOff className="size-10" aria-hidden /> ) : (
</div> <SetPlaceholder
)} setNumber={set.setNumber}
manufacturerName={set.manufacturerName}
size="lg"
className="aspect-square"
/>
)}
</div>
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
@@ -110,6 +140,7 @@ export default async function SetDetailPage({ params }: Props) {
setName={`${set.setNumber} · ${set.name}`} setName={`${set.setNumber} · ${set.name}`}
owned={owned} owned={owned}
wishlists={listWishlistTargets()} 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"> <dl className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm sm:grid-cols-3">
@@ -141,7 +172,24 @@ export default async function SetDetailPage({ params }: Props) {
<CardTitle>In der Sammlung</CardTitle> <CardTitle>In der Sammlung</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<CollectionItemsSection items={collectionItems} locations={locations} /> <CollectionItemsSection
items={collectionItems}
locations={locations}
collectors={collectors.map((row) => ({ id: row.id, name: row.name }))}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Preis und Wertentwicklung</CardTitle>
</CardHeader>
<CardContent>
<PriceSection
setId={set.id}
prices={listPricePoints(set.id)}
retailPriceCents={set.retailPriceCents}
/>
</CardContent> </CardContent>
</Card> </Card>
+25 -1
View File
@@ -5,8 +5,10 @@ import { and, eq, ne } from 'drizzle-orm';
import { db } from '@/db'; import { db } from '@/db';
import { instruction, photo, set } from '@/db/schema'; import { instruction, photo, set } from '@/db/schema';
import { actionData, actionError, actionOk, formToObject, parseForm, type ActionResult } from '@/lib/action-result'; import { actionData, actionError, actionOk, formToObject, parseForm, type ActionResult } from '@/lib/action-result';
import { env } from '@/env';
import { safeFetch, SafeFetchError } from '@/lib/safe-fetch';
import { requireUser } from '@/lib/session'; import { requireUser } from '@/lib/session';
import { deleteStoredFile, storeImage, storePdf, UploadError } from '@/lib/uploads'; import { deleteStoredFile, storeImage, storeImageBytes, storePdf, UploadError } from '@/lib/uploads';
import { optionalText, optionalUrl, setSchema } from '@/lib/validation'; import { optionalText, optionalUrl, setSchema } from '@/lib/validation';
import { z } from 'zod'; import { z } from 'zod';
@@ -20,6 +22,23 @@ function revalidateSet(id?: string) {
if (id) revalidatePath(`/katalog/${id}`); if (id) revalidatePath(`/katalog/${id}`);
} }
async function cacheRemoteImage(url: string, setId: string): Promise<string | null> {
try {
const response = await safeFetch(url, {
accept: 'image/webp,image/png,image/jpeg;q=0.9,*/*;q=0.5',
maxBytes: env.MAX_UPLOAD_BYTES,
});
if (response.status !== 200) return null;
return (await storeImageBytes(response.body, 'set-image', setId)).filePath;
} catch (error) {
if (error instanceof SafeFetchError || error instanceof UploadError) {
console.error('[katalog] Bild konnte nicht übernommen werden', url, error.message);
return null;
}
throw error;
}
}
function fileFrom(formData: FormData, field: string): File | null { function fileFrom(formData: FormData, field: string): File | null {
const value = formData.get(field); const value = formData.get(field);
return value instanceof File && value.size > 0 ? value : null; return value instanceof File && value.size > 0 ? value : null;
@@ -60,12 +79,17 @@ export async function createSet(formData: FormData): Promise<ActionResult<{ id:
if (error instanceof UploadError) return actionError(error.message); if (error instanceof UploadError) return actionError(error.message);
throw error; throw error;
} }
} else if (formData.get('cacheImage') === 'on' && parsed.data.imageUrl) {
// A picture found by the link import is kept locally, like the Lego
// import does. If that fails, the URL stays as the fallback.
imagePath = await cacheRemoteImage(parsed.data.imageUrl, id);
} }
db.insert(set) db.insert(set)
.values({ .values({
id, id,
...parsed.data, ...parsed.data,
imageUrl: imagePath ? null : parsed.data.imageUrl,
imagePath, imagePath,
createdAt: now(), createdAt: now(),
updatedAt: now(), updatedAt: now(),
+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,
},
});
}
+225
View File
@@ -0,0 +1,225 @@
'use server';
import { and, eq } from 'drizzle-orm';
import { z } from 'zod';
import { db } from '@/db';
import { manufacturer, set } from '@/db/schema';
import { actionData, actionError, type ActionResult } from '@/lib/action-result';
import {
cleanSetName,
hintsFromHtml,
hintsFromUrl,
mergeHints,
sourceLabel,
type LinkHints,
} from '@/lib/link-import';
import { fetchMinifigCount, fetchSet, isRebrickableEnabled, RebrickableError } from '@/lib/rebrickable';
import { safeFetch, SafeFetchError } from '@/lib/safe-fetch';
import { requireUser } from '@/lib/session';
import { uniqueSlug } from '@/lib/slug';
/**
* "Aus einem Link übernehmen": reads a set out of a shop, price-comparison or
* database link and returns a draft for the form. Nothing is saved here except
* a missing manufacturer - the form needs it to be selectable.
*
* Amazon, idealo and geizhals usually refuse requests from a server; then the
* link itself (brand, number, words in the address) is the starting point,
* and Rebrickable (Lego) or Merlins Bricks (BlueBrixx, Cobi) fill in the rest.
*/
export type LinkPreview = {
manufacturerId: string | null;
manufacturerName: string | null;
setNumber: string;
name: string;
year: number | null;
pieceCount: number | null;
minifigCount: number | null;
ean: string;
imageUrl: string;
sourceUrl: string;
/** Where the data came from, for the confirmation message. */
sources: string[];
/** The shop did not let us read the page; only the address was used. */
pageBlocked: boolean;
/** The set is already in the catalog. */
existingSetId: string | null;
};
const inputSchema = z.object({
url: z
.string()
.trim()
.min(1, 'Bitte einen Link einfügen.')
.max(2000, 'Der Link ist zu lang.')
.url('Das ist kein gültiger Link.')
.refine((value) => /^https?:\/\//i.test(value), 'Nur http- und https-Links.'),
});
/** Manufacturer website for the ones we create on the fly. */
const WEBSITES: Record<string, string> = {
Lego: 'https://www.lego.com/de-de',
Playmobil: 'https://www.playmobil.de',
Cobi: 'https://cobi.pl',
BlueBrixx: 'https://www.bluebrixx.com',
'Mega Bloks': 'https://www.megabrands.com',
};
/** Merlins Bricks keys its set pages by a brand prefix. */
const MERLIN_PREFIX: Record<string, string> = { BlueBrixx: 'bb', Cobi: 'cobi' };
function key(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9]/g, '');
}
function ensureManufacturer(name: string): string {
const rows = db
.select({ id: manufacturer.id, name: manufacturer.name, slug: manufacturer.slug })
.from(manufacturer)
.all();
const match = rows.find((row) => key(row.name) === key(name) || row.slug === key(name));
if (match) return match.id;
const id = crypto.randomUUID();
const now = new Date();
db.insert(manufacturer)
.values({
id,
name,
slug: uniqueSlug(name, rows.map((row) => row.slug)),
website: WEBSITES[name] ?? null,
createdAt: now,
updatedAt: now,
})
.run();
return id;
}
async function readPage(url: URL): Promise<{ hints: LinkHints | null; finalUrl: URL; blocked: boolean }> {
const response = await safeFetch(url);
const finalUrl = new URL(response.url);
const isHtml = /html|xml/i.test(response.contentType);
if (response.status !== 200 || !isHtml) return { hints: null, finalUrl, blocked: true };
return { hints: hintsFromHtml(response.body.toString('utf8'), finalUrl), finalUrl, blocked: false };
}
async function fromRebrickable(setNumber: string): Promise<(LinkHints & { minifigCount?: number }) | null> {
if (!isRebrickableEnabled()) return null;
try {
const remote = await fetchSet(setNumber);
if (!remote) return null;
const minifigs = await fetchMinifigCount(remote.setNumber);
return {
name: remote.name,
year: remote.year ?? undefined,
pieceCount: remote.pieceCount ?? undefined,
imageUrl: remote.imageUrl ?? undefined,
minifigCount: minifigs ?? undefined,
};
} catch (error) {
if (error instanceof RebrickableError) return null;
throw error;
}
}
async function fromMerlinsBricks(brand: string, setNumber: string): Promise<LinkHints | null> {
const prefix = MERLIN_PREFIX[brand];
if (!prefix || !/^[\w-]+$/.test(setNumber)) return null;
try {
const page = await readPage(new URL(`https://www.merlinsbricks.com/sets/${prefix}-${setNumber}/`));
return page.hints;
} catch {
return null;
}
}
export async function previewSetFromLink(input: unknown): Promise<ActionResult<LinkPreview>> {
await requireUser();
const parsed = inputSchema.safeParse(input);
if (!parsed.success) return actionError(parsed.error.issues[0]?.message ?? 'Ungültiger Link.');
const url = new URL(parsed.data.url);
const sources: string[] = [];
// 1. The page itself, if it lets us in. A short link (amzn.eu/...) still
// tells us where it leads, even when the target then refuses.
let pageHints: LinkHints | null = null;
let finalUrl = url;
let pageBlocked = true;
try {
const page = await readPage(url);
pageHints = page.hints;
finalUrl = page.finalUrl;
pageBlocked = page.blocked;
} catch (error) {
if (!(error instanceof SafeFetchError)) throw error;
if (/intern/i.test(error.message)) return actionError(error.message);
}
if (pageHints) sources.push(sourceLabel(finalUrl));
// 2. The address.
const addressHints = mergeHints(hintsFromUrl(finalUrl), hintsFromUrl(url));
if (!pageHints || pageBlocked) sources.push(`Link (${sourceLabel(finalUrl)})`);
let hints = mergeHints(pageHints, addressHints);
let minifigCount: number | null = null;
// 3. A set database for the brand.
if (hints.manufacturer === 'Lego' && hints.setNumber) {
const remote = await fromRebrickable(hints.setNumber);
if (remote) {
// The shop's German name beats Rebrickable's English one; Rebrickable's
// facts and clean picture beat the shop's.
hints = mergeHints({ name: pageHints?.name ?? addressHints.name, ean: hints.ean }, remote, hints);
minifigCount = remote.minifigCount ?? null;
sources.push('Rebrickable');
}
} else if (hints.manufacturer && hints.setNumber && !/merlinsbricks\./.test(finalUrl.hostname)) {
const merlin = await fromMerlinsBricks(hints.manufacturer, hints.setNumber);
if (merlin) {
hints = mergeHints(hints, merlin);
sources.push('Merlins Bricks');
}
}
if (!hints.name && !hints.setNumber) {
return actionError(
pageBlocked
? `${sourceLabel(finalUrl)} lässt keine Abfrage zu, und der Link selbst verrät nichts über das Set. Versuch es mit einem anderen Link oder trag das Set von Hand ein.`
: 'Auf der Seite stehen keine Produktdaten, mit denen sich etwas anfangen lässt.',
);
}
const manufacturerName = hints.manufacturer ?? null;
const manufacturerId = manufacturerName ? ensureManufacturer(manufacturerName) : null;
const setNumber = hints.setNumber ?? '';
const existing =
manufacturerId && setNumber
? db
.select({ id: set.id })
.from(set)
.where(and(eq(set.manufacturerId, manufacturerId), eq(set.setNumber, setNumber)))
.get()
: undefined;
return actionData({
manufacturerId,
manufacturerName,
setNumber,
name: hints.name ? cleanSetName(hints.name, manufacturerName ?? undefined, setNumber) : '',
year: hints.year ?? null,
pieceCount: hints.pieceCount ?? null,
minifigCount,
ean: hints.ean ?? '',
imageUrl: hints.imageUrl ?? '',
sourceUrl: url.toString(),
sources,
pageBlocked,
existingSetId: existing?.id ?? null,
});
}
+26 -16
View File
@@ -1,8 +1,10 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import Link from 'next/link'; import Link from 'next/link';
import { SetForm } from '@/components/katalog/set-form'; import { NewSetEntry } from '@/components/katalog/link-import';
import { RebrickableImport } from '@/components/katalog/rebrickable-import';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { listManufacturers, listThemes } from '@/lib/queries/master-data'; import { listManufacturers, listThemes } from '@/lib/queries/master-data';
import { isRebrickableEnabled } from '@/lib/rebrickable';
import { requireUser } from '@/lib/session'; import { requireUser } from '@/lib/session';
export const metadata: Metadata = { title: 'Neues Set' }; export const metadata: Metadata = { title: 'Neues Set' };
@@ -13,6 +15,8 @@ export default async function NeuesSetPage({ searchParams }: { searchParams: Sea
await requireUser(); await requireUser();
const params = await searchParams; const params = await searchParams;
const ean = typeof params.ean === 'string' ? params.ean : undefined; const ean = typeof params.ean === 'string' ? params.ean : undefined;
// Which way the plus menu chose: the matching field gets the cursor.
const way = typeof params.weg === 'string' ? params.weg : undefined;
const manufacturers = listManufacturers().map((row) => ({ id: row.id, name: row.name })); const manufacturers = listManufacturers().map((row) => ({ id: row.id, name: row.name }));
const themes = listThemes().map((row) => ({ const themes = listThemes().map((row) => ({
@@ -21,21 +25,10 @@ export default async function NeuesSetPage({ searchParams }: { searchParams: Sea
manufacturerId: row.manufacturerId, manufacturerId: row.manufacturerId,
})); }));
if (manufacturers.length === 0) { const importEnabled = isRebrickableEnabled();
return (
<div className="space-y-4">
<h1 className="text-2xl font-semibold tracking-tight">Neues Set</h1>
<p className="rounded-lg border border-dashed border-border p-6 text-sm text-muted-foreground">
Jedes Set gehört zu einem Hersteller. Leg zuerst einen an – Lego, Playmobil, Cobi oder
was auch immer im Kinderzimmer steht.
</p>
<Button asChild size="lg">
<Link href="/verwaltung/hersteller">Hersteller anlegen</Link>
</Button>
</div>
);
}
// One layout for both cases: the link import may create the first
// manufacturer, and its draft must survive the refresh that follows.
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
@@ -45,7 +38,24 @@ export default async function NeuesSetPage({ searchParams }: { searchParams: Sea
Sammlung hinzufügen“ fest. Sammlung hinzufügen“ fest.
</p> </p>
</div> </div>
<SetForm manufacturers={manufacturers} themes={themes} defaultEan={ean} /> {/* The Lego import creates its manufacturer itself, so it works even
on a completely empty instance. */}
<RebrickableImport enabled={importEnabled} autoFocus={way === 'lego'} />
<NewSetEntry
manufacturers={manufacturers}
themes={themes}
defaultEan={ean}
focus={way === 'link' || way === 'hand' ? way : undefined}
>
<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>
</NewSetEntry>
</div> </div>
); );
} }
+20 -8
View File
@@ -1,6 +1,7 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import Link from 'next/link'; import Link from 'next/link';
import { Plus } from 'lucide-react'; import { Plus } from 'lucide-react';
import { BarcodeScannerButton } from '@/components/katalog/barcode-scanner';
import { CatalogFilters } from '@/components/katalog/catalog-filters'; import { CatalogFilters } from '@/components/katalog/catalog-filters';
import { CatalogGrid } from '@/components/katalog/catalog-grid'; import { CatalogGrid } from '@/components/katalog/catalog-grid';
import { Pagination } from '@/components/katalog/pagination'; import { Pagination } from '@/components/katalog/pagination';
@@ -8,6 +9,8 @@ import { Button } from '@/components/ui/button';
import { pluralize } from '@/lib/format'; import { pluralize } from '@/lib/format';
import { OWNERSHIP_FILTER_LABELS, parseOwnershipFilter } from '@/lib/ownership'; import { OWNERSHIP_FILTER_LABELS, parseOwnershipFilter } from '@/lib/ownership';
import { CATALOG_PAGE_SIZE, listSets, listSetYears } from '@/lib/queries/catalog'; import { CATALOG_PAGE_SIZE, listSets, listSetYears } from '@/lib/queries/catalog';
import { activeCollectorId } from '@/lib/collector-choice';
import { listCollectors } from '@/lib/queries/collectors';
import { listManufacturers, listThemes } from '@/lib/queries/master-data'; import { listManufacturers, listThemes } from '@/lib/queries/master-data';
import { listWishlistTargets } from '@/lib/queries/wishlists'; import { listWishlistTargets } from '@/lib/queries/wishlists';
import { requireUser } from '@/lib/session'; import { requireUser } from '@/lib/session';
@@ -26,6 +29,9 @@ export default async function KatalogPage({ searchParams }: { searchParams: Sear
const params = await searchParams; const params = await searchParams;
const ownership = parseOwnershipFilter(single(params.besitz)); const ownership = parseOwnershipFilter(single(params.besitz));
const collectors = listCollectors();
const collectorId = await activeCollectorId(single(params.sammler));
const activeCollector = collectors.find((row) => row.id === collectorId) ?? null;
const yearParam = single(params.jahr); const yearParam = single(params.jahr);
const page = Number(single(params.seite) ?? '1') || 1; const page = Number(single(params.seite) ?? '1') || 1;
@@ -35,6 +41,7 @@ export default async function KatalogPage({ searchParams }: { searchParams: Sear
themeId: single(params.thema), themeId: single(params.thema),
year: yearParam ? Number(yearParam) : undefined, year: yearParam ? Number(yearParam) : undefined,
ownership, ownership,
collectorId,
page, page,
}; };
@@ -56,16 +63,21 @@ export default async function KatalogPage({ searchParams }: { searchParams: Sear
</h1> </h1>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{ownership === 'fehlt' {ownership === 'fehlt'
? 'Sets, die noch nicht in der Sammlung sind.' ? activeCollector
? `Sets, die ${activeCollector.name} noch nicht hat.`
: 'Sets, die noch nicht in der Sammlung sind.'
: 'Alle bekannten Sets – unabhängig davon, ob sie schon da sind.'} : 'Alle bekannten Sets – unabhängig davon, ob sie schon da sind.'}
</p> </p>
</div> </div>
<Button asChild size="lg" className="shrink-0"> <div className="flex shrink-0 gap-2">
<Link href="/katalog/neu"> <BarcodeScannerButton />
<Plus /> <Button asChild size="lg">
<span className="hidden sm:inline">Set anlegen</span> <Link href="/katalog/neu">
</Link> <Plus />
</Button> <span className="hidden sm:inline">Set anlegen</span>
</Link>
</Button>
</div>
</div> </div>
<CatalogFilters options={{ manufacturers, themes, years: listSetYears() }} /> <CatalogFilters options={{ manufacturers, themes, years: listSetYears() }} />
@@ -75,7 +87,7 @@ export default async function KatalogPage({ searchParams }: { searchParams: Sear
{ownership !== 'alle' ? ` · ${OWNERSHIP_FILTER_LABELS[ownership]}` : ''} {ownership !== 'alle' ? ` · ${OWNERSHIP_FILTER_LABELS[ownership]}` : ''}
</p> </p>
<CatalogGrid sets={rows} wishlists={wishlists} /> <CatalogGrid sets={rows} wishlists={wishlists} collectorId={collectorId} />
<Pagination page={page} total={total} pageSize={CATALOG_PAGE_SIZE} /> <Pagination page={page} total={total} pageSize={CATALOG_PAGE_SIZE} />
</div> </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 });
}
+28 -1
View File
@@ -1,12 +1,16 @@
'use server'; 'use server';
import { revalidatePath } from 'next/cache'; import { revalidatePath } from 'next/cache';
import { headers } from 'next/headers'; import { cookies, headers } from 'next/headers';
import { z } from 'zod'; import { z } from 'zod';
import { env } from '@/env';
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
import { DESIGN_COOKIE, DESIGNS } from '@/lib/design';
import { requireUser } from '@/lib/session';
const idSchema = z.object({ id: z.string().min(1) }); const idSchema = z.object({ id: z.string().min(1) });
const renameSchema = idSchema.extend({ name: z.string().trim().min(1).max(60) }); const renameSchema = idSchema.extend({ name: z.string().trim().min(1).max(60) });
const designSchema = z.object({ design: z.enum(DESIGNS) });
export type ActionResult = { ok: true } | { ok: false; error: string }; export type ActionResult = { ok: true } | { ok: false; error: string };
@@ -43,3 +47,26 @@ export async function renamePasskeyAction(input: unknown): Promise<ActionResult>
revalidatePath('/konto'); revalidatePath('/konto');
return { ok: true }; return { ok: true };
} }
/**
* The design is a per-device choice: a cookie, not a column on the user. That
* way it also covers the login page and lets two children's tablets look
* different.
*/
export async function setDesignAction(input: unknown): Promise<ActionResult> {
await requireUser();
const parsed = designSchema.safeParse(input);
if (!parsed.success) return { ok: false, error: 'Unbekanntes Design.' };
(await cookies()).set(DESIGN_COOKIE, parsed.data.design, {
httpOnly: true,
secure: env.isProduction,
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 24 * 365,
});
revalidatePath('/', 'layout');
return { ok: true };
}
+16 -1
View File
@@ -1,9 +1,11 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { headers } from 'next/headers'; import { cookies, headers } from 'next/headers';
import { PasskeyManager, type PasskeyRow } from '@/components/auth/passkey-manager'; import { PasskeyManager, type PasskeyRow } from '@/components/auth/passkey-manager';
import { DesignPicker } from '@/components/konto/design-picker';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
import { DESIGN_COOKIE, resolveDesign } from '@/lib/design';
import { requireUser } from '@/lib/session'; import { requireUser } from '@/lib/session';
export const metadata: Metadata = { title: 'Konto' }; export const metadata: Metadata = { title: 'Konto' };
@@ -17,6 +19,7 @@ export default async function AccountPage() {
name: passkey.name ?? null, name: passkey.name ?? null,
createdAt: passkey.createdAt ? new Date(passkey.createdAt) : null, createdAt: passkey.createdAt ? new Date(passkey.createdAt) : null,
})); }));
const design = resolveDesign((await cookies()).get(DESIGN_COOKIE)?.value);
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -45,6 +48,18 @@ export default async function AccountPage() {
</CardContent> </CardContent>
</Card> </Card>
<Card>
<CardHeader>
<CardTitle>Design</CardTitle>
<CardDescription>
Gilt für dieses Gerät. Öffentliche Wunschlisten sehen Gäste im Standard-Design.
</CardDescription>
</CardHeader>
<CardContent>
<DesignPicker current={design} />
</CardContent>
</Card>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Passkeys</CardTitle> <CardTitle>Passkeys</CardTitle>
+22 -3
View File
@@ -1,16 +1,35 @@
import { AppHeader } from '@/components/layout/app-header'; import { AppHeader } from '@/components/layout/app-header';
import { BottomNav } from '@/components/layout/bottom-nav'; import { BottomNav } from '@/components/layout/bottom-nav';
import { storedCollectorId } from '@/lib/collector-choice';
import { listCollectors } from '@/lib/queries/collectors';
import { isRebrickableEnabled } from '@/lib/rebrickable';
import { ensureReminderScheduler } from '@/lib/reminder-job';
import { requireUser } from '@/lib/session'; import { requireUser } from '@/lib/session';
export default async function AppLayout({ children }: { children: React.ReactNode }) { 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(); const user = await requireUser();
const collectors = listCollectors().map((row) => ({
id: row.id,
name: row.name,
color: row.color,
}));
const legoImport = isRebrickableEnabled();
return ( return (
<div className="flex min-h-dvh flex-col"> <div className="flex min-h-dvh flex-col">
<AppHeader user={user} /> <AppHeader
user={user}
collectors={collectors}
storedCollectorId={(await storedCollectorId()) ?? null}
legoImport={legoImport}
/>
{/* Bottom padding keeps the mobile nav from covering the last row. */} {/* 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> <main className="mx-auto w-full max-w-5xl flex-1 px-4 pt-4 pb-28 md:pb-10">{children}</main>
<BottomNav /> <BottomNav legoImport={legoImport} />
</div> </div>
); );
} }
+66
View File
@@ -0,0 +1,66 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { cn } from 'cn';
import { Studs } from '@/components/brick/studs';
import { MORE_GROUPS, moreItemsFor } from '@/components/layout/more-items';
import { SignOutButton } from '@/components/layout/sign-out-button';
import { requireUser } from '@/lib/session';
export const metadata: Metadata = { title: 'Mehr' };
const GROUP_TONES: Record<string, string> = {
ich: 'bg-tone-1',
familie: 'bg-tone-2',
katalog: 'bg-tone-3',
daten: 'bg-tone-4',
};
export default async function MehrPage() {
const user = await requireUser();
const items = moreItemsFor(user.role);
return (
<div className="space-y-7">
<div className="flex items-center gap-3">
<div>
<h1 className="text-2xl font-semibold tracking-tight">{user.name}</h1>
<p className="text-sm text-muted-foreground">
{user.email} · {user.role === 'admin' ? 'Administrator' : 'Mitglied'}
</p>
</div>
</div>
{MORE_GROUPS.map((group) => {
const entries = items.filter((item) => item.group === group.id);
if (entries.length === 0) return null;
return (
<section key={group.id} className="space-y-4">
<h2 className="text-xs font-bold tracking-widest text-muted-foreground uppercase">
{group.label}
</h2>
<ul className="grid grid-cols-2 gap-x-3 gap-y-5 sm:grid-cols-3">
{entries.map((item) => (
<li key={item.href} className="relative">
<Studs studClassName={GROUP_TONES[group.id]} />
<Link
href={item.href}
className={cn(
'brick flex h-full flex-col gap-1 rounded-xl p-3.5 text-tone-ink outline-none focus-visible:ring-3 focus-visible:ring-ring/50',
GROUP_TONES[group.id],
)}
>
<item.icon className="size-6" aria-hidden />
<span className="font-bold">{item.label}</span>
<span className="text-xs leading-snug opacity-80">{item.description}</span>
</Link>
</li>
))}
</ul>
</section>
);
})}
<SignOutButton />
</div>
);
}
+186 -46
View File
@@ -1,58 +1,198 @@
import Link from 'next/link'; import Link from 'next/link';
import { Boxes, Gift, LayoutGrid, PackageSearch } from 'lucide-react'; import { ChevronRight, Gift, PackageSearch, Plus } from 'lucide-react';
import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { cn } from 'cn';
import { Studs } from '@/components/brick/studs';
import { Button } from '@/components/ui/button';
import { brickRow } from '@/lib/brick-row';
import { activeCollectorId } from '@/lib/collector-choice';
import { collectorDotClass } from '@/lib/collector-colors';
import { formatDate, formatNumber, pluralize } from '@/lib/format';
import { formatEuro } from '@/lib/money';
import { countMissingSets, getCollectionStats } from '@/lib/queries/collection';
import { countItemsPerCollector, listCollectors } from '@/lib/queries/collectors';
import { listWishlists } from '@/lib/queries/wishlists';
import { daysUntil } from '@/lib/reminders';
import { requireUser } from '@/lib/session'; import { requireUser } from '@/lib/session';
const shortcuts = [ function daysLabel(days: number): string {
{ if (days === 0) return 'heute';
href: '/katalog', if (days === 1) return 'morgen';
icon: LayoutGrid, return `in ${days} Tagen`;
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() { type SearchParams = Promise<Record<string, string | string[] | undefined>>;
export default async function DashboardPage({ searchParams }: { searchParams: SearchParams }) {
const user = await requireUser(); const user = await requireUser();
const params = await searchParams;
const rawCollector = Array.isArray(params.sammler) ? params.sammler[0] : params.sammler;
// With a child chosen in the header, the start page is about that child.
const collectorId = await activeCollectorId(rawCollector);
const stats = getCollectionStats(collectorId);
const missing = countMissingSets(collectorId);
const totalSets = stats.setCount + missing;
const collectors = listCollectors();
const activeCollector = collectors.find((entry) => entry.id === collectorId) ?? null;
const perCollector = countItemsPerCollector();
const showCollectors = collectors.length > 1 && !activeCollector;
const bricks = brickRow(
stats.setCount,
totalSets,
activeCollector
? [{ key: activeCollector.id, count: 1 }]
: showCollectors
? collectors.map((entry) => ({ key: entry.id, count: perCollector.get(entry.id) ?? 0 }))
: [],
);
const colorById = new Map(collectors.map((entry) => [entry.id, entry.color]));
// The next date someone could bring a present for.
const now = new Date();
const nextEvent =
listWishlists(user.id)
.filter((list) => list.eventDate && daysUntil(list.eventDate, now) >= 0)
.sort((a, b) => a.eventDate!.getTime() - b.eventDate!.getTime())[0] ?? null;
return ( return (
<div className="space-y-6"> <div className="mx-auto max-w-2xl space-y-7">
<div> <h1 className="text-3xl font-semibold tracking-tight">Hallo {user.name}!</h1>
<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"> <section className="relative">
{shortcuts.map((shortcut) => ( <Studs count={3} />
<Link key={shortcut.href} href={shortcut.href} className="group"> <div className="brick rounded-xl bg-card p-4 text-card-foreground">
<Card className="h-full transition-colors group-hover:border-primary/40"> <div className="flex items-baseline justify-between gap-3">
<CardHeader> <h2 className="text-lg font-semibold">
<shortcut.icon className="size-6 text-primary" aria-hidden /> {activeCollector ? `Sammlung von ${activeCollector.name}` : 'Unsere Sammlung'}
<CardTitle>{shortcut.title}</CardTitle> </h2>
<CardDescription>{shortcut.description}</CardDescription> <p className="text-sm font-semibold text-muted-foreground">
</CardHeader> {formatNumber(stats.setCount)} von {pluralize(totalSets, 'Set', 'Sets')}
</Card> </p>
</div>
<ol
aria-label={`${formatNumber(stats.setCount)} von ${formatNumber(totalSets)} Sets vorhanden`}
className="mt-5 grid grid-cols-10 items-end gap-1.5"
>
{bricks.map((key, index) => (
<li key={index} aria-hidden className="relative">
{key === null ? (
<span className="block h-7 rounded-[6px] border-2 border-dashed border-muted-foreground/50" />
) : (
<>
<span
className={cn(
'stud mx-auto mb-[calc(var(--edge-w)*-1)] !h-1.5 !w-3 !rounded-t-[4px] [display:var(--studs)]',
key ? collectorDotClass(colorById.get(key)) : 'bg-secondary',
)}
/>
<span
className={cn(
'block h-7 rounded-[6px] border-[length:var(--edge-w)] border-edge',
key ? collectorDotClass(colorById.get(key)) : 'bg-secondary',
)}
/>
</>
)}
</li>
))}
</ol>
{showCollectors ? (
<ul className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs font-semibold">
{collectors.map((entry) => (
<li key={entry.id} className="flex items-center gap-1.5">
<span
aria-hidden
className={cn(
'size-3 rounded-[3px] border-[length:var(--edge-w)] border-edge',
collectorDotClass(entry.color),
)}
/>
{entry.name}
</li>
))}
<li className="flex items-center gap-1.5 text-muted-foreground">
<span
aria-hidden
className="size-3 rounded-[3px] border-2 border-dashed border-muted-foreground/50"
/>
fehlt noch
</li>
</ul>
) : null}
</div>
</section>
<section className="grid grid-cols-2 gap-3">
<div className="relative">
<Studs studClassName="bg-secondary" />
<div className="brick h-full rounded-xl bg-secondary p-4 text-secondary-foreground">
<p className="text-2xl font-bold tabular-nums [font-family:var(--font-display)]">
{formatNumber(stats.pieceCount)}
</p>
<p className="text-sm font-semibold">Teile verbaut</p>
</div>
</div>
<div className="relative">
<Studs studClassName="bg-owned" />
<div className="brick h-full rounded-xl bg-owned p-4 text-owned-foreground">
<p className="text-2xl font-bold tabular-nums [font-family:var(--font-display)]">
{formatEuro(stats.currentValueCents)}
</p>
<p className="text-sm font-semibold">
{stats.currentValueCents === stats.retailValueCents ? 'Wert nach UVP' : 'Aktueller Wert'}
</p>
</div>
</div>
</section>
{nextEvent?.eventDate ? (
<Link href={`/wunschlisten/${nextEvent.id}`} className="relative block rounded-xl">
<Studs count={4} studClassName="bg-primary" />
<div className="brick flex items-center gap-4 rounded-xl bg-primary p-4 text-primary-foreground">
<div className="flex size-14 shrink-0 flex-col items-center justify-center rounded-lg border-[length:var(--edge-w)] border-edge bg-card text-card-foreground">
<span className="text-xl leading-none font-bold [font-family:var(--font-display)]">
{formatDate(nextEvent.eventDate).slice(0, 5)}
</span>
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-lg font-semibold [font-family:var(--font-display)]">
{nextEvent.title}
</p>
<p className="text-sm font-semibold">
{daysLabel(daysUntil(nextEvent.eventDate, now))} ·{' '}
{nextEvent.openCount === 1
? '1 Wunsch noch offen'
: `${formatNumber(nextEvent.openCount)} Wünsche noch offen`}
</p>
</div>
<ChevronRight className="size-5 shrink-0" aria-hidden />
</div>
</Link>
) : null}
<section className="flex flex-wrap gap-3">
<Button asChild variant="outline" size="lg">
<Link href="/katalog?besitz=fehlt">
<PackageSearch aria-hidden />
{missing === 1 ? '1 Set fehlt noch' : `${formatNumber(missing)} Sets fehlen noch`}
</Link> </Link>
))} </Button>
</div> <Button asChild variant="outline" size="lg">
<Link href="/wunschlisten">
<Gift aria-hidden />
Wunschlisten
</Link>
</Button>
<Button asChild size="lg">
<Link href="/katalog/neu">
<Plus aria-hidden />
Set anlegen
</Link>
</Button>
</section>
</div> </div>
); );
} }
+133 -4
View File
@@ -1,9 +1,9 @@
'use server'; 'use server';
import { revalidatePath } from 'next/cache'; import { revalidatePath } from 'next/cache';
import { eq } from 'drizzle-orm'; import { and, eq } from 'drizzle-orm';
import { db } from '@/db'; import { db } from '@/db';
import { collectionItem, set } from '@/db/schema'; import { collectionItem, missingPart, set } from '@/db/schema';
import { import {
actionData, actionData,
actionError, actionError,
@@ -11,9 +11,10 @@ import {
parseForm, parseForm,
type ActionResult, type ActionResult,
} from '@/lib/action-result'; } from '@/lib/action-result';
import { defaultCollectorId } from '@/lib/queries/collectors';
import { reconcileFulfilledWishlistItems } from '@/lib/queries/wishlists'; import { reconcileFulfilledWishlistItems } from '@/lib/queries/wishlists';
import { requireUser } from '@/lib/session'; import { requireUser } from '@/lib/session';
import { collectionItemSchema } from '@/lib/validation'; import { collectionItemSchema, missingPartSchema } from '@/lib/validation';
import type { Condition } from '@/db/types'; import type { Condition } from '@/db/types';
function now() { function now() {
@@ -32,17 +33,24 @@ function revalidateCollection(setId?: string) {
* Quick add from the set detail page: one tap, sensible defaults, details can * Quick add from the set detail page: one tap, sensible defaults, details can
* be filled in later. * be filled in later.
*/ */
export async function addSetToCollection(setId: string): Promise<ActionResult<{ id: string }>> { export async function addSetToCollection(
setId: string,
collectorId?: string,
): Promise<ActionResult<{ id: string }>> {
await requireUser(); await requireUser();
const exists = db.select({ id: set.id }).from(set).where(eq(set.id, setId)).get(); 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.'); 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(); const id = crypto.randomUUID();
db.insert(collectionItem) db.insert(collectionItem)
.values({ .values({
id, id,
setId, setId,
collectorId: owner,
status: 'owned', status: 'owned',
completeness: 'unknown', completeness: 'unknown',
createdAt: now(), createdAt: now(),
@@ -68,6 +76,7 @@ export async function updateCollectionItem(id: string, input: unknown): Promise<
db.update(collectionItem) db.update(collectionItem)
.set({ .set({
collectorId: parsed.data.collectorId ?? existing.collectorId,
status: parsed.data.status, status: parsed.data.status,
completeness: parsed.data.completeness, completeness: parsed.data.completeness,
condition: parsed.data.condition as Condition | null, condition: parsed.data.condition as Condition | null,
@@ -103,3 +112,123 @@ export async function deleteCollectionItem(id: string): Promise<ActionResult> {
revalidateCollection(existing.setId); revalidateCollection(existing.setId);
return actionOk(); return actionOk();
} }
// ---------------------------------------------------------------------------
// Missing parts
// ---------------------------------------------------------------------------
/**
* Keeps `completeness` honest without ever fighting the user.
*
* The moment a part is reported missing the item is not complete any more, and
* once the last open entry is resolved it is complete again. A deliberate
* "unknown" is left alone in the second direction only.
*/
function syncCompleteness(collectionItemId: string): void {
const open = db
.select({ id: missingPart.id })
.from(missingPart)
.where(and(eq(missingPart.collectionItemId, collectionItemId), eq(missingPart.resolved, false)))
.all();
const item = db
.select({ completeness: collectionItem.completeness })
.from(collectionItem)
.where(eq(collectionItem.id, collectionItemId))
.get();
if (!item) return;
if (open.length > 0 && item.completeness !== 'missing_parts') {
db.update(collectionItem)
.set({ completeness: 'missing_parts', updatedAt: now() })
.where(eq(collectionItem.id, collectionItemId))
.run();
return;
}
if (open.length === 0 && item.completeness === 'missing_parts') {
db.update(collectionItem)
.set({ completeness: 'complete', updatedAt: now() })
.where(eq(collectionItem.id, collectionItemId))
.run();
}
}
function loadItemSetId(collectionItemId: string): string | null {
const row = db
.select({ setId: collectionItem.setId })
.from(collectionItem)
.where(eq(collectionItem.id, collectionItemId))
.get();
return row?.setId ?? null;
}
export async function addMissingPart(
collectionItemId: string,
input: unknown,
): Promise<ActionResult> {
await requireUser();
const parsed = parseForm(missingPartSchema, input);
if (!parsed.ok) return parsed;
const setId = loadItemSetId(collectionItemId);
if (!setId) return actionError('Diesen Sammlungseintrag gibt es nicht mehr.');
db.insert(missingPart)
.values({
id: crypto.randomUUID(),
collectionItemId,
partNumber: parsed.data.partNumber,
colorName: parsed.data.colorName,
quantity: parsed.data.quantity,
resolved: false,
notes: parsed.data.notes,
createdAt: now(),
})
.run();
syncCompleteness(collectionItemId);
revalidateCollection(setId);
return actionOk();
}
export async function setMissingPartResolved(
missingPartId: string,
resolved: boolean,
): Promise<ActionResult> {
await requireUser();
const row = db
.select({ collectionItemId: missingPart.collectionItemId })
.from(missingPart)
.where(eq(missingPart.id, missingPartId))
.get();
if (!row) return actionOk();
db.update(missingPart)
.set({ resolved })
.where(eq(missingPart.id, missingPartId))
.run();
syncCompleteness(row.collectionItemId);
revalidateCollection(loadItemSetId(row.collectionItemId) ?? undefined);
return actionOk();
}
export async function deleteMissingPart(missingPartId: string): Promise<ActionResult> {
await requireUser();
const row = db
.select({ collectionItemId: missingPart.collectionItemId })
.from(missingPart)
.where(eq(missingPart.id, missingPartId))
.get();
if (!row) return actionOk();
db.delete(missingPart).where(eq(missingPart.id, missingPartId)).run();
syncCompleteness(row.collectionItemId);
revalidateCollection(loadItemSetId(row.collectionItemId) ?? undefined);
return actionOk();
}
+54 -13
View File
@@ -1,19 +1,25 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import Link from 'next/link'; import Link from 'next/link';
import { Boxes, Euro, ImageOff, Layers, PackageSearch } from 'lucide-react'; import { Boxes, Euro, Layers, PackageSearch } from 'lucide-react';
import { SetPlaceholder } from '@/components/brick/set-placeholder';
import { Studs } from '@/components/brick/studs';
import { CollectionFilters } from '@/components/sammlung/collection-filters'; import { CollectionFilters } from '@/components/sammlung/collection-filters';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { formatDate, formatNumber, pluralize } from '@/lib/format'; import { formatDate, formatNumber, pluralize } from '@/lib/format';
import { collectorBadgeClass, collectorDotClass } from '@/lib/collector-colors';
import { setImageSrc } from '@/lib/images'; import { setImageSrc } from '@/lib/images';
import { completenessLabel, statusLabel } from '@/lib/labels'; import { completenessLabel, statusLabel } from '@/lib/labels';
import { formatEuro } from '@/lib/money'; import { formatEuro } from '@/lib/money';
import { import {
countMissingSets, countMissingSets,
countOpenMissingParts,
getCollectionStats, getCollectionStats,
listCollectionItems, listCollectionItems,
type CollectionFilters as Filters, type CollectionFilters as Filters,
} from '@/lib/queries/collection'; } from '@/lib/queries/collection';
import { activeCollectorId } from '@/lib/collector-choice';
import { listCollectors } from '@/lib/queries/collectors';
import { listLocations, listManufacturers } from '@/lib/queries/master-data'; import { listLocations, listManufacturers } from '@/lib/queries/master-data';
import { requireUser } from '@/lib/session'; import { requireUser } from '@/lib/session';
import type { CollectionStatus, Completeness } from '@/db/types'; import type { CollectionStatus, Completeness } from '@/db/types';
@@ -31,22 +37,33 @@ export default async function SammlungPage({ searchParams }: { searchParams: Sea
await requireUser(); await requireUser();
const params = await searchParams; const params = await searchParams;
const collectors = listCollectors();
const collectorId = await activeCollectorId(single(params.sammler));
const activeCollector = collectors.find((row) => row.id === collectorId) ?? null;
const filters: Filters = { const filters: Filters = {
q: single(params.q), q: single(params.q),
status: single(params.status) as CollectionStatus | undefined, status: single(params.status) as CollectionStatus | undefined,
completeness: single(params.zustand) as Completeness | undefined, completeness: single(params.zustand) as Completeness | undefined,
locationId: single(params.standort), locationId: single(params.standort),
manufacturerId: single(params.hersteller), manufacturerId: single(params.hersteller),
collectorId,
}; };
const items = listCollectionItems(filters); const items = listCollectionItems(filters);
const stats = getCollectionStats(); const openParts = countOpenMissingParts(items.map((item) => item.id));
const missing = countMissingSets(); const stats = getCollectionStats(collectorId);
const missing = countMissingSets(collectorId);
const tiles = [ const tiles = [
{ icon: Boxes, label: 'Sets', value: formatNumber(stats.setCount) }, { icon: Boxes, label: 'Sets', value: formatNumber(stats.setCount) },
{ icon: Layers, label: 'Teile gesamt', value: formatNumber(stats.pieceCount) }, { icon: Layers, label: 'Teile gesamt', value: formatNumber(stats.pieceCount) },
{ icon: Euro, label: 'Wert nach UVP', value: formatEuro(stats.retailValueCents) }, {
icon: Euro,
// Notierte Preise gehen vor, sonst die UVP.
label: stats.currentValueCents === stats.retailValueCents ? 'Wert nach UVP' : 'Aktueller Wert',
value: formatEuro(stats.currentValueCents),
},
{ icon: PackageSearch, label: 'Fehlt noch', value: formatNumber(missing) }, { icon: PackageSearch, label: 'Fehlt noch', value: formatNumber(missing) },
] as const; ] as const;
@@ -54,7 +71,11 @@ export default async function SammlungPage({ searchParams }: { searchParams: Sea
<div className="space-y-5"> <div className="space-y-5">
<div> <div>
<h1 className="text-2xl font-semibold tracking-tight">Sammlung</h1> <h1 className="text-2xl font-semibold tracking-tight">Sammlung</h1>
<p className="text-sm text-muted-foreground">Alles, was tatsächlich da ist.</p> <p className="text-sm text-muted-foreground">
{activeCollector
? `Alles, was ${activeCollector.name} tatsächlich hat.`
: 'Alles, was tatsächlich da ist.'}
</p>
</div> </div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4"> <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
@@ -98,23 +119,31 @@ export default async function SammlungPage({ searchParams }: { searchParams: Sea
</Link> </Link>
</div> </div>
) : ( ) : (
<ul className="divide-y divide-border rounded-lg border border-border"> <ul className="space-y-5 pt-2">
{items.map((item) => { {items.map((item) => {
const image = setImageSrc(item); const image = setImageSrc(item);
return ( return (
<li key={item.id}> <li key={item.id} className="relative">
{/* The studs carry the collector's colour once there is more than one. */}
<Studs
studClassName={
collectors.length > 1 ? collectorDotClass(item.collectorColor) : undefined
}
/>
<Link <Link
href={`/katalog/${item.setId}`} href={`/katalog/${item.setId}`}
className="flex items-center gap-3 p-3 transition-colors hover:bg-muted/50" className="brick flex items-center gap-3 rounded-xl bg-card p-3 transition-colors hover:bg-muted/50"
> >
<div className="size-14 shrink-0 overflow-hidden rounded-lg border border-border bg-muted"> <div className="size-16 shrink-0 overflow-hidden rounded-lg border-[length:max(1px,var(--edge-w))] border-edge bg-muted">
{image ? ( {image ? (
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
<img src={image} alt="" loading="lazy" className="size-full object-contain" /> <img src={image} alt="" loading="lazy" className="size-full object-contain" />
) : ( ) : (
<div className="flex size-full items-center justify-center text-muted-foreground"> <SetPlaceholder
<ImageOff className="size-5" aria-hidden /> setNumber={item.setNumber}
</div> manufacturerName={item.manufacturerName}
size="sm"
/>
)} )}
</div> </div>
@@ -126,12 +155,24 @@ export default async function SammlungPage({ searchParams }: { searchParams: Sea
.join(' · ')} .join(' · ')}
</p> </p>
<div className="mt-1 flex flex-wrap items-center gap-1"> <div className="mt-1 flex flex-wrap items-center gap-1">
<Badge variant={item.status === 'sold' ? 'outline' : 'default'}> {/* Green like "Vorhanden" in the catalog; red stays for actions. */}
<Badge
variant={item.status === 'sold' ? 'outline' : 'default'}
className={item.status === 'sold' ? undefined : 'bg-owned text-owned-foreground'}
>
{statusLabel(item.status)} {statusLabel(item.status)}
</Badge> </Badge>
{item.completeness !== 'unknown' ? ( {item.completeness !== 'unknown' ? (
<Badge variant="secondary">{completenessLabel(item.completeness)}</Badge> <Badge variant="secondary">{completenessLabel(item.completeness)}</Badge>
) : null} ) : 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 ? ( {item.acquiredAt ? (
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
seit {formatDate(item.acquiredAt)} seit {formatDate(item.acquiredAt)}
+45
View File
@@ -0,0 +1,45 @@
'use server';
import { revalidatePath } from 'next/cache';
import { actionData, actionError, type ActionResult } from '@/lib/action-result';
import { importArchive, ImportError, type ImportSummary } from '@/lib/export';
import { requireAdmin } from '@/lib/session';
const MAX_ARCHIVE_BYTES = 512 * 1024 * 1024;
/** Typed by hand before anything is deleted. */
const CONFIRMATION = 'ERSETZEN';
export async function importArchiveAction(
formData: FormData,
): Promise<ActionResult<ImportSummary>> {
const admin = await requireAdmin();
if (formData.get('confirmation') !== CONFIRMATION) {
return actionError(`Bitte tipp ${CONFIRMATION} ein, um den Austausch zu bestätigen.`);
}
const file = formData.get('archive');
if (!(file instanceof File) || file.size === 0) {
return actionError('Bitte wähle ein Bricktrack-Archiv (.zip) aus.');
}
if (file.size > MAX_ARCHIVE_BYTES) {
return actionError('Das Archiv ist größer als 512 MB und wird nicht eingelesen.');
}
try {
const summary = importArchive(
new Uint8Array(await file.arrayBuffer()),
admin.id,
);
revalidatePath('/', 'layout');
return actionData(summary);
} catch (error) {
if (error instanceof ImportError) return actionError(error.message);
console.error('[import] Archiv konnte nicht eingelesen werden', error);
return actionError(
'Das Archiv konnte nicht eingelesen werden. Die vorhandenen Daten wurden nicht verändert.',
);
}
}
+10
View File
@@ -0,0 +1,10 @@
import type { Metadata } from 'next';
import { DataTransfer } from '@/components/verwaltung/data-transfer';
import { requireAdmin } from '@/lib/session';
export const metadata: Metadata = { title: 'Daten' };
export default async function DatenPage() {
await requireAdmin();
return <DataTransfer />;
}
@@ -0,0 +1,44 @@
'use server';
import { revalidatePath } from 'next/cache';
import { z } from 'zod';
import { actionData, actionOk, parseForm, type ActionResult } from '@/lib/action-result';
import { createInvite, deleteInvite, INVITE_VALID_DAYS } from '@/lib/invites';
import { requireAdmin } from '@/lib/session';
import { optionalText } from '@/lib/validation';
const schema = z.object({
// Optional: when set, the invitation only works for that address.
email: optionalText(160),
role: z.enum(['admin', 'member']).default('member'),
validDays: z.coerce.number().int().min(1).max(90).default(INVITE_VALID_DAYS),
});
export async function createInviteAction(
input: unknown,
): Promise<ActionResult<{ token: string }>> {
const admin = await requireAdmin();
const parsed = parseForm(schema, input);
if (!parsed.ok) return parsed;
const created = createInvite({
createdByUserId: admin.id,
email: parsed.data.email,
role: parsed.data.role,
validDays: parsed.data.validDays,
});
revalidatePath('/verwaltung/einladungen');
// The plaintext token is returned once and never stored - only its hash is.
return actionData({ token: created.token });
}
export async function deleteInviteAction(id: string): Promise<ActionResult> {
await requireAdmin();
deleteInvite(id);
revalidatePath('/verwaltung/einladungen');
return actionOk();
}
@@ -0,0 +1,22 @@
import type { Metadata } from 'next';
import { InviteList, type InviteListRow } from '@/components/verwaltung/invite-list';
import { inviteState, listInvites } from '@/lib/invites';
import { requireAdmin } from '@/lib/session';
export const metadata: Metadata = { title: 'Einladungen' };
export default async function EinladungenPage() {
await requireAdmin();
const invites: InviteListRow[] = listInvites().map((row) => ({
id: row.id,
email: row.email,
role: row.role,
expiresAt: row.expiresAt,
usedAt: row.usedAt,
usedByName: row.usedByName,
state: inviteState(row),
}));
return <InviteList invites={invites} />;
}
@@ -0,0 +1,88 @@
'use server';
import { revalidatePath } from 'next/cache';
import {
actionData,
actionError,
actionOk,
formToObject,
parseForm,
type ActionResult,
} from '@/lib/action-result';
import { env } from '@/env';
import {
deleteMailSettings,
getMailConfig,
isMailConfigured,
saveMailSettings,
} from '@/lib/mail-settings';
import { sendMail, verifyMailConnection } from '@/lib/mailer';
import { mailSettingsSchema } from '@/lib/validation';
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 (!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 (!isMailConfigured()) {
return actionError('Es ist kein SMTP-Server konfiguriert.');
}
const run = await runReminderJob();
revalidatePath('/verwaltung/erinnerungen');
return actionData(run);
}
/** Stores the SMTP server set up on the reminders page. */
export async function saveMailSettingsAction(formData: FormData): Promise<ActionResult> {
await requireAdmin();
if (getMailConfig()?.source === 'env') {
return actionError('Der Mailserver kommt aus der .env. Änderungen bitte dort vornehmen.');
}
const parsed = parseForm(mailSettingsSchema, formToObject(formData));
if (!parsed.ok) return parsed;
saveMailSettings(parsed.data);
revalidatePath('/verwaltung/erinnerungen');
return actionOk();
}
export async function deleteMailSettingsAction(): Promise<ActionResult> {
await requireAdmin();
deleteMailSettings();
revalidatePath('/verwaltung/erinnerungen');
return actionOk();
}
@@ -0,0 +1,58 @@
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 { MailSettingsForm } from '@/components/verwaltung/mail-settings-form';
import {
ReminderSettings,
type ReminderListRow,
} from '@/components/verwaltung/reminder-settings';
import { getMailSettingsView } from '@/lib/mail-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));
const mail = getMailSettingsView();
return (
<div className="space-y-6">
<ReminderSettings
configured={mail.source !== null}
daysBefore={env.REMINDER_DAYS_BEFORE}
lists={lists}
/>
<MailSettingsForm view={mail} />
</div>
);
}
+2 -8
View File
@@ -1,5 +1,5 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { VerwaltungNav } from '@/components/verwaltung/verwaltung-nav'; import { VerwaltungHeader } from '@/components/verwaltung/verwaltung-header';
import { requireUser } from '@/lib/session'; import { requireUser } from '@/lib/session';
export const metadata: Metadata = { title: 'Verwaltung' }; export const metadata: Metadata = { title: 'Verwaltung' };
@@ -9,13 +9,7 @@ export default async function VerwaltungLayout({ children }: { children: React.R
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <VerwaltungHeader />
<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 />
{children} {children}
</div> </div>
); );
+160
View File
@@ -0,0 +1,160 @@
'use server';
import { revalidatePath } from 'next/cache';
import { asc, eq, sql } from 'drizzle-orm';
import { db } from '@/db';
import { collectionItem, collector, wishlist } from '@/db/schema';
import { actionError, actionOk, parseForm, type ActionResult } from '@/lib/action-result';
import { requireUser } from '@/lib/session';
import { collectorSchema } from '@/lib/validation';
function now() {
return new Date();
}
function revalidateEverything() {
// A collector shows up in the catalog, the collection and the wishlists.
revalidatePath('/', 'layout');
}
export async function createCollector(input: unknown): Promise<ActionResult> {
await requireUser();
const parsed = parseForm(collectorSchema, input);
if (!parsed.ok) return parsed;
const [row] = db
.select({ next: sql<number>`coalesce(max(${collector.sortOrder}), -1) + 1`.mapWith(Number) })
.from(collector)
.all();
db.insert(collector)
.values({
id: crypto.randomUUID(),
name: parsed.data.name,
color: parsed.data.color,
birthday: parsed.data.birthday,
notes: parsed.data.notes,
sortOrder: row?.next ?? 0,
createdAt: now(),
updatedAt: now(),
})
.run();
revalidateEverything();
return actionOk();
}
export async function updateCollector(id: string, input: unknown): Promise<ActionResult> {
await requireUser();
const parsed = parseForm(collectorSchema, input);
if (!parsed.ok) return parsed;
db.update(collector)
.set({
name: parsed.data.name,
color: parsed.data.color,
birthday: parsed.data.birthday,
notes: parsed.data.notes,
updatedAt: now(),
})
.where(eq(collector.id, id))
.run();
revalidateEverything();
return actionOk();
}
/**
* Deleting a collector would take their whole collection with it, so it is
* only allowed while nothing is attached - and never for the last one.
*/
export async function deleteCollector(id: string): Promise<ActionResult> {
await requireUser();
const remaining = db.select({ id: collector.id }).from(collector).all();
if (remaining.length <= 1) {
return actionError('Der letzte Sammler kann nicht gelöscht werden.');
}
const owned = db
.select({ id: collectionItem.id })
.from(collectionItem)
.where(eq(collectionItem.collectorId, id))
.get();
if (owned) {
return actionError(
'Diesem Sammler sind noch Sets zugeordnet. Ordne sie erst jemand anderem zu, dann lässt er sich löschen.',
);
}
const lists = db
.select({ id: wishlist.id })
.from(wishlist)
.where(eq(wishlist.collectorId, id))
.get();
if (lists) {
return actionError(
'Zu diesem Sammler gibt es noch Wunschlisten. Ordne sie erst um oder lösch sie.',
);
}
db.delete(collector).where(eq(collector.id, id)).run();
revalidateEverything();
return actionOk();
}
/** Moves every owned copy of one collector over to another. */
export async function reassignCollectionItems(
fromCollectorId: string,
toCollectorId: string,
): Promise<ActionResult> {
await requireUser();
if (fromCollectorId === toCollectorId) return actionOk();
const target = db
.select({ id: collector.id })
.from(collector)
.where(eq(collector.id, toCollectorId))
.get();
if (!target) return actionError('Diesen Sammler gibt es nicht.');
db.update(collectionItem)
.set({ collectorId: toCollectorId, updatedAt: now() })
.where(eq(collectionItem.collectorId, fromCollectorId))
.run();
revalidateEverything();
return actionOk();
}
/** Reorders the collectors; the first one is the default for new entries. */
export async function reorderCollectors(orderedIds: string[]): Promise<ActionResult> {
await requireUser();
db.transaction((tx) => {
orderedIds.forEach((id, index) => {
tx.update(collector)
.set({ sortOrder: index, updatedAt: now() })
.where(eq(collector.id, id))
.run();
});
});
revalidateEverything();
return actionOk();
}
/** Every collector, ordered - for the selects in the forms. */
export async function listCollectorOptions(): Promise<Array<{ id: string; name: string }>> {
await requireUser();
return db
.select({ id: collector.id, name: collector.name })
.from(collector)
.orderBy(asc(collector.sortOrder), asc(collector.name))
.all();
}
+40
View File
@@ -0,0 +1,40 @@
import type { Metadata } from 'next';
import { CollectorList, type CollectorRowItem } from '@/components/verwaltung/collector-list';
import { formatDate, pluralize } from '@/lib/format';
import { COLLECTOR_COLOR_LABELS } from '@/lib/collector-colors';
import { listCollectors } from '@/lib/queries/collectors';
import { requireUser } from '@/lib/session';
export const metadata: Metadata = { title: 'Sammler' };
export default async function SammlerPage() {
await requireUser();
const collectors = listCollectors();
const rows: CollectorRowItem[] = collectors.map((row) => ({
id: row.id,
title: row.name,
subtitle: [
pluralize(row.itemCount, 'Set', 'Sets'),
COLLECTOR_COLOR_LABELS[row.color],
row.birthday ? `geboren am ${formatDate(row.birthday)}` : null,
]
.filter(Boolean)
.join(' · '),
color: row.color,
birthday: row.birthday,
notes: row.notes,
itemCount: row.itemCount,
}));
return (
<div className="space-y-4">
<p className="rounded-lg border border-dashed border-border p-4 text-sm text-muted-foreground">
Der Katalog ist gemeinsam: Ein Set existiert einmal, unabhängig davon, wer es hat.
Sammlung und Wunschlisten gehören dagegen je einem Sammler. Solange es nur einen gibt,
taucht die Unterscheidung nirgends in der App auf – sie erscheint erst mit dem zweiten.
</p>
<CollectorList collectors={rows} />
</div>
);
}
+17 -2
View File
@@ -8,10 +8,12 @@ import { WishlistHeaderActions } from '@/components/wunschlisten/wishlist-header
import { WishlistItems } from '@/components/wunschlisten/wishlist-items'; import { WishlistItems } from '@/components/wunschlisten/wishlist-items';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { collectorBadgeClass } from '@/lib/collector-colors';
import { formatDate, pluralize } from '@/lib/format'; import { formatDate, pluralize } from '@/lib/format';
import { occasionLabel } from '@/lib/labels'; import { occasionLabel } from '@/lib/labels';
import { CATALOG_PAGE_SIZE, listSets, listSetYears } from '@/lib/queries/catalog'; import { CATALOG_PAGE_SIZE, listSets, listSetYears } from '@/lib/queries/catalog';
import { listManufacturers, listThemes } from '@/lib/queries/master-data'; import { listManufacturers, listThemes } from '@/lib/queries/master-data';
import { listCollectors } from '@/lib/queries/collectors';
import { getWishlist, listWishlistItems } from '@/lib/queries/wishlists'; import { getWishlist, listWishlistItems } from '@/lib/queries/wishlists';
import { requireUser } from '@/lib/session'; import { requireUser } from '@/lib/session';
@@ -39,7 +41,9 @@ export default async function WunschlisteDetailPage({ params, searchParams }: Pr
const list = getWishlist(id); const list = getWishlist(id);
if (!list || list.ownerUserId !== user.id) notFound(); if (!list || list.ownerUserId !== user.id) notFound();
const items = listWishlistItems(list.id); const collectors = listCollectors();
const activeCollector = collectors.find((row) => row.id === list.collectorId) ?? null;
const items = listWishlistItems(list.id, list.collectorId);
// The embedded picker: missing sets that are not on this list yet. It shares // The embedded picker: missing sets that are not on this list yet. It shares
// the URL parameters with the catalog, so the filters behave identically. // the URL parameters with the catalog, so the filters behave identically.
@@ -52,6 +56,7 @@ export default async function WunschlisteDetailPage({ params, searchParams }: Pr
year: yearParam ? Number(yearParam) : undefined, year: yearParam ? Number(yearParam) : undefined,
ownership: 'fehlt', ownership: 'fehlt',
excludeWishlistId: list.id, excludeWishlistId: list.id,
collectorId: list.collectorId ?? undefined,
page, page,
}); });
@@ -63,6 +68,11 @@ export default async function WunschlisteDetailPage({ params, searchParams }: Pr
<Badge variant="secondary">{occasionLabel(list.occasion)}</Badge> <Badge variant="secondary">{occasionLabel(list.occasion)}</Badge>
{list.eventDate ? <Badge variant="outline">{formatDate(list.eventDate)}</Badge> : null} {list.eventDate ? <Badge variant="outline">{formatDate(list.eventDate)}</Badge> : null}
{list.surpriseMode ? <Badge variant="outline">Überraschungsmodus</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> </div>
<h1 className="mt-2 truncate text-2xl font-semibold tracking-tight">{list.title}</h1> <h1 className="mt-2 truncate text-2xl font-semibold tracking-tight">{list.title}</h1>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
@@ -70,8 +80,10 @@ export default async function WunschlisteDetailPage({ params, searchParams }: Pr
</p> </p>
</div> </div>
<WishlistHeaderActions <WishlistHeaderActions
collectors={collectors.map((row) => ({ id: row.id, name: row.name }))}
wishlist={{ wishlist={{
id: list.id, id: list.id,
collectorId: list.collectorId,
title: list.title, title: list.title,
occasion: list.occasion, occasion: list.occasion,
eventDate: list.eventDate, eventDate: list.eventDate,
@@ -99,7 +111,9 @@ export default async function WunschlisteDetailPage({ params, searchParams }: Pr
<CardHeader> <CardHeader>
<CardTitle>Aus fehlenden Sets hinzufügen</CardTitle> <CardTitle>Aus fehlenden Sets hinzufügen</CardTitle>
<CardDescription> <CardDescription>
Nur Sets, die noch nicht in der Sammlung und noch nicht auf dieser Liste sind. Kachel {activeCollector
? `Nur Sets, die ${activeCollector.name} noch nicht hat und die noch nicht auf dieser Liste stehen.`
: 'Nur Sets, die noch nicht in der Sammlung und noch nicht auf dieser Liste sind.'}{' '} Kachel
lange drücken oder „Auswählen“ tippen, dann mehrere auf einmal übernehmen. lange drücken oder „Auswählen“ tippen, dann mehrere auf einmal übernehmen.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
@@ -124,6 +138,7 @@ export default async function WunschlisteDetailPage({ params, searchParams }: Pr
sets={missing.rows} sets={missing.rows}
wishlists={[{ id: list.id, title: list.title }]} wishlists={[{ id: list.id, title: list.title }]}
defaultWishlistId={list.id} defaultWishlistId={list.id}
collectorId={list.collectorId ?? undefined}
emptyState="Es fehlt gerade nichts, was noch nicht auf dieser Liste steht." emptyState="Es fehlt gerade nichts, was noch nicht auf dieser Liste steht."
/> />
+4 -1
View File
@@ -12,6 +12,7 @@ import {
type ActionResult, type ActionResult,
} from '@/lib/action-result'; } from '@/lib/action-result';
import { ownedCopiesSql } from '@/lib/ownership'; import { ownedCopiesSql } from '@/lib/ownership';
import { defaultCollectorId } from '@/lib/queries/collectors';
import { import {
findExistingWishlistSetIds, findExistingWishlistSetIds,
getWishlist, getWishlist,
@@ -58,6 +59,7 @@ export async function createWishlist(input: unknown): Promise<ActionResult<{ id:
.values({ .values({
id, id,
ownerUserId: user.id, ownerUserId: user.id,
collectorId: parsed.data.collectorId ?? defaultCollectorId(),
title: parsed.data.title, title: parsed.data.title,
occasion: parsed.data.occasion, occasion: parsed.data.occasion,
eventDate: parsed.data.eventDate, eventDate: parsed.data.eventDate,
@@ -83,6 +85,7 @@ export async function updateWishlist(id: string, input: unknown): Promise<Action
db.update(wishlist) db.update(wishlist)
.set({ .set({
collectorId: parsed.data.collectorId,
title: parsed.data.title, title: parsed.data.title,
occasion: parsed.data.occasion, occasion: parsed.data.occasion,
eventDate: parsed.data.eventDate, eventDate: parsed.data.eventDate,
@@ -153,7 +156,7 @@ export async function addSetsToWishlist(
id: set.id, id: set.id,
name: set.name, name: set.name,
setNumber: set.setNumber, setNumber: set.setNumber,
ownedCopies: ownedCopiesSql(set.id).mapWith(Number), ownedCopies: ownedCopiesSql(set.id, list.collectorId).mapWith(Number),
}) })
.from(set) .from(set)
.where(inArray(set.id, setIds)) .where(inArray(set.id, setIds))
+11 -1
View File
@@ -4,8 +4,10 @@ import { CalendarDays, Gift, Link2Off } from 'lucide-react';
import { NewWishlistButton } from '@/components/wunschlisten/wishlist-form-dialog'; import { NewWishlistButton } from '@/components/wunschlisten/wishlist-form-dialog';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { collectorBadgeClass } from '@/lib/collector-colors';
import { formatDate, pluralize } from '@/lib/format'; import { formatDate, pluralize } from '@/lib/format';
import { occasionLabel } from '@/lib/labels'; import { occasionLabel } from '@/lib/labels';
import { listCollectors } from '@/lib/queries/collectors';
import { listWishlists } from '@/lib/queries/wishlists'; import { listWishlists } from '@/lib/queries/wishlists';
import { requireUser } from '@/lib/session'; import { requireUser } from '@/lib/session';
@@ -14,6 +16,7 @@ export const metadata: Metadata = { title: 'Wunschlisten' };
export default async function WunschlistenPage() { export default async function WunschlistenPage() {
const user = await requireUser(); const user = await requireUser();
const lists = listWishlists(user.id); const lists = listWishlists(user.id);
const collectors = listCollectors();
return ( return (
<div className="space-y-5"> <div className="space-y-5">
@@ -24,7 +27,9 @@ export default async function WunschlistenPage() {
Zum Teilen mit Familie und Freunden – ganz ohne Konto für die anderen. Zum Teilen mit Familie und Freunden – ganz ohne Konto für die anderen.
</p> </p>
</div> </div>
<NewWishlistButton /> <NewWishlistButton
collectors={collectors.map((row) => ({ id: row.id, name: row.name }))}
/>
</div> </div>
{lists.length === 0 ? ( {lists.length === 0 ? (
@@ -54,6 +59,11 @@ export default async function WunschlistenPage() {
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="flex flex-wrap items-center gap-2 text-xs"> <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> <Badge variant="secondary">{pluralize(list.itemCount, 'Wunsch', 'Wünsche')}</Badge>
{list.openCount > 0 ? ( {list.openCount > 0 ? (
<Badge variant="outline">{list.openCount} noch offen</Badge> <Badge variant="outline">{list.openCount} noch offen</Badge>
@@ -0,0 +1,63 @@
'use server';
import { headers } from 'next/headers';
import { z } from 'zod';
import { actionError, actionOk, parseForm, type ActionResult } from '@/lib/action-result';
import { auth } from '@/lib/auth';
import { findUsableInvite, markInviteUsed, withInviteGrant } from '@/lib/invites';
const schema = z.object({
token: z.string().trim().min(1),
name: z.string().trim().min(1, 'Bitte gib deinen Namen an.').max(80),
email: z.email('Bitte gib eine gültige E-Mail-Adresse an.'),
password: z.string().min(10, 'Das Passwort braucht mindestens 10 Zeichen.'),
});
/**
* Registration through an invitation link.
*
* The token is checked here, on the server, and only then is the sign-up
* wrapped in a grant that the user-create hook can see. The role comes from
* the invitation, never from the submitted form.
*/
export async function registerWithInvite(input: unknown): Promise<ActionResult> {
const parsed = parseForm(schema, input);
if (!parsed.ok) return parsed;
const invitation = findUsableInvite(parsed.data.token);
if (!invitation) {
return actionError('Diese Einladung ist abgelaufen oder wurde bereits verwendet.');
}
if (invitation.email && invitation.email.toLowerCase() !== parsed.data.email.toLowerCase()) {
return actionError(`Diese Einladung gilt für ${invitation.email}.`);
}
const requestHeaders = await headers();
try {
const result = await withInviteGrant(
{ inviteId: invitation.id, role: invitation.role },
() =>
auth.api.signUpEmail({
body: {
name: parsed.data.name,
email: parsed.data.email,
password: parsed.data.password,
},
headers: requestHeaders,
}),
);
markInviteUsed(invitation.id, result.user.id);
} catch (error) {
const message = error instanceof Error ? error.message : '';
if (message.toLowerCase().includes('exist')) {
return actionError('Zu dieser E-Mail-Adresse gibt es schon ein Konto.');
}
console.error('[invite] Registrierung fehlgeschlagen', error);
return actionError('Die Registrierung hat nicht geklappt. Bitte versuch es noch einmal.');
}
return actionOk();
}
+54
View File
@@ -0,0 +1,54 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { InviteForm } from '@/components/auth/invite-form';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { formatDate } from '@/lib/format';
import { findUsableInvite } from '@/lib/invites';
import { getSession } from '@/lib/session';
export const metadata: Metadata = {
title: 'Einladung',
robots: { index: false, follow: false },
};
export default async function InvitePage({ params }: { params: Promise<{ token: string }> }) {
if (await getSession()) redirect('/');
const { token } = await params;
const invitation = findUsableInvite(token);
if (!invitation) {
return (
<Card>
<CardHeader>
<CardTitle>Einladung ungültig</CardTitle>
<CardDescription>
Dieser Link ist abgelaufen oder wurde schon benutzt. Lass dir einfach einen neuen
schicken.
</CardDescription>
</CardHeader>
<CardContent>
<Link href="/anmelden" className="text-sm underline underline-offset-4">
Zur Anmeldung
</Link>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle>Konto anlegen</CardTitle>
<CardDescription>
Du wurdest zu Bricktrack eingeladen. Der Link gilt noch bis zum{' '}
{formatDate(invitation.expiresAt)}.
</CardDescription>
</CardHeader>
<CardContent>
<InviteForm token={token} email={invitation.email} />
</CardContent>
</Card>
);
}
+6 -3
View File
@@ -1,11 +1,14 @@
import Link from 'next/link'; import Link from 'next/link';
import { Blocks } from 'lucide-react'; import { BrickLogo } from '@/components/brick/logo';
export default function AuthLayout({ children }: { children: React.ReactNode }) { export default function AuthLayout({ children }: { children: React.ReactNode }) {
return ( return (
<div className="flex min-h-dvh flex-col items-center justify-center gap-8 px-4 py-10"> <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"> <Link
<Blocks className="size-6 text-primary" aria-hidden /> href="/"
className="flex items-center gap-2 text-2xl font-bold tracking-tight [font-family:var(--font-display)]"
>
<BrickLogo className="h-8 w-9" />
Bricktrack Bricktrack
</Link> </Link>
<main className="w-full max-w-sm">{children}</main> <main className="w-full max-w-sm">{children}</main>
+106
View File
@@ -0,0 +1,106 @@
import { NextResponse } from 'next/server';
import {
buildCollectionCsv,
buildExportPayload,
buildExportZip,
exportFileName,
} from '@/lib/export';
import { formatDate } from '@/lib/format';
import { conditionLabel, completenessLabel, statusLabel } from '@/lib/labels';
import { centsToInput } from '@/lib/money';
import { listCollectionItems } from '@/lib/queries/collection';
import { getCurrentUser } from '@/lib/session';
export const dynamic = 'force-dynamic';
const CSV_COLUMNS = [
{ key: 'setNumber', label: 'Setnummer' },
{ key: 'setName', label: 'Name' },
{ key: 'manufacturer', label: 'Hersteller' },
{ key: 'theme', label: 'Thema' },
{ key: 'year', label: 'Jahr' },
{ key: 'pieceCount', label: 'Teile' },
{ key: 'status', label: 'Status' },
{ key: 'completeness', label: 'Vollständigkeit' },
{ key: 'condition', label: 'Zustand' },
{ key: 'location', label: 'Standort' },
{ key: 'acquiredAt', label: 'Im Besitz seit' },
{ key: 'acquiredPrice', label: 'Kaufpreis (EUR)' },
{ key: 'retailPrice', label: 'UVP (EUR)' },
{ key: 'acquiredFrom', label: 'Gekauft bei' },
{ key: 'giftedBy', label: 'Geschenkt von' },
{ key: 'notes', label: 'Notizen' },
];
/**
* Downloads.
*
* The full archive is for admins - it contains the whole collection. The CSV
* of the collection is available to every signed-in member.
*/
export async function GET(
_request: Request,
context: { params: Promise<{ kind: string }> },
) {
const user = await getCurrentUser();
if (!user) return new NextResponse('Nicht angemeldet', { status: 401 });
const { kind } = await context.params;
if (kind === 'csv') {
const rows = listCollectionItems({}).map((item) => ({
setNumber: item.setNumber,
setName: item.setName,
manufacturer: item.manufacturerName,
theme: item.themeName,
year: item.year,
pieceCount: item.pieceCount,
status: statusLabel(item.status),
completeness: completenessLabel(item.completeness),
condition: conditionLabel(item.condition),
location: item.locationName,
acquiredAt: item.acquiredAt ? formatDate(item.acquiredAt) : '',
acquiredPrice: centsToInput(item.acquiredPriceCents),
retailPrice: centsToInput(item.retailPriceCents),
acquiredFrom: item.acquiredFrom,
giftedBy: item.giftedBy,
notes: item.notes,
}));
return new NextResponse(buildCollectionCsv(rows, CSV_COLUMNS), {
headers: {
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition': `attachment; filename="${exportFileName('csv')}"`,
'Cache-Control': 'no-store',
},
});
}
if (user.role !== 'admin') {
return new NextResponse('Nur Administratoren dürfen die Daten exportieren', { status: 403 });
}
if (kind === 'json') {
return new NextResponse(JSON.stringify(buildExportPayload(), null, 2), {
headers: {
'Content-Type': 'application/json; charset=utf-8',
'Content-Disposition': `attachment; filename="${exportFileName('json')}"`,
'Cache-Control': 'no-store',
},
});
}
if (kind === 'zip') {
const archive = buildExportZip();
return new NextResponse(archive as unknown as BodyInit, {
headers: {
'Content-Type': 'application/zip',
'Content-Length': String(archive.byteLength),
'Content-Disposition': `attachment; filename="${exportFileName('zip')}"`,
'Cache-Control': 'no-store',
},
});
}
return new NextResponse('Unbekanntes Format', { status: 404 });
}
+5
View File
@@ -1,6 +1,7 @@
import { sql } from 'drizzle-orm'; import { sql } from 'drizzle-orm';
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { db } from '@/db'; import { db } from '@/db';
import { ensureReminderScheduler } from '@/lib/reminder-job';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@@ -9,6 +10,10 @@ export const dynamic = 'force-dynamic';
* file is readable - a healthy Node process on a broken volume is not healthy. * file is readable - a healthy Node process on a broken volume is not healthy.
*/ */
export function GET() { export function GET() {
// The container polls this every 30s, which makes it the earliest reliable
// moment to have the reminder scheduler running. Repeat calls are free.
ensureReminderScheduler();
try { try {
db.get(sql`select 1`); db.get(sql`select 1`);
return NextResponse.json({ status: 'ok', time: new Date().toISOString() }); return NextResponse.json({ status: 'ok', time: new Date().toISOString() });
+301
View File
@@ -0,0 +1,301 @@
/*
* Selectable designs. Each one is a set of tokens under :root[data-design=...]
* (the attribute is set in the root layout from a cookie, see src/lib/design.ts).
* The neutral "schlicht" design uses the defaults from globals.css.
*
* Token additions on top of shadcn:
* --font-display headings and big numbers
* --edge, --edge-w outline of cards, buttons and badges
* --lift(-sm) the hard bottom edge that makes a card look like a brick
* --studs "flex" shows the studs on top of cards, "none" hides them
* --tone-1..5 placeholder colours for sets without an image
* --owned the "Vorhanden" badge
*/
/* --- Klemmbaustein (default) --------------------------------------------- */
:root[data-design='klemmbaustein'] {
--font-sans: 'Nunito Variable', ui-sans-serif, system-ui, sans-serif;
--font-display: 'Fredoka Variable', ui-sans-serif, system-ui, sans-serif;
--radius: 1rem;
--background: #fff6e5;
--foreground: #24203f;
--card: #ffffff;
--card-foreground: #24203f;
--popover: #ffffff;
--popover-foreground: #24203f;
--primary: #d63a30;
--primary-foreground: #ffffff;
--secondary: #ffc93c;
--secondary-foreground: #24203f;
--muted: #f6ead0;
--muted-foreground: #5f5a78;
--accent: #ffe9b0;
--accent-foreground: #24203f;
--destructive: #c62828;
--border: rgb(36 32 63 / 0.16);
--input: #24203f;
--ring: #2f6bea;
--logo: #ffc93c;
--edge: #24203f;
--edge-w: 2px;
--lift: 0 4px 0 #24203f;
--lift-sm: 0 3px 0 #24203f;
--studs: flex;
--owned: #2ebd85;
--owned-foreground: #24203f;
--tone-ink: #24203f;
--tone-1: #ffe08a;
--tone-2: #bfd6ff;
--tone-3: #bdebd3;
--tone-4: #ffc9d9;
--tone-5: #e2d6ff;
}
/* --- Warm ---------------------------------------------------------------- */
:root[data-design='warm'] {
--font-sans: 'Inter Variable', ui-sans-serif, system-ui, sans-serif;
--font-display: 'Bricolage Grotesque Variable', ui-sans-serif, system-ui, sans-serif;
--radius: 0.875rem;
--background: #f6f3ec;
--foreground: #1d1b17;
--card: #ffffff;
--card-foreground: #1d1b17;
--popover: #ffffff;
--popover-foreground: #1d1b17;
--primary: #c8381f;
--primary-foreground: #ffffff;
--secondary: #fbe3a6;
--secondary-foreground: #1d1b17;
--muted: #ece6da;
--muted-foreground: #5f594f;
--accent: #fbe3a6;
--accent-foreground: #1d1b17;
--destructive: #b42318;
--border: #e2dbcd;
--input: #d6cebf;
--ring: #c8381f;
--edge: transparent;
--edge-w: 0px;
--lift: 0 1px 2px rgb(29 27 23 / 0.06);
--lift-sm: none;
--studs: none;
--owned: #1f7a4d;
--owned-foreground: #ffffff;
--tone-ink: #1d1b17;
--tone-1: #fbe3a6;
--tone-2: #d6e6f5;
--tone-3: #dde8d2;
--tone-4: #f6d5cc;
--tone-5: #e5ddf0;
}
/* --- Nachtblau (dark) ---------------------------------------------------- */
:root[data-design='nacht'] {
color-scheme: dark;
--font-sans: 'Inter Variable', ui-sans-serif, system-ui, sans-serif;
--font-display: 'Outfit Variable', ui-sans-serif, system-ui, sans-serif;
--radius: 1.125rem;
--background: #0e1422;
--foreground: #f2f4f8;
--card: #182136;
--card-foreground: #f2f4f8;
--popover: #1b2540;
--popover-foreground: #f2f4f8;
--primary: #ffc93c;
--primary-foreground: #1a1300;
--secondary: #222d45;
--secondary-foreground: #f2f4f8;
--muted: #222d45;
--muted-foreground: #a3adc2;
--accent: #2a3654;
--accent-foreground: #f2f4f8;
--destructive: #f87171;
--border: #2c3854;
--input: #34405e;
--ring: #ffc93c;
--edge: transparent;
--edge-w: 0px;
--lift: none;
--lift-sm: none;
--studs: none;
--owned: #1e4d3a;
--owned-foreground: #8fe3b8;
--tone-ink: #f2f4f8;
--tone-1: #3a3218;
--tone-2: #1b2c44;
--tone-3: #22331e;
--tone-4: #3d2230;
--tone-5: #2c2546;
}
/* --- Bonbon -------------------------------------------------------------- */
:root[data-design='bonbon'] {
--font-sans: 'Nunito Variable', ui-sans-serif, system-ui, sans-serif;
--font-display: 'Fredoka Variable', ui-sans-serif, system-ui, sans-serif;
--radius: 1.5rem;
--background: #f4f0ff;
--foreground: #23194a;
--card: #ffffff;
--card-foreground: #23194a;
--popover: #ffffff;
--popover-foreground: #23194a;
--primary: #6d4aff;
--primary-foreground: #ffffff;
--secondary: #ede7ff;
--secondary-foreground: #4b2fc4;
--muted: #ede7ff;
--muted-foreground: #5e567c;
--accent: #ede7ff;
--accent-foreground: #23194a;
--destructive: #c62828;
--border: #e4ddf7;
--input: #d8cff3;
--ring: #6d4aff;
--edge: transparent;
--edge-w: 0px;
--lift: 0 8px 24px rgb(35 25 74 / 0.08);
--lift-sm: none;
--studs: none;
--owned: #157a57;
--owned-foreground: #ffffff;
--tone-ink: #23194a;
--tone-1: #fff1c7;
--tone-2: #dff0ff;
--tone-3: #ddf7ea;
--tone-4: #ffe1d1;
--tone-5: #ffddeb;
}
/* --- Bauklotz ------------------------------------------------------------ */
:root[data-design='bauklotz'] {
--font-sans: 'Archivo Variable', ui-sans-serif, system-ui, sans-serif;
--font-display: 'Archivo Variable', ui-sans-serif, system-ui, sans-serif;
--radius: 0.75rem;
--background: #fffbef;
--foreground: #111111;
--card: #ffffff;
--card-foreground: #111111;
--popover: #ffffff;
--popover-foreground: #111111;
--primary: #1e5eff;
--primary-foreground: #ffffff;
--secondary: #ffd500;
--secondary-foreground: #111111;
--muted: #f3eedc;
--muted-foreground: #4a4a4a;
--accent: #ffd500;
--accent-foreground: #111111;
--destructive: #c62828;
--border: rgb(17 17 17 / 0.2);
--input: #111111;
--ring: #1e5eff;
--logo: #ffd500;
--edge: #111111;
--edge-w: 2.5px;
--lift: 4px 4px 0 #111111;
--lift-sm: 3px 3px 0 #111111;
--studs: none;
--owned: #111111;
--owned-foreground: #ffffff;
--tone-ink: #111111;
--tone-1: #ffd500;
--tone-2: #7fb2ff;
--tone-3: #8bd17c;
--tone-4: #ff8a7a;
--tone-5: #c9a7ff;
}
:root[data-design='bauklotz'] :is(h1, h2) {
font-weight: 900;
text-transform: uppercase;
letter-spacing: -0.01em;
}
/*
* Surfaces. Unlayered on purpose: these must win over the utility classes of
* the shadcn components, which carry the neutral look. Focused elements keep
* their ring (also a box-shadow), so the lift steps aside for it.
*/
[data-slot='card'] {
border: var(--edge-w) solid var(--edge);
box-shadow: var(--lift);
}
:root:is([data-design='klemmbaustein'], [data-design='bauklotz']) {
& [data-slot='button']:is(
[data-variant='default'],
[data-variant='outline'],
[data-variant='secondary'],
[data-variant='destructive']
) {
border-width: var(--edge-w);
border-color: var(--edge);
font-weight: 700;
&:not(:focus-visible) {
box-shadow: var(--lift-sm);
}
&:active:not(:disabled) {
translate: 0 2px;
box-shadow: none;
}
}
& [data-slot='badge'] {
border-width: 2px;
border-color: var(--edge);
border-radius: calc(var(--radius) * 0.5);
font-weight: 800;
}
& :is([data-slot='input'], [data-slot='textarea'], [data-slot='native-select'], [data-slot='select-trigger']) {
border-width: var(--edge-w);
border-color: var(--edge);
}
& :is([data-slot='dialog-content'], [data-slot='alert-dialog-content'], [data-slot='popover-content'], [data-slot='dropdown-menu-content'], [data-slot='select-content']) {
border: var(--edge-w) solid var(--edge);
box-shadow: var(--lift);
}
}
/* A surface outside the shadcn components (set tiles, list entries). */
@utility brick {
border: var(--edge-w) solid var(--edge);
box-shadow: var(--lift);
}
/* --- Studs ---------------------------------------------------------------- */
/* In a layer, so a utility class on a stud (collector colour) still wins. */
@layer components {
.studs {
display: var(--studs);
position: absolute;
top: calc(var(--edge-w) - 10px);
left: 16px;
gap: 10px;
pointer-events: none;
}
.stud {
display: block;
width: 20px;
height: 10px;
box-sizing: border-box;
border: var(--edge-w) solid var(--edge);
border-bottom: 0;
border-radius: 7px 7px 0 0;
background: var(--card);
}
}

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