Author SHA1 Message Date
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
138 changed files with 13463 additions and 1267 deletions
+13
View File
@@ -32,3 +32,16 @@ TZ=Europe/Berlin
# und Bild werden uebernommen. Ohne Key funktioniert alles manuell weiter. # und Bild werden uebernommen. Ohne Key funktioniert alles manuell weiter.
# Kostenlos anlegen: https://rebrickable.com/api/ (Konto -> Settings -> API) # Kostenlos anlegen: https://rebrickable.com/api/ (Konto -> Settings -> API)
# REBRICKABLE_API_KEY= # REBRICKABLE_API_KEY=
# Optional: Mailversand fuer Erinnerungen an Wunschlisten-Termine. Ohne
# SMTP_HOST und SMTP_FROM sind Erinnerungen komplett aus - alles andere
# funktioniert unveraendert.
# SMTP_HOST=mail.example.de
# SMTP_PORT=587
# SMTP_SECURE=false
# SMTP_USER=bricktrack@example.de
# SMTP_PASSWORD=
# SMTP_FROM=Bricktrack <bricktrack@example.de>
# Wie viele Tage vor dem Termin erinnert wird (Standard: 14).
# REMINDER_DAYS_BEFORE=14
+123 -1
View File
@@ -7,6 +7,126 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/).
## [Unreleased] ## [Unreleased]
### Geändert
- **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, oben rechts hinter den Initialen.
- **„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.
### Hinzugefügt
- **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.
### 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 ## [0.2.0] - 2026-09-07
Phase 2: die Handgriffe, die den Alltag schneller machen. Phase 2: die Handgriffe, die den Alltag schneller machen.
@@ -147,6 +267,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.2.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.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
+32 -2
View File
@@ -57,8 +57,26 @@ Bild-URLs, die der Nutzer selbst hinterlegt hat.
| `src/lib/rebrickable.ts` | Lego-Import, ausschliesslich serverseitig | | `src/lib/rebrickable.ts` | Lego-Import, ausschliesslich serverseitig |
| `src/lib/set-number.ts`, `src/lib/ean.ts` | Setnummern und Barcodes (rein, testbar) | | `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/uploads.ts` | Datei-Uploads, Magic Bytes, Thumbnails |
| `src/lib/similarity.ts` | Namensvergleich fuer die Dublettenpruefung (rein) |
| `src/lib/prices.ts` | Wertentwicklung aus notierten Preisen (rein) |
| `src/lib/reminders.ts` | Regeln, wann eine Erinnerung faellig ist (rein) |
| `src/lib/reminder-job.ts`, `src/lib/mailer.ts` | Versand und Zeitplan |
| `src/lib/export.ts` | Export und Import des gesamten Bestands | | `src/lib/export.ts` | Export und Import des gesamten Bestands |
| `src/lib/money.ts`, `src/lib/format.ts` | Cent-Umrechnung, dd.MM.yyyy | | `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
@@ -114,8 +132,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` |
@@ -173,7 +199,9 @@ 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 6. Der Export enthaelt **keine** Konten, Passwoerter oder Passkeys - und
@@ -190,7 +218,9 @@ Session.
- **Phase 2 (Komfort)** - Rebrickable-Import, Barcode-Scanner, Fehlteile, - **Phase 2 (Komfort)** - Rebrickable-Import, Barcode-Scanner, Fehlteile,
Einladungen, Export/Import, PWA. *Fertig in 0.2.0.* 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** (das Kästchen mit deinen Initialen 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"**.
+10 -1
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
@@ -208,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
+200 -18
View File
@@ -27,7 +27,12 @@ Kinderzimmer, nicht für den Schreibtisch.
13. [Noch ein Konto für die Familie](#13-einladungen) 13. [Noch ein Konto für die Familie](#13-einladungen)
14. [Daten sichern, umziehen, auswerten](#14-daten) 14. [Daten sichern, umziehen, auswerten](#14-daten)
15. [Auf den Startbildschirm legen](#15-startbildschirm) 15. [Auf den Startbildschirm legen](#15-startbildschirm)
16. [Antworten auf die zwei wichtigen Fragen](#16-die-zwei-fragen) 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)
--- ---
@@ -48,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 Kästchen mit deinen
Initialen oben rechts – der Seite **Mehr**: 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.
--- ---
@@ -66,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:
@@ -109,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.
@@ -278,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.
@@ -298,14 +321,41 @@ gib ihnen sprechende Namen.
## 11. Import und Scanner ## 11. Import und Scanner
Sets von Hand einzutippen ist der langsame Weg. Für Lego gibt es zwei Sets von Hand einzutippen ist der langsame Weg. Es gibt drei schnellere.
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 über die Setnummer
![Lego-Import](bilder/17-lego-import.png) ![Lego-Import](bilder/17-lego-import.png)
Auf **Katalog → Set anlegen** steht oben das Feld für den Import. Setnummer Über **+ → Lego-Nummer** landest du im Feld für den Import. Setnummer
von der Packung eintippen, „Holen" – und Name, Jahr, Teile, Minifiguren und von der Packung eintippen, „Holen" – und Name, Jahr, Teile, Minifiguren und
Thema stehen drin. Das Bild wird **lokal gespeichert**, funktioniert also auch Thema stehen drin. Das Bild wird **lokal gespeichert**, funktioniert also auch
weiter, wenn die Quelle es irgendwann austauscht. Fehlen Hersteller oder weiter, wenn die Quelle es irgendwann austauscht. Fehlen Hersteller oder
@@ -358,7 +408,7 @@ der Sammlung stimmt damit, ohne dass du daran denken musst.
Die Registrierung ist nach dem ersten Konto geschlossen – sonst könnte sich 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 jeder, der die Adresse kennt, ein Konto anlegen. Für den zweiten Elternteil
gibt es deshalb **Verwaltung → Einladungen**: gibt es deshalb **Mehr → Einladungen**:
1. „Einladung erstellen", optional an eine E-Mail-Adresse gebunden, mit Rolle 1. „Einladung erstellen", optional an eine E-Mail-Adresse gebunden, mit Rolle
und Gültigkeit. und Gültigkeit.
@@ -376,7 +426,7 @@ zusätzlich einladen und Daten exportieren.
![Daten](bilder/20-daten.png) ![Daten](bilder/20-daten.png)
Unter **Verwaltung → Daten** gibt es drei Downloads: Unter **Mehr → Export & Import** gibt es drei Downloads:
| | Wofür | | | Wofür |
|---|---| |---|---|
@@ -419,7 +469,117 @@ den Zwischenspeicher, damit auf einem geteilten Gerät nichts lesbar bleibt.
--- ---
## 16. Die zwei Fragen ## 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 SMTP-Zugang in der `.env`:
```bash
SMTP_HOST=mail.example.de
SMTP_PORT=587
SMTP_USER=bricktrack@example.de
SMTP_PASSWORD=…
SMTP_FROM=Bricktrack <bricktrack@example.de>
# Optional, Standard ist 14:
REMINDER_DAYS_BEFORE=14
```
Unter **Mehr → Erinnerungen** kannst du die Verbindung prüfen, dir eine
Testnachricht schicken und die Prüfung sofort auslösen, statt auf die nächste
Stunde zu warten. Dort steht auch, welche Listen einen Termin haben und wie
viel dort noch offen ist.
Ohne SMTP-Zugang ist die Funktion vollständig aus – alles andere funktioniert
unverändert.
---
## 19. Dubletten
Beim Anlegen eines neuen Sets schaut Bricktrack nach, ob es das schon gibt:
- **gleiche Setnummer beim gleichen Hersteller** – dann ist es dasselbe Set,
- **sehr ähnlicher Name** – meist ein Tippfehler oder eine andere Schreibweise,
- **gleiche Nummer bei einem anderen Hersteller** – meist der falsche
Hersteller im Formular.
Passt etwas, erscheint ein Hinweis mit Link zu den Verdächtigen. Von dort
kommst du direkt zum vorhandenen Eintrag – oder legst mit „Trotzdem anlegen"
weiter an, wenn es wirklich ein anderes Set ist.
Die Erkennung ist bewusst zurückhaltend eingestellt: Tippfehler schlagen an,
zwei echte Sets desselben Themas nicht.
---
## 20. Die zwei Fragen
Dafür ist die ganze App gebaut: Dafür ist die ganze App gebaut:
@@ -428,13 +588,35 @@ 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: Scanner öffnen, Strichcode einlesen. Steht ein grüner Im Laden vor dem Regal: Scanner öffnen, Strichcode einlesen. 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.
---
## Kleinigkeiten, die den Alltag leichter machen ## Kleinigkeiten, die den Alltag leichter machen
- **Filter stecken in der URL.** Ein Lesezeichen auf - **Filter stecken in der URL.** Ein Lesezeichen auf
+34
View File
@@ -45,6 +45,20 @@ Commit-Spalte verweist auf den Stand am Ende des Schritts.
**Summe Phase 2: 0:55** **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
@@ -120,6 +134,21 @@ Commit-Spalte verweist auf den Stand am Ende des Schritts.
Konfliktregeln für jede Tabelle und wäre im Ergebnis nicht vorhersagbar. Konfliktregeln für jede Tabelle und wäre im Ergebnis nicht vorhersagbar.
Der Import ersetzt deshalb – dafür mit getipptem `ERSETZEN` als Bremse. 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**:
@@ -130,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`.
+29 -6
View File
@@ -46,12 +46,35 @@ Laeuft vollstaendig im Docker-Stack.
Sammlung zusaetzlich als CSV Sammlung zusaetzlich als CSV
- [x] 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: 80 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 288 KiB

After

Width:  |  Height:  |  Size: 443 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 201 KiB

After

Width:  |  Height:  |  Size: 329 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 209 KiB

After

Width:  |  Height:  |  Size: 369 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 236 KiB

After

Width:  |  Height:  |  Size: 407 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 185 KiB

After

Width:  |  Height:  |  Size: 348 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

After

Width:  |  Height:  |  Size: 329 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 293 KiB

After

Width:  |  Height:  |  Size: 495 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: 427 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 301 KiB

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 294 KiB

After

Width:  |  Height:  |  Size: 497 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 211 KiB

After

Width:  |  Height:  |  Size: 381 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 327 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 257 KiB

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 445 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 269 KiB

+28
View File
@@ -0,0 +1,28 @@
CREATE TABLE `collector` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`color` text DEFAULT 'blue' NOT NULL,
`birthday` integer,
`notes` text,
`sort_order` integer DEFAULT 0 NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL
);
--> statement-breakpoint
CREATE INDEX `collector_sort_idx` ON `collector` (`sort_order`,`name`);--> statement-breakpoint
ALTER TABLE `collection_item` ADD `collector_id` text REFERENCES collector(id);--> statement-breakpoint
CREATE INDEX `collection_item_collector_idx` ON `collection_item` (`collector_id`);--> statement-breakpoint
ALTER TABLE `wishlist` ADD `collector_id` text REFERENCES collector(id);--> statement-breakpoint
-- Von Hand ergaenzt: Bestandsdaten nachziehen.
-- Vor dieser Migration gab es genau eine Sammlung. Sie bekommt einen Sammler,
-- damit alles Vorhandene zugeordnet bleibt; umbenennen laesst er sich unter
-- Verwaltung -> Sammler.
INSERT INTO `collector` (`id`, `name`, `color`, `sort_order`, `created_at`, `updated_at`)
SELECT 'c0000000-0000-4000-8000-000000000001', 'Sammlung', 'blue', 0, unixepoch(), unixepoch()
WHERE NOT EXISTS (SELECT 1 FROM `collector`);--> statement-breakpoint
UPDATE `collection_item`
SET `collector_id` = (SELECT `id` FROM `collector` ORDER BY `sort_order`, `name` LIMIT 1)
WHERE `collector_id` IS NULL;--> statement-breakpoint
UPDATE `wishlist`
SET `collector_id` = (SELECT `id` FROM `collector` ORDER BY `sort_order`, `name` LIMIT 1)
WHERE `collector_id` IS NULL;
+14
View File
@@ -0,0 +1,14 @@
CREATE TABLE `price_point` (
`id` text PRIMARY KEY NOT NULL,
`set_id` text NOT NULL,
`price_cents` integer NOT NULL,
`condition` text DEFAULT 'new' NOT NULL,
`source` text,
`observed_at` integer NOT NULL,
`note` text,
`created_at` integer NOT NULL,
FOREIGN KEY (`set_id`) REFERENCES `set`(`id`) ON UPDATE no action ON DELETE cascade,
CONSTRAINT "price_point_positive" CHECK("price_point"."price_cents" >= 0)
);
--> statement-breakpoint
CREATE INDEX `price_point_set_idx` ON `price_point` (`set_id`,`observed_at`);
+1
View File
@@ -0,0 +1 @@
ALTER TABLE `wishlist` ADD `last_reminder_at` integer;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+21
View File
@@ -8,6 +8,27 @@
"when": 1788726503038, "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
} }
] ]
} }
+62 -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();
@@ -128,6 +145,18 @@ test('Screenshots fuer die Anleitung', async ({ page, browser }) => {
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 ------------------------------------------------------ // --- 12. Lego import ------------------------------------------------------
// The screenshot container runs with a placeholder API key so the manual // The screenshot container runs with a placeholder API key so the manual
// shows the form, not the "not configured" hint. Nothing is submitted. // shows the form, not the "not configured" hint. Nothing is submitted.
@@ -160,4 +189,36 @@ test('Screenshots fuer die Anleitung', async ({ page, browser }) => {
await visit(page, '/verwaltung/daten'); await visit(page, '/verwaltung/daten');
await expect(page.getByText('Alles als ZIP')).toBeVisible(); await expect(page.getByText('Alles als ZIP')).toBeVisible();
await shoot(page, '20-daten'); 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');
}); });
+24
View File
@@ -81,6 +81,30 @@ 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 }) => { 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. // The admin from the first test is still the only account, so log in.
await page.goto('/anmelden'); await page.goto('/anmelden');
+37 -14
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",
@@ -23,47 +23,56 @@
"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",
"@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/browser": "^0.2.1",
"@zxing/library": "^0.23.0", "@zxing/library": "^0.23.0",
"better-auth": "^1.7.3", "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", "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": [
@@ -71,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"
}
} }
} }
+669 -633
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -17,6 +17,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(`[e2e] ${dataDir} zurueckgesetzt.`); 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) …'); console.log('[e2e] Image wird gebaut (bricktrack:e2e) …');
execFileSync('docker', ['build', '-t', 'bricktrack:e2e', '.'], { stdio: 'inherit' }); execFileSync('docker', ['build', '-t', 'bricktrack:e2e', '.'], { stdio: 'inherit' });
console.log('[e2e] Image ist bereit.'); console.log('[e2e] Image ist bereit.');
+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();
}
+60 -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,25 +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 { 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);
@@ -43,11 +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}`, setName: `${set.setNumber} · ${set.name}`,
missingParts: listMissingParts(item.id), 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]> = [
@@ -96,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">
@@ -113,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">
@@ -144,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);
}
+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,
});
}
+22 -29
View File
@@ -1,7 +1,7 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import Link from 'next/link'; import Link from 'next/link';
import { NewSetEntry } from '@/components/katalog/link-import';
import { RebrickableImport } from '@/components/katalog/rebrickable-import'; import { RebrickableImport } from '@/components/katalog/rebrickable-import';
import { SetForm } from '@/components/katalog/set-form';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { listManufacturers, listThemes } from '@/lib/queries/master-data'; import { listManufacturers, listThemes } from '@/lib/queries/master-data';
import { isRebrickableEnabled } from '@/lib/rebrickable'; import { isRebrickableEnabled } from '@/lib/rebrickable';
@@ -15,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) => ({
@@ -25,26 +27,8 @@ export default async function NeuesSetPage({ searchParams }: { searchParams: Sea
const importEnabled = isRebrickableEnabled(); const importEnabled = isRebrickableEnabled();
if (manufacturers.length === 0) { // One layout for both cases: the link import may create the first
return ( // manufacturer, and its draft must survive the refresh that follows.
<div className="space-y-6">
<h1 className="text-2xl font-semibold tracking-tight">Neues Set</h1>
{/* The Lego import creates its manufacturer itself, so it works even
on a completely empty instance. */}
<RebrickableImport enabled={importEnabled} />
<p className="rounded-lg border border-dashed border-border p-6 text-sm text-muted-foreground">
Für ein Set von Hand brauchst du zuerst einen Hersteller – Lego, Playmobil, Cobi oder
was auch immer im Kinderzimmer steht.
</p>
<Button asChild size="lg">
<Link href="/verwaltung/hersteller">Hersteller anlegen</Link>
</Button>
</div>
);
}
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
@@ -54,15 +38,24 @@ export default async function NeuesSetPage({ searchParams }: { searchParams: Sea
Sammlung hinzufügen“ fest. Sammlung hinzufügen“ fest.
</p> </p>
</div> </div>
<RebrickableImport enabled={importEnabled} /> {/* The Lego import creates its manufacturer itself, so it works even
on a completely empty instance. */}
<RebrickableImport enabled={importEnabled} autoFocus={way === 'lego'} />
<div className="flex items-center gap-3 text-xs text-muted-foreground"> <NewSetEntry
<span className="h-px flex-1 bg-border" /> manufacturers={manufacturers}
oder von Hand themes={themes}
<span className="h-px flex-1 bg-border" /> defaultEan={ean}
</div> focus={way === 'link' || way === 'hand' ? way : undefined}
>
<SetForm manufacturers={manufacturers} themes={themes} defaultEan={ean} /> <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>
); );
} }
+10 -2
View File
@@ -9,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';
@@ -27,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;
@@ -36,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,
}; };
@@ -57,7 +63,9 @@ 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>
@@ -79,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();
}
+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>
); );
} }
+10 -1
View File
@@ -11,6 +11,7 @@ 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, missingPartSchema } from '@/lib/validation'; import { collectionItemSchema, missingPartSchema } from '@/lib/validation';
@@ -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,
+49 -13
View File
@@ -1,10 +1,13 @@
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';
@@ -15,6 +18,8 @@ import {
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';
@@ -32,23 +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 openParts = countOpenMissingParts(items.map((item) => item.id)); const openParts = countOpenMissingParts(items.map((item) => item.id));
const stats = getCollectionStats(); const stats = getCollectionStats(collectorId);
const missing = countMissingSets(); 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;
@@ -56,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">
@@ -100,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>
@@ -128,12 +155,21 @@ 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) ? ( {openParts.get(item.id) ? (
<Badge variant="outline">{openParts.get(item.id)} Fehlteile</Badge> <Badge variant="outline">{openParts.get(item.id)} Fehlteile</Badge>
) : null} ) : null}
@@ -0,0 +1,51 @@
'use server';
import { revalidatePath } from 'next/cache';
import { actionData, actionError, type ActionResult } from '@/lib/action-result';
import { env } from '@/env';
import { sendMail, verifyMailConnection } from '@/lib/mailer';
import { runReminderJob, type ReminderRun } from '@/lib/reminder-job';
import { requireAdmin } from '@/lib/session';
export async function testMailConnection(): Promise<ActionResult> {
await requireAdmin();
const result = await verifyMailConnection();
return result.ok ? { ok: true } : actionError(result.error);
}
export async function sendTestMail(): Promise<ActionResult> {
const admin = await requireAdmin();
if (!env.isMailConfigured) {
return actionError('Es ist kein SMTP-Server konfiguriert.');
}
const result = await sendMail({
to: admin.email,
subject: 'Bricktrack: Testnachricht',
text: [
'Das hat geklappt.',
'',
'Wenn diese Nachricht ankommt, funktionieren auch die Erinnerungen zu',
'Wunschlisten-Terminen.',
'',
env.BASE_URL,
].join('\n'),
});
return result.ok ? { ok: true } : actionError(result.error);
}
/** Runs the check immediately instead of waiting for the next hour. */
export async function runRemindersNow(): Promise<ActionResult<ReminderRun>> {
await requireAdmin();
if (!env.isMailConfigured) {
return actionError('Es ist kein SMTP-Server konfiguriert.');
}
const run = await runReminderJob();
revalidatePath('/verwaltung/erinnerungen');
return actionData(run);
}
@@ -0,0 +1,51 @@
import type { Metadata } from 'next';
import { eq, sql } from 'drizzle-orm';
import { db } from '@/db';
import { wishlist } from '@/db/schema';
import { env } from '@/env';
import {
ReminderSettings,
type ReminderListRow,
} from '@/components/verwaltung/reminder-settings';
import { daysUntil } from '@/lib/reminders';
import { requireAdmin } from '@/lib/session';
export const metadata: Metadata = { title: 'Erinnerungen' };
export default async function ErinnerungenPage() {
const admin = await requireAdmin();
const openItems = sql<number>`(
select count(*) from wishlist_item wi
where wi.wishlist_id = ${wishlist.id}
and wi.reserved_at is null
and wi.fulfilled_at is null
)`.mapWith(Number);
const now = new Date();
const lists: ReminderListRow[] = db
.select({
id: wishlist.id,
title: wishlist.title,
eventDate: wishlist.eventDate,
lastReminderAt: wishlist.lastReminderAt,
openItemCount: openItems,
})
.from(wishlist)
.where(eq(wishlist.ownerUserId, admin.id))
.all()
.filter((row) => row.eventDate !== null)
.map((row) => ({
...row,
daysLeft: row.eventDate ? daysUntil(row.eventDate, now) : null,
}))
.sort((a, b) => (a.daysLeft ?? 0) - (b.daysLeft ?? 0));
return (
<ReminderSettings
configured={env.isMailConfigured}
daysBefore={env.REMINDER_DAYS_BEFORE}
lists={lists}
/>
);
}
+3 -9
View File
@@ -1,21 +1,15 @@
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' };
export default async function VerwaltungLayout({ children }: { children: React.ReactNode }) { export default async function VerwaltungLayout({ children }: { children: React.ReactNode }) {
const user = await requireUser(); await requireUser();
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 isAdmin={user.role === 'admin'} />
{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>
+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>
+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);
}
}
+33 -2
View File
@@ -1,6 +1,7 @@
@import "tailwindcss"; @import "tailwindcss";
@import "tw-animate-css"; @import "tw-animate-css";
@import "shadcn/tailwind.css"; @import "shadcn/tailwind.css";
@import "./designs.css";
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
@@ -10,6 +11,7 @@
"Segoe UI Emoji"; "Segoe UI Emoji";
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, --font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
"Liberation Mono", monospace; "Liberation Mono", monospace;
--font-display: var(--font-sans);
} }
@@ -18,7 +20,16 @@
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--font-sans: var(--font-sans); --font-sans: var(--font-sans);
--font-mono: var(--font-mono); --font-mono: var(--font-mono);
--font-heading: var(--font-sans); --font-heading: var(--font-display);
--color-edge: var(--edge);
--color-owned: var(--owned);
--color-owned-foreground: var(--owned-foreground);
--color-tone-ink: var(--tone-ink);
--color-tone-1: var(--tone-1);
--color-tone-2: var(--tone-2);
--color-tone-3: var(--tone-3);
--color-tone-4: var(--tone-4);
--color-tone-5: var(--tone-5);
--color-sidebar-ring: var(--sidebar-ring); --color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border); --color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
@@ -90,6 +101,21 @@
--sidebar-accent-foreground: oklch(0.205 0 0); --sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0); --sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0); --sidebar-ring: oklch(0.708 0 0);
/* Brick look: outline, "lift" (the hard bottom edge) and studs. The
neutral design has none of it; see designs.css for the others. */
--edge: oklch(0.145 0 0 / 10%);
--edge-w: 1px;
--lift: none;
--lift-sm: none;
--studs: none;
--owned: oklch(0.596 0.145 163.225);
--owned-foreground: oklch(1 0 0);
--tone-ink: oklch(0.556 0 0);
--tone-1: oklch(0.97 0 0);
--tone-2: oklch(0.97 0 0);
--tone-3: oklch(0.97 0 0);
--tone-4: oklch(0.97 0 0);
--tone-5: oklch(0.97 0 0);
} }
.dark { .dark {
@@ -136,4 +162,9 @@
html { html {
@apply font-sans; @apply font-sans;
} }
} h1,
h2,
h3 {
font-family: var(--font-display);
}
}␍
+30 -11
View File
@@ -1,6 +1,14 @@
import type { Metadata, Viewport } from 'next'; import type { Metadata, Viewport } from 'next';
import { cookies } from 'next/headers';
import '@fontsource-variable/archivo';
import '@fontsource-variable/bricolage-grotesque';
import '@fontsource-variable/fredoka';
import '@fontsource-variable/inter';
import '@fontsource-variable/nunito';
import '@fontsource-variable/outfit';
import { ServiceWorkerRegistration } from '@/components/layout/service-worker'; import { ServiceWorkerRegistration } from '@/components/layout/service-worker';
import { Toaster } from '@/components/ui/sonner'; import { Toaster } from '@/components/ui/sonner';
import { DESIGN_COOKIE, DESIGN_INFO, resolveDesign } from '@/lib/design';
import './globals.css'; import './globals.css';
export const metadata: Metadata = { export const metadata: Metadata = {
@@ -25,19 +33,30 @@ export const metadata: Metadata = {
robots: { index: false, follow: false }, robots: { index: false, follow: false },
}; };
export const viewport: Viewport = { async function currentDesign() {
width: 'device-width', return resolveDesign((await cookies()).get(DESIGN_COOKIE)?.value);
initialScale: 1, }
viewportFit: 'cover',
themeColor: [ export async function generateViewport(): Promise<Viewport> {
{ media: '(prefers-color-scheme: light)', color: '#ffffff' }, const design = await currentDesign();
{ media: '(prefers-color-scheme: dark)', color: '#0a0a0a' }, return {
], width: 'device-width',
}; initialScale: 1,
viewportFit: 'cover',
themeColor: DESIGN_INFO[design].themeColor,
};
}
export default async function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
const design = await currentDesign();
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return ( return (
<html lang="de" suppressHydrationWarning> <html
lang="de"
data-design={design}
className={DESIGN_INFO[design].dark ? 'dark' : undefined}
suppressHydrationWarning
>
<body className="min-h-dvh bg-background text-foreground antialiased"> <body className="min-h-dvh bg-background text-foreground antialiased">
{children} {children}
<Toaster position="top-center" richColors closeButton /> <Toaster position="top-center" richColors closeButton />
+48 -40
View File
@@ -1,6 +1,8 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
import { Blocks, CalendarDays, Gift } from 'lucide-react'; import { CalendarDays, Gift } from 'lucide-react';
import { BrickLogo } from '@/components/brick/logo';
import { Studs } from '@/components/brick/studs';
import { PublicWishlist, type PublicItem } from '@/components/wunschlisten/public-wishlist'; import { PublicWishlist, type PublicItem } from '@/components/wunschlisten/public-wishlist';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { formatDate, pluralize } from '@/lib/format'; import { formatDate, pluralize } from '@/lib/format';
@@ -27,7 +29,7 @@ export default async function PublicWishlistPage({ params }: Props) {
const list = getWishlistByShareToken(shareToken); const list = getWishlistByShareToken(shareToken);
if (!list || !list.isPublic) notFound(); if (!list || !list.isPublic) notFound();
const rows = listWishlistItems(list.id); const rows = listWishlistItems(list.id, list.collectorId);
// Fulfilled wishes are gone from the public view: the present is already // Fulfilled wishes are gone from the public view: the present is already
// under the tree, no reason to let someone buy it twice. // under the tree, no reason to let someone buy it twice.
@@ -54,50 +56,56 @@ export default async function PublicWishlistPage({ params }: Props) {
const openCount = items.filter((item) => !item.isReserved).length; const openCount = items.filter((item) => !item.isReserved).length;
return ( return (
<div className="mx-auto min-h-dvh w-full max-w-2xl px-4 py-8"> <div className="min-h-dvh">
<header className="space-y-3 border-b border-border pb-6"> <header className="border-b-[length:max(1px,var(--edge-w))] border-edge bg-primary text-primary-foreground">
<div className="flex items-center gap-2 text-sm text-muted-foreground"> <div className="mx-auto w-full max-w-2xl space-y-3 px-4 pt-6 pb-10">
<Blocks className="size-4 text-primary" aria-hidden /> <div className="flex items-center gap-2 text-sm font-semibold">
Bricktrack <BrickLogo className="h-5 w-6" />
</div> Bricktrack
</div>
<h1 className="text-2xl font-semibold tracking-tight">{list.title}</h1> <h1 className="text-3xl font-semibold tracking-tight">{list.title}</h1>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary" className="gap-1"> <Badge className="gap-1 bg-card text-card-foreground">
<Gift className="size-3" aria-hidden /> <Gift className="size-3" aria-hidden />
{occasionLabel(list.occasion)} {occasionLabel(list.occasion)}
</Badge>
{list.eventDate ? (
<Badge variant="outline" className="gap-1">
<CalendarDays className="size-3" aria-hidden />
{formatDate(list.eventDate)}
</Badge> </Badge>
) : null} {list.eventDate ? (
<span className="text-sm text-muted-foreground"> <Badge className="gap-1 bg-card text-card-foreground">
{pluralize(openCount, 'Wunsch noch offen', 'Wünsche noch offen')} <CalendarDays className="size-3" aria-hidden />
</span> {formatDate(list.eventDate)}
</Badge>
) : null}
<Badge variant="secondary">
{pluralize(openCount, 'Wunsch noch offen', 'Wünsche noch offen')}
</Badge>
</div>
</div> </div>
{list.note ? (
<p className="rounded-lg bg-muted p-3 text-sm whitespace-pre-line">{list.note}</p>
) : null}
<p className="text-xs text-muted-foreground">
Klick auf „Das schenke ich“, damit niemand dasselbe doppelt kauft. Eine Anmeldung
brauchst du dafür nicht.
</p>
</header> </header>
<main className="py-6"> <div className="mx-auto w-full max-w-2xl px-4 pb-8">
{items.length === 0 ? ( <div className="relative -mt-5">
<p className="rounded-lg border border-dashed border-border p-8 text-center text-sm text-muted-foreground"> <Studs />
Auf dieser Liste steht gerade nichts. <div className="brick space-y-2 rounded-xl bg-card p-4 text-card-foreground">
</p> {list.note ? <p className="text-sm whitespace-pre-line">{list.note}</p> : null}
) : ( <p className="text-xs text-muted-foreground">
<PublicWishlist shareToken={shareToken} items={items} /> Klick auf „Das schenke ich“, damit niemand dasselbe doppelt kauft. Eine Anmeldung
)} brauchst du dafür nicht.
</main> </p>
</div>
</div>
<main className="py-6">
{items.length === 0 ? (
<p className="rounded-lg border border-dashed border-border p-8 text-center text-sm text-muted-foreground">
Auf dieser Liste steht gerade nichts.
</p>
) : (
<PublicWishlist shareToken={shareToken} items={items} />
)}
</main>
</div>
</div> </div>
); );
} }
+18
View File
@@ -0,0 +1,18 @@
/** The app mark: a 2x1 brick in the design's logo colour. */
export function BrickLogo({ className }: { className?: string }) {
return (
<svg viewBox="0 0 38 32" aria-hidden className={className}>
<g
style={{
fill: 'var(--logo, var(--primary))',
stroke: 'var(--edge)',
strokeWidth: 'var(--edge-w)',
}}
>
<rect x="9" y="2" width="8" height="8" rx="2.5" />
<rect x="21" y="2" width="8" height="8" rx="2.5" />
<rect x="2" y="8" width="34" height="22" rx="6" />
</g>
</svg>
);
}
+66
View File
@@ -0,0 +1,66 @@
import { cn } from 'cn';
import { toneIndex } from '@/lib/design';
const TONE_CLASSES: Record<number, string> = {
1: 'bg-tone-1',
2: 'bg-tone-2',
3: 'bg-tone-3',
4: 'bg-tone-4',
5: 'bg-tone-5',
};
/**
* Stand-in for a set without an image: the manufacturer's tone, a drawn
* brick and the set number. Reads as intended rather than as "image broken".
*/
export function SetPlaceholder({
setNumber,
manufacturerName,
size = 'md',
className,
}: {
setNumber: string;
manufacturerName: string;
size?: 'sm' | 'md' | 'lg';
className?: string;
}) {
const tone = TONE_CLASSES[toneIndex(manufacturerName)];
return (
<div
className={cn(
'relative flex size-full items-end overflow-hidden text-tone-ink',
size === 'sm' ? 'p-1.5' : 'p-3',
tone,
className,
)}
>
{size !== 'sm' ? (
<svg
viewBox="0 0 70 44"
aria-hidden
className={cn(
'absolute opacity-40',
size === 'lg' ? 'top-[18%] right-[12%] w-2/5' : 'top-3 right-3 w-1/3',
)}
>
<g fill="none" stroke="currentColor" strokeWidth="2.5">
<rect x="12" y="2" width="16" height="10" rx="3" />
<rect x="42" y="2" width="16" height="10" rx="3" />
<rect x="2" y="10" width="66" height="32" rx="7" />
</g>
</svg>
) : null}
<span
className={cn(
'relative leading-none font-bold tracking-tight [font-family:var(--font-display)] tabular-nums',
size === 'sm' && 'text-xs',
size === 'md' && (setNumber.length > 6 ? 'text-lg' : 'text-2xl'),
size === 'lg' && 'text-5xl',
)}
>
{setNumber}
</span>
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
import { cn } from 'cn';
/**
* The row of studs on top of a card. Purely decorative and only visible in
* designs that set `--studs: flex`. The parent must be `relative` and must not
* clip its overflow - place it next to the card, not inside.
*/
export function Studs({
count = 2,
className,
studClassName,
}: {
count?: number;
className?: string;
/** Colour of the studs, e.g. the collector's dot class. */
studClassName?: string;
}) {
return (
<span aria-hidden className={cn('studs', className)}>
{Array.from({ length: count }, (_, index) => (
<span key={index} className={cn('stud', studClassName)} />
))}
</span>
);
}
+64 -52
View File
@@ -25,13 +25,18 @@ type Controls = { stop: () => void };
* loaded only when the scanner is opened - nothing leaves the device, and the * loaded only when the scanner is opened - nothing leaves the device, and the
* catalog pages stay light for everyone who never scans. * catalog pages stay light for everyone who never scans.
*/ */
export function BarcodeScannerButton() { export function BarcodeScannerDialog({
open,
onOpenChange: setOpen,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const router = useRouter(); const router = useRouter();
const videoRef = useRef<HTMLVideoElement>(null); const videoRef = useRef<HTMLVideoElement>(null);
const controlsRef = useRef<Controls | null>(null); const controlsRef = useRef<Controls | null>(null);
const handledRef = useRef(false); const handledRef = useRef(false);
const [open, setOpen] = useState(false);
const [status, setStatus] = useState<'starting' | 'scanning' | 'checking' | 'error'>('starting'); const [status, setStatus] = useState<'starting' | 'scanning' | 'checking' | 'error'>('starting');
const [message, setMessage] = useState<string | null>(null); const [message, setMessage] = useState<string | null>(null);
@@ -68,7 +73,7 @@ export function BarcodeScannerButton() {
router.refresh(); router.refresh();
}); });
}, },
[router, stop], [router, setOpen, stop],
); );
useEffect(() => { useEffect(() => {
@@ -134,6 +139,61 @@ export function BarcodeScannerButton() {
}; };
}, [open, onDetected, stop]); }, [open, onDetected, stop]);
return (
<Dialog
open={open}
onOpenChange={(next) => {
if (!next) stop();
setOpen(next);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Barcode scannen</DialogTitle>
<DialogDescription>
Halte den Strichcode auf der Packung ins Bild. Kennt der Katalog das Set schon,
landest du direkt darauf – sonst im Formular mit ausgefüllter EAN.
</DialogDescription>
</DialogHeader>
{status === 'error' ? (
<p className="rounded-lg border border-dashed border-border p-4 text-sm text-muted-foreground">
{message}
</p>
) : (
<div className="relative overflow-hidden rounded-lg bg-black">
<video
ref={videoRef}
className="aspect-[3/4] w-full object-cover"
muted
playsInline
aria-label="Kamerabild"
/>
{/* A guide line makes people hold the box the right way round. */}
<div className="pointer-events-none absolute inset-x-6 top-1/2 h-0.5 -translate-y-1/2 bg-red-500/80" />
{status !== 'scanning' ? (
<div className="absolute inset-0 flex items-center justify-center gap-2 bg-black/60 text-sm text-white">
<Loader2 className="size-4 animate-spin" />
{status === 'checking' ? 'Im Katalog nachsehen …' : 'Kamera startet …'}
</div>
) : null}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
Abbrechen
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export function BarcodeScannerButton() {
const [open, setOpen] = useState(false);
return ( return (
<> <>
<Button <Button
@@ -145,55 +205,7 @@ export function BarcodeScannerButton() {
> >
<ScanLine /> <ScanLine />
</Button> </Button>
<BarcodeScannerDialog open={open} onOpenChange={setOpen} />
<Dialog
open={open}
onOpenChange={(next) => {
if (!next) stop();
setOpen(next);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Barcode scannen</DialogTitle>
<DialogDescription>
Halte den Strichcode auf der Packung ins Bild. Kennt der Katalog das Set schon,
landest du direkt darauf – sonst im Formular mit ausgefüllter EAN.
</DialogDescription>
</DialogHeader>
{status === 'error' ? (
<p className="rounded-lg border border-dashed border-border p-4 text-sm text-muted-foreground">
{message}
</p>
) : (
<div className="relative overflow-hidden rounded-lg bg-black">
<video
ref={videoRef}
className="aspect-[3/4] w-full object-cover"
muted
playsInline
aria-label="Kamerabild"
/>
{/* A guide line makes people hold the box the right way round. */}
<div className="pointer-events-none absolute inset-x-6 top-1/2 h-0.5 -translate-y-1/2 bg-red-500/80" />
{status !== 'scanning' ? (
<div className="absolute inset-0 flex items-center justify-center gap-2 bg-black/60 text-sm text-white">
<Loader2 className="size-4 animate-spin" />
{status === 'checking' ? 'Im Katalog nachsehen …' : 'Kamera startet …'}
</div>
) : null}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
Abbrechen
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</> </>
); );
} }
+13 -2
View File
@@ -33,6 +33,8 @@ type Props = {
wishlists: WishlistTarget[]; wishlists: WishlistTarget[];
/** Preselected target, used by the embedded view on a wishlist page. */ /** Preselected target, used by the embedded view on a wishlist page. */
defaultWishlistId?: string; defaultWishlistId?: string;
/** A list created from a filtered view belongs to that collector. */
collectorId?: string;
emptyState?: React.ReactNode; emptyState?: React.ReactNode;
}; };
@@ -43,7 +45,13 @@ type Props = {
* On a desktop the "Auswählen" button does the same job. Sets that are already * On a desktop the "Auswählen" button does the same job. Sets that are already
* in the collection cannot be selected - they do not belong on a wishlist. * in the collection cannot be selected - they do not belong on a wishlist.
*/ */
export function CatalogGrid({ sets, wishlists, defaultWishlistId, emptyState }: Props) { export function CatalogGrid({
sets,
wishlists,
defaultWishlistId,
collectorId,
emptyState,
}: Props) {
const router = useRouter(); const router = useRouter();
const [selecting, setSelecting] = useState(false); const [selecting, setSelecting] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set()); const [selected, setSelected] = useState<Set<string>>(new Set());
@@ -102,6 +110,7 @@ export function CatalogGrid({ sets, wishlists, defaultWishlistId, emptyState }:
title: newTitle, title: newTitle,
occasion: 'other', occasion: 'other',
isPublic: 'on', isPublic: 'on',
collectorId: collectorId ?? '',
}); });
if (!created.ok) { if (!created.ok) {
toast.error(created.error); toast.error(created.error);
@@ -199,6 +208,7 @@ export function CatalogGrid({ sets, wishlists, defaultWishlistId, emptyState }:
<button <button
key={item.id} key={item.id}
type="button" type="button"
data-set-card
disabled={owned} disabled={owned}
aria-pressed={isSelected} aria-pressed={isSelected}
onClick={() => toggle(item.id)} onClick={() => toggle(item.id)}
@@ -207,7 +217,8 @@ export function CatalogGrid({ sets, wishlists, defaultWishlistId, emptyState }:
'relative rounded-xl text-left outline-none transition-opacity', 'relative rounded-xl text-left outline-none transition-opacity',
'focus-visible:ring-3 focus-visible:ring-ring/50', 'focus-visible:ring-3 focus-visible:ring-ring/50',
owned && 'cursor-not-allowed opacity-50', owned && 'cursor-not-allowed opacity-50',
isSelected && '[&>div]:border-primary [&>div]:ring-2 [&>div]:ring-primary/40', isSelected &&
'[&_.brick]:outline-3 [&_.brick]:outline-offset-2 [&_.brick]:outline-primary',
)} )}
> >
<SetCardContent set={item} /> <SetCardContent set={item} />
+166
View File
@@ -0,0 +1,166 @@
'use client';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useEffect, useState, useTransition } from 'react';
import { Link2, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { previewSetFromLink, type LinkPreview } from '@/app/(app)/katalog/link-actions';
import { SetForm, type SetPrefill } from '@/components/katalog/set-form';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Field } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
function toPrefill(preview: LinkPreview): SetPrefill {
return {
manufacturerId: preview.manufacturerId ?? undefined,
setNumber: preview.setNumber || undefined,
name: preview.name || undefined,
year: preview.year,
pieceCount: preview.pieceCount,
minifigCount: preview.minifigCount,
ean: preview.ean || null,
imageUrl: preview.imageUrl || null,
cacheImage: Boolean(preview.imageUrl),
notes: `Übernommen aus: ${preview.sourceUrl}`,
};
}
/**
* "Neues Set" with the link import on top: paste a link from a shop, a price
* comparison or a set database, check the prefilled form, save.
*/
export function NewSetEntry({
manufacturers,
themes,
defaultEan,
focus,
children,
}: {
manufacturers: Array<{ id: string; name: string }>;
themes: Array<{ id: string; name: string; manufacturerId: string }>;
defaultEan?: string;
/** Set by the plus menu: start in the link field or in the form. */
focus?: 'link' | 'hand';
/** Shown instead of the form while there is no manufacturer yet. */
children?: React.ReactNode;
}) {
const router = useRouter();
const [url, setUrl] = useState('');
const [prefill, setPrefill] = useState<SetPrefill | undefined>(undefined);
const [version, setVersion] = useState(0);
const [existing, setExisting] = useState<{ id: string; label: string } | null>(null);
const [pending, startTransition] = useTransition();
useEffect(() => {
if (focus !== 'hand') return;
const field = document.getElementById('setNumber');
field?.scrollIntoView({ block: 'center' });
field?.focus({ preventScroll: true });
}, [focus]);
function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setExisting(null);
startTransition(async () => {
const result = await previewSetFromLink({ url });
if (!result.ok) {
toast.error(result.error);
return;
}
const preview = result.data;
if (preview.existingSetId) {
setExisting({
id: preview.existingSetId,
label: [preview.setNumber, preview.name].filter(Boolean).join(' · '),
});
return;
}
setPrefill(toPrefill(preview));
setVersion((value) => value + 1);
// A manufacturer may just have been created for the draft.
router.refresh();
const missing = [
preview.manufacturerId ? null : 'Hersteller',
preview.setNumber ? null : 'Setnummer',
preview.name ? null : 'Name',
].filter(Boolean);
toast.success(
missing.length === 0
? `Übernommen aus ${preview.sources.join(', ')}. Bitte kurz prüfen und speichern.`
: `Übernommen aus ${preview.sources.join(', ')} – ${missing.join(', ')} bitte ergänzen.`,
);
if (preview.pageBlocked) {
toast.info('Die Seite selbst ließ sich nicht lesen – die Angaben stammen aus dem Link.');
}
});
}
return (
<>
<Card>
<CardHeader>
<CardTitle>Aus einem Link übernehmen</CardTitle>
<CardDescription>
Link zu Amazon, idealo, geizhals, zum Shop des Herstellers oder zu einer Set-Datenbank
einfügen. Die Angaben landen im Formular unten – gespeichert wird erst, wenn du es
sagst.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<form onSubmit={submit} className="flex items-end gap-2">
<Field label="Link" htmlFor="import-link" className="flex-1">
<Input
id="import-link"
type="url"
inputMode="url"
value={url}
onChange={(event) => setUrl(event.target.value)}
placeholder="https://…"
maxLength={2000}
autoFocus={focus === 'link'}
/>
</Field>
<Button type="submit" size="lg" disabled={pending || url.trim().length === 0}>
{pending ? <Loader2 className="animate-spin" /> : <Link2 />}
Übernehmen
</Button>
</form>
{existing ? (
<p className="rounded-lg bg-muted p-3 text-sm">
Das Set ist schon im Katalog:{' '}
<Link href={`/katalog/${existing.id}`} className="font-semibold underline">
{existing.label}
</Link>
</p>
) : null}
</CardContent>
</Card>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span className="h-px flex-1 bg-border" />
{prefill ? 'Bitte prüfen und speichern' : 'oder von Hand'}
<span className="h-px flex-1 bg-border" />
</div>
{manufacturers.length === 0 ? (
children
) : (
<SetForm
key={`${version}-${manufacturers.length}`}
manufacturers={manufacturers}
themes={themes}
defaultEan={defaultEan}
prefill={prefill}
/>
)}
</>
);
}
+206
View File
@@ -0,0 +1,206 @@
'use client';
import { useRouter } from 'next/navigation';
import { useState, useTransition } from 'react';
import { Loader2, Plus, TrendingDown, TrendingUp, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { addPricePoint, deletePricePoint } from '@/app/(app)/katalog/price-actions';
import { formatDate, toDateInputValue } from '@/lib/format';
import { formatEuro } from '@/lib/money';
import { priceTrend, type PriceObservation } from '@/lib/prices';
import type { PricePointRow } from '@/lib/queries/catalog';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Field } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { NativeSelect } from '@/components/ui/native-select';
const CONDITION_LABELS: Record<string, string> = {
new: 'Neu',
used: 'Gebraucht',
};
/**
* Observed prices for one set.
*
* The point is not the single current price but the development: what a set
* did since it was bought. Everything here is maintained by hand - no price
* service is queried, and nothing leaves the server.
*/
export function PriceSection({
setId,
prices,
retailPriceCents,
}: {
setId: string;
prices: PricePointRow[];
retailPriceCents: number | null;
}) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [open, setOpen] = useState(false);
const observations: PriceObservation[] = prices.map((price) => ({
priceCents: price.priceCents,
condition: price.condition,
observedAt: price.observedAt,
}));
const trend = priceTrend(observations, retailPriceCents);
const gained = (trend.changeCents ?? 0) > 0;
function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const form = event.currentTarget;
const input = Object.fromEntries(new FormData(form).entries());
startTransition(async () => {
const result = await addPricePoint(setId, input);
if (!result.ok) {
toast.error(result.error);
return;
}
form.reset();
setOpen(false);
toast.success('Preis notiert.');
router.refresh();
});
}
function remove(id: string) {
startTransition(async () => {
const result = await deletePricePoint(id);
if (!result.ok) {
toast.error(result.error);
return;
}
toast.success('Eintrag gelöscht.');
router.refresh();
});
}
return (
<div className="space-y-4">
<dl className="grid grid-cols-3 gap-3 text-sm">
<div>
<dt className="text-xs text-muted-foreground">Ursprünglich</dt>
<dd className="font-medium">{formatEuro(trend.baseCents)}</dd>
</div>
<div>
<dt className="text-xs text-muted-foreground">Aktuell</dt>
<dd className="font-medium">{formatEuro(trend.currentCents)}</dd>
</div>
<div>
<dt className="text-xs text-muted-foreground">Entwicklung</dt>
<dd>
{trend.changePercent === null ? (
<span className="text-muted-foreground">–</span>
) : (
<span
className={`flex items-center gap-1 font-medium ${
gained ? 'text-emerald-600' : 'text-muted-foreground'
}`}
>
{gained ? (
<TrendingUp className="size-4" aria-hidden />
) : (
<TrendingDown className="size-4" aria-hidden />
)}
{trend.changePercent > 0 ? '+' : ''}
{trend.changePercent.toLocaleString('de-DE')} %
</span>
)}
</dd>
</div>
</dl>
{prices.length > 0 ? (
<ul className="divide-y divide-border rounded-lg border border-border">
{prices.map((price) => (
<li key={price.id} className="flex items-center gap-3 p-3">
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">
{formatEuro(price.priceCents)}{' '}
<Badge variant="secondary">{CONDITION_LABELS[price.condition]}</Badge>
</p>
<p className="truncate text-xs text-muted-foreground">
{[formatDate(price.observedAt), price.source, price.note]
.filter(Boolean)
.join(' · ')}
</p>
</div>
<Button
variant="ghost"
size="icon-sm"
aria-label="Preis löschen"
onClick={() => remove(price.id)}
disabled={pending}
>
<Trash2 className="text-destructive" />
</Button>
</li>
))}
</ul>
) : (
<p className="text-sm text-muted-foreground">
Noch kein Preis notiert. Der Ausgangswert ist die UVP des Sets.
</p>
)}
{open ? (
<form onSubmit={submit} className="space-y-3 rounded-lg border border-border p-3">
<p className="text-sm font-medium">Preis notieren</p>
<div className="grid gap-3 sm:grid-cols-3">
<Field label="Preis" htmlFor="priceCents" required hint="In Euro.">
<Input
id="priceCents"
name="priceCents"
inputMode="decimal"
required
placeholder="79,99"
/>
</Field>
<Field label="Zustand" htmlFor="condition">
<NativeSelect id="condition" name="condition" defaultValue="new">
<option value="new">Neu</option>
<option value="used">Gebraucht</option>
</NativeSelect>
</Field>
<Field label="Gesehen am" htmlFor="observedAt" hint="Leer = heute.">
<Input
id="observedAt"
name="observedAt"
type="date"
defaultValue={toDateInputValue(new Date())}
/>
</Field>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<Field label="Wo" htmlFor="source">
<Input id="source" name="source" maxLength={80} placeholder="Amazon, Flohmarkt …" />
</Field>
<Field label="Notiz" htmlFor="price-note">
<Input id="price-note" name="note" maxLength={200} />
</Field>
</div>
<div className="flex gap-2">
<Button type="submit" disabled={pending}>
{pending ? <Loader2 className="animate-spin" /> : <Plus />}
Speichern
</Button>
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
Abbrechen
</Button>
</div>
</form>
) : (
<Button variant="outline" onClick={() => setOpen(true)}>
<Plus />
Preis notieren
</Button>
)}
</div>
);
}
@@ -14,7 +14,7 @@ import { Input } from '@/components/ui/input';
* One-field Lego import: type the number from the box, get a finished catalog * One-field Lego import: type the number from the box, get a finished catalog
* entry. Hidden entirely when no API key is configured. * entry. Hidden entirely when no API key is configured.
*/ */
export function RebrickableImport({ enabled }: { enabled: boolean }) { export function RebrickableImport({ enabled, autoFocus }: { enabled: boolean; autoFocus?: boolean }) {
const router = useRouter(); const router = useRouter();
const [setNumber, setSetNumber] = useState(''); const [setNumber, setSetNumber] = useState('');
const [pending, startTransition] = useTransition(); const [pending, startTransition] = useTransition();
@@ -80,6 +80,7 @@ export function RebrickableImport({ enabled }: { enabled: boolean }) {
onChange={(event) => setSetNumber(event.target.value)} onChange={(event) => setSetNumber(event.target.value)}
placeholder="60398" placeholder="60398"
inputMode="numeric" inputMode="numeric"
autoFocus={autoFocus}
maxLength={40} maxLength={40}
className="font-mono" className="font-mono"
/> />
+52 -4
View File
@@ -38,32 +38,47 @@ export function SetActions({
setName, setName,
owned, owned,
wishlists, wishlists,
collectors,
}: { }: {
setId: string; setId: string;
setName: string; setName: string;
owned: boolean; owned: boolean;
wishlists: Array<{ id: string; title: string }>; wishlists: Array<{ id: string; title: string }>;
/** More than one turns "Zur Sammlung" into a question of "for whom". */
collectors: Array<{ id: string; name: string }>;
}) { }) {
const router = useRouter(); const router = useRouter();
const [pending, startTransition] = useTransition(); const [pending, startTransition] = useTransition();
const [wishlistOpen, setWishlistOpen] = useState(false); const [wishlistOpen, setWishlistOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false);
const [targetId, setTargetId] = useState(wishlists[0]?.id ?? NEW_LIST); const [targetId, setTargetId] = useState(wishlists[0]?.id ?? NEW_LIST);
const [collectorOpen, setCollectorOpen] = useState(false);
const [collectorId, setCollectorId] = useState(collectors[0]?.id ?? '');
const [newTitle, setNewTitle] = useState(''); const [newTitle, setNewTitle] = useState('');
const [priority, setPriority] = useState('2'); const [priority, setPriority] = useState('2');
function addToCollection() { function addToCollection(forCollectorId?: string) {
startTransition(async () => { startTransition(async () => {
const result = await addSetToCollection(setId); const result = await addSetToCollection(setId, forCollectorId);
if (!result.ok) { if (!result.ok) {
toast.error(result.error); toast.error(result.error);
return; return;
} }
toast.success('Zur Sammlung hinzugefügt.'); const owner = collectors.find((entry) => entry.id === forCollectorId);
toast.success(
owner ? `Zur Sammlung von ${owner.name} hinzugefügt.` : 'Zur Sammlung hinzugefügt.',
);
setCollectorOpen(false);
router.refresh(); router.refresh();
}); });
} }
// With a single collector there is nothing to ask.
function onAddClick() {
if (collectors.length > 1) setCollectorOpen(true);
else addToCollection();
}
function addToWishlist() { function addToWishlist() {
startTransition(async () => { startTransition(async () => {
let wishlistId = targetId; let wishlistId = targetId;
@@ -111,7 +126,7 @@ export function SetActions({
return ( return (
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button size="lg" onClick={addToCollection} disabled={pending}> <Button size="lg" onClick={onAddClick} disabled={pending}>
{pending ? <Loader2 className="animate-spin" /> : <Plus />} {pending ? <Loader2 className="animate-spin" /> : <Plus />}
Zur Sammlung hinzufügen Zur Sammlung hinzufügen
</Button> </Button>
@@ -138,6 +153,39 @@ export function SetActions({
</p> </p>
) : null} ) : null}
<Dialog open={collectorOpen} onOpenChange={setCollectorOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Wer bekommt das Set?</DialogTitle>
<DialogDescription>{setName}</DialogDescription>
</DialogHeader>
<Field label="Sammler" htmlFor="add-collector" required>
<NativeSelect
id="add-collector"
value={collectorId}
onChange={(event) => setCollectorId(event.target.value)}
>
{collectors.map((entry) => (
<option key={entry.id} value={entry.id}>
{entry.name}
</option>
))}
</NativeSelect>
</Field>
<DialogFooter>
<Button variant="outline" onClick={() => setCollectorOpen(false)} disabled={pending}>
Abbrechen
</Button>
<Button onClick={() => addToCollection(collectorId)} disabled={pending}>
{pending ? <Loader2 className="animate-spin" /> : <Plus />}
Hinzufügen
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={wishlistOpen} onOpenChange={setWishlistOpen}> <Dialog open={wishlistOpen} onOpenChange={setWishlistOpen}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
+53 -48
View File
@@ -1,6 +1,8 @@
import Link from 'next/link'; import Link from 'next/link';
import { Check, Gift, ImageOff } from 'lucide-react'; import { Check, Gift } from 'lucide-react';
import { cn } from 'cn'; import { cn } from 'cn';
import { SetPlaceholder } from '@/components/brick/set-placeholder';
import { Studs } from '@/components/brick/studs';
import type { CatalogRow } from '@/lib/queries/catalog'; import type { CatalogRow } from '@/lib/queries/catalog';
import { formatEuro } from '@/lib/money'; import { formatEuro } from '@/lib/money';
import { setImageSrc } from '@/lib/images'; import { setImageSrc } from '@/lib/images';
@@ -17,56 +19,59 @@ export function SetCardContent({ set }: { set: CatalogRow }) {
const owned = set.ownedCopies > 0; const owned = set.ownedCopies > 0;
return ( return (
<div className="flex h-full flex-col overflow-hidden rounded-xl border border-border bg-card transition-colors"> <div className="relative h-full">
<div className="relative aspect-square bg-muted"> <Studs count={3} />
{image ? ( <div className="brick flex h-full flex-col overflow-hidden rounded-xl bg-card transition-colors">
// Plain img: images are either local files or a URL the user entered, <div className="relative aspect-square border-b-[length:var(--edge-w)] border-edge bg-muted">
// and the optimizer must never fetch arbitrary remote hosts. {image ? (
// eslint-disable-next-line @next/next/no-img-element // Plain img: images are either local files or a URL the user entered,
<img // and the optimizer must never fetch arbitrary remote hosts.
src={image} // eslint-disable-next-line @next/next/no-img-element
alt="" <img
loading="lazy" src={image}
decoding="async" alt=""
className="size-full object-contain p-2" loading="lazy"
/> decoding="async"
) : ( className="size-full object-contain p-2"
<div className="flex size-full items-center justify-center text-muted-foreground"> />
<ImageOff className="size-8" aria-hidden /> ) : (
<SetPlaceholder setNumber={set.setNumber} manufacturerName={set.manufacturerName} />
)}
<div className="absolute top-2 left-2 flex flex-col items-start gap-1">
{owned ? (
<Badge className="gap-1 bg-owned text-owned-foreground">
<Check className="size-3" aria-hidden />
{set.ownedCopies > 1 ? `${set.ownedCopies}× vorhanden` : 'Vorhanden'}
</Badge>
) : null}
{set.wishlistCount > 0 ? (
<Badge className="gap-1">
<Gift className="size-3" aria-hidden />
Auf Wunschliste
</Badge>
) : null}
</div> </div>
)}
<div className="absolute top-2 left-2 flex flex-col items-start gap-1">
{owned ? (
<Badge className="gap-1 bg-emerald-600 text-white hover:bg-emerald-600">
<Check className="size-3" aria-hidden />
{set.ownedCopies > 1 ? `${set.ownedCopies}× vorhanden` : 'Vorhanden'}
</Badge>
) : null}
{set.wishlistCount > 0 ? (
<Badge variant="secondary" className="gap-1">
<Gift className="size-3" aria-hidden />
Auf Wunschliste
</Badge>
) : null}
</div> </div>
</div>
<div className="flex flex-1 flex-col gap-1 p-3"> <div className="flex flex-1 flex-col gap-1 p-3">
<div className="flex items-center gap-2 text-xs text-muted-foreground"> <div className="flex items-center gap-2 text-xs text-muted-foreground">
<Badge variant="outline" className="font-mono"> <Badge variant="outline" className="font-mono">
{set.setNumber} {set.setNumber}
</Badge> </Badge>
<span className="truncate">{set.manufacturerName}</span> <span className="truncate">{set.manufacturerName}</span>
</div> </div>
<p className="line-clamp-2 text-sm leading-snug font-medium">{set.name}</p> <p className="line-clamp-2 text-sm leading-snug font-medium">{set.name}</p>
<div className="mt-auto flex items-center justify-between gap-2 pt-1 text-xs text-muted-foreground"> <div className="mt-auto flex items-center justify-between gap-2 pt-1 text-xs text-muted-foreground">
<span className="truncate">{[set.themeName, set.year].filter(Boolean).join(' · ')}</span> <span className="truncate">
{set.retailPriceCents !== null ? ( {[set.themeName, set.year].filter(Boolean).join(' · ')}
<span className="shrink-0 font-medium text-foreground">
{formatEuro(set.retailPriceCents)}
</span> </span>
) : null} {set.retailPriceCents !== null ? (
<span className="shrink-0 font-medium text-foreground">
{formatEuro(set.retailPriceCents)}
</span>
) : null}
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -78,7 +83,7 @@ export function SetCard({ set, className }: { set: CatalogRow; className?: strin
<Link <Link
href={`/katalog/${set.id}`} href={`/katalog/${set.id}`}
className={cn( className={cn(
'rounded-xl outline-none focus-visible:ring-3 focus-visible:ring-ring/50 [&>div]:hover:border-primary/40', 'rounded-xl outline-none focus-visible:ring-3 focus-visible:ring-ring/50',
className, className,
)} )}
> >
+157 -31
View File
@@ -1,13 +1,28 @@
'use client'; 'use client';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useState, useTransition } from 'react'; import { useRef, useState, useTransition } from 'react';
import Link from 'next/link';
import { Loader2, Save } from 'lucide-react'; import { Loader2, Save } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { createSet, updateSet } from '@/app/(app)/katalog/actions'; import { createSet, updateSet } from '@/app/(app)/katalog/actions';
import {
findDuplicateCandidates,
type DuplicateCandidate,
} from '@/app/(app)/katalog/duplicate-actions';
import { centsToInput } from '@/lib/money'; import { centsToInput } from '@/lib/money';
import { setImageSrc } from '@/lib/images'; import { setImageSrc } from '@/lib/images';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { Field } from '@/components/ui/field'; import { Field } from '@/components/ui/field';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@@ -32,20 +47,29 @@ export type SetFormValues = {
notes: string | null; notes: string | null;
}; };
/** A draft for a new set, e.g. from the link import. */
export type SetPrefill = Partial<Omit<SetFormValues, 'id' | 'imagePath'>> & {
/** Download imageUrl on save and keep it locally. */
cacheImage?: boolean;
};
type Props = { type Props = {
manufacturers: Array<{ id: string; name: string }>; manufacturers: Array<{ id: string; name: string }>;
themes: Array<{ id: string; name: string; manufacturerId: string }>; themes: Array<{ id: string; name: string; manufacturerId: string }>;
set?: SetFormValues; set?: SetFormValues;
/** Prefilled EAN coming from the barcode scanner (phase 2). */ /** Prefilled EAN coming from the barcode scanner (phase 2). */
defaultEan?: string; defaultEan?: string;
prefill?: SetPrefill;
}; };
export function SetForm({ manufacturers, themes, set, defaultEan }: Props) { export function SetForm({ manufacturers, themes, set, defaultEan, prefill }: Props) {
const router = useRouter(); const router = useRouter();
const [pending, startTransition] = useTransition(); const [pending, startTransition] = useTransition();
const [manufacturerId, setManufacturerId] = useState( const [manufacturerId, setManufacturerId] = useState(
set?.manufacturerId ?? manufacturers[0]?.id ?? '', set?.manufacturerId ?? prefill?.manufacturerId ?? manufacturers[0]?.id ?? '',
); );
const [duplicates, setDuplicates] = useState<DuplicateCandidate[] | null>(null);
const pendingForm = useRef<FormData | null>(null);
const existingImage = set ? setImageSrc(set) : null; const existingImage = set ? setImageSrc(set) : null;
const availableThemes = themes.filter((theme) => theme.manufacturerId === manufacturerId); const availableThemes = themes.filter((theme) => theme.manufacturerId === manufacturerId);
@@ -54,32 +78,70 @@ export function SetForm({ manufacturers, themes, set, defaultEan }: Props) {
event.preventDefault(); event.preventDefault();
const formData = new FormData(event.currentTarget); const formData = new FormData(event.currentTarget);
startTransition(async () => { // Ask before creating something that already exists. Editing skips this:
if (set) { // there the entry is the one we would be warning about.
const result = await updateSet(set.id, formData); if (!set) {
if (!result.ok) { startTransition(async () => {
toast.error(result.error); const check = await findDuplicateCandidates({
manufacturerId: String(formData.get('manufacturerId') ?? ''),
setNumber: String(formData.get('setNumber') ?? ''),
name: String(formData.get('name') ?? ''),
});
if (check.ok && check.data.candidates.length > 0) {
pendingForm.current = formData;
setDuplicates(check.data.candidates);
return; return;
} }
toast.success('Set gespeichert.');
router.push(`/katalog/${set.id}`);
router.refresh();
return;
}
const result = await createSet(formData); await save(formData);
if (!result.ok) { });
toast.error(result.error); return;
return; }
}
toast.success('Set angelegt.'); startTransition(() => save(formData));
router.push(`/katalog/${result.data.id}`); }
router.refresh();
async function save(formData: FormData) {
return new Promise<void>((resolve) => {
startTransition(async () => {
if (set) {
const result = await updateSet(set.id, formData);
if (!result.ok) {
toast.error(result.error);
resolve();
return;
}
toast.success('Set gespeichert.');
router.push(`/katalog/${set.id}`);
router.refresh();
resolve();
return;
}
const result = await createSet(formData);
if (!result.ok) {
toast.error(result.error);
resolve();
return;
}
toast.success('Set angelegt.');
router.push(`/katalog/${result.data.id}`);
router.refresh();
resolve();
});
}); });
} }
const REASON_LABELS: Record<DuplicateCandidate['reason'], string> = {
'same-number': 'Gleiche Setnummer beim gleichen Hersteller',
'similar-name': 'Sehr ähnlicher Name',
'number-elsewhere': 'Gleiche Setnummer bei einem anderen Hersteller',
};
return ( return (
<form onSubmit={onSubmit} className="space-y-6"> <>
<form onSubmit={onSubmit} className="space-y-6">
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<Field label="Hersteller" htmlFor="manufacturerId" required> <Field label="Hersteller" htmlFor="manufacturerId" required>
<NativeSelect <NativeSelect
@@ -112,7 +174,7 @@ export function SetForm({ manufacturers, themes, set, defaultEan }: Props) {
<Input <Input
id="setNumber" id="setNumber"
name="setNumber" name="setNumber"
defaultValue={set?.setNumber ?? ''} defaultValue={set?.setNumber ?? prefill?.setNumber ?? ''}
required required
maxLength={40} maxLength={40}
placeholder="60398" placeholder="60398"
@@ -124,7 +186,7 @@ export function SetForm({ manufacturers, themes, set, defaultEan }: Props) {
<Input <Input
id="name" id="name"
name="name" name="name"
defaultValue={set?.name ?? ''} defaultValue={set?.name ?? prefill?.name ?? ''}
required required
maxLength={160} maxLength={160}
placeholder="Familienhaus und Elektroauto" placeholder="Familienhaus und Elektroauto"
@@ -137,7 +199,7 @@ export function SetForm({ manufacturers, themes, set, defaultEan }: Props) {
name="year" name="year"
type="number" type="number"
inputMode="numeric" inputMode="numeric"
defaultValue={set?.year ?? ''} defaultValue={set?.year ?? prefill?.year ?? ''}
min={1930} min={1930}
max={new Date().getFullYear() + 5} max={new Date().getFullYear() + 5}
/> />
@@ -149,7 +211,7 @@ export function SetForm({ manufacturers, themes, set, defaultEan }: Props) {
name="pieceCount" name="pieceCount"
type="number" type="number"
inputMode="numeric" inputMode="numeric"
defaultValue={set?.pieceCount ?? ''} defaultValue={set?.pieceCount ?? prefill?.pieceCount ?? ''}
min={0} min={0}
/> />
</Field> </Field>
@@ -160,7 +222,7 @@ export function SetForm({ manufacturers, themes, set, defaultEan }: Props) {
name="minifigCount" name="minifigCount"
type="number" type="number"
inputMode="numeric" inputMode="numeric"
defaultValue={set?.minifigCount ?? ''} defaultValue={set?.minifigCount ?? prefill?.minifigCount ?? ''}
min={0} min={0}
/> />
</Field> </Field>
@@ -180,7 +242,7 @@ export function SetForm({ manufacturers, themes, set, defaultEan }: Props) {
id="ean" id="ean"
name="ean" name="ean"
inputMode="numeric" inputMode="numeric"
defaultValue={set?.ean ?? defaultEan ?? ''} defaultValue={set?.ean ?? prefill?.ean ?? defaultEan ?? ''}
maxLength={20} maxLength={20}
className="font-mono" className="font-mono"
/> />
@@ -201,10 +263,18 @@ export function SetForm({ manufacturers, themes, set, defaultEan }: Props) {
name="imageUrl" name="imageUrl"
type="url" type="url"
inputMode="url" inputMode="url"
defaultValue={set?.imageUrl ?? ''} defaultValue={set?.imageUrl ?? prefill?.imageUrl ?? ''}
placeholder="https://…" placeholder="https://…"
/> />
</Field> </Field>
{prefill?.cacheImage && prefill.imageUrl ? (
<>
<input type="hidden" name="cacheImage" value="on" />
<p className="-mt-4 text-xs text-muted-foreground">
Das Bild aus dem Link wird beim Speichern heruntergeladen und lokal abgelegt.
</p>
</>
) : null}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="image">Bild hochladen</Label> <Label htmlFor="image">Bild hochladen</Label>
@@ -231,7 +301,12 @@ export function SetForm({ manufacturers, themes, set, defaultEan }: Props) {
</div> </div>
<Field label="Notizen" htmlFor="notes"> <Field label="Notizen" htmlFor="notes">
<Textarea id="notes" name="notes" defaultValue={set?.notes ?? ''} rows={4} /> <Textarea
id="notes"
name="notes"
defaultValue={set?.notes ?? prefill?.notes ?? ''}
rows={4}
/>
</Field> </Field>
<div className="flex gap-3"> <div className="flex gap-3">
@@ -243,6 +318,57 @@ export function SetForm({ manufacturers, themes, set, defaultEan }: Props) {
Abbrechen Abbrechen
</Button> </Button>
</div> </div>
</form> </form>
<AlertDialog
open={duplicates !== null}
onOpenChange={(open) => {
if (!open) {
setDuplicates(null);
pendingForm.current = null;
}
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Gibt es das vielleicht schon?</AlertDialogTitle>
<AlertDialogDescription>
Diese Einträge sehen ähnlich aus. Schau kurz nach, bevor der Katalog zwei Mal
dasselbe Set enthält.
</AlertDialogDescription>
</AlertDialogHeader>
<ul className="divide-y divide-border rounded-lg border border-border">
{(duplicates ?? []).map((candidate) => (
<li key={candidate.id} className="p-3">
<Link
href={`/katalog/${candidate.id}`}
className="text-sm font-medium hover:underline"
>
{candidate.setNumber} · {candidate.name}
</Link>
<p className="text-xs text-muted-foreground">
{candidate.manufacturerName} — {REASON_LABELS[candidate.reason]}
</p>
</li>
))}
</ul>
<AlertDialogFooter>
<AlertDialogCancel>Zurück zum Formular</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
const formData = pendingForm.current;
setDuplicates(null);
pendingForm.current = null;
if (formData) void save(formData);
}}
>
Trotzdem anlegen
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
); );
} }
+88
View File
@@ -0,0 +1,88 @@
'use client';
import { useRouter } from 'next/navigation';
import { useState, useTransition } from 'react';
import { Check } from 'lucide-react';
import { toast } from 'sonner';
import { cn } from 'cn';
import { setDesignAction } from '@/app/(app)/konto/actions';
import { DESIGN_INFO, DESIGNS, type Design } from '@/lib/design';
/** Switches the look right away and stores it in the cookie in the background. */
function applyDesign(design: Design) {
const root = document.documentElement;
root.dataset.design = design;
root.classList.toggle('dark', DESIGN_INFO[design].dark);
}
export function DesignPicker({ current }: { current: Design }) {
const router = useRouter();
const [selected, setSelected] = useState<Design>(current);
const [, startTransition] = useTransition();
function choose(design: Design) {
const previous = selected;
setSelected(design);
applyDesign(design);
startTransition(async () => {
const result = await setDesignAction({ design });
if (!result.ok) {
setSelected(previous);
applyDesign(previous);
toast.error(result.error);
return;
}
router.refresh();
});
}
return (
<fieldset>
<legend className="sr-only">Design</legend>
<div className="grid gap-3 sm:grid-cols-2">
{DESIGNS.map((design) => {
const info = DESIGN_INFO[design];
const active = design === selected;
return (
<label
key={design}
className={cn(
'relative flex cursor-pointer items-center gap-3 rounded-lg border-[length:max(1px,var(--edge-w))] p-3 transition-colors',
'has-focus-visible:ring-3 has-focus-visible:ring-ring/50',
active ? 'border-primary bg-accent' : 'border-border hover:bg-muted/60',
)}
>
<input
type="radio"
name="design"
value={design}
checked={active}
onChange={() => choose(design)}
className="sr-only"
/>
<span
aria-hidden
className="grid size-12 shrink-0 grid-cols-2 overflow-hidden rounded-md border border-black/15"
>
{info.swatch.map((color, index) => (
<span key={index} style={{ backgroundColor: color }} />
))}
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-semibold">
{info.label}
{design === 'klemmbaustein' ? (
<span className="font-normal text-muted-foreground"> · Standard</span>
) : null}
</span>
<span className="block text-xs text-muted-foreground">{info.description}</span>
</span>
{active ? <Check className="size-5 shrink-0 text-primary" aria-hidden /> : null}
</label>
);
})}
</div>
</fieldset>
);
}
+142
View File
@@ -0,0 +1,142 @@
'use client';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { Hash, Link2, PencilLine, Plus, ScanLine, type LucideIcon } from 'lucide-react';
import { cn } from 'cn';
import { Studs } from '@/components/brick/studs';
import { BarcodeScannerDialog } from '@/components/katalog/barcode-scanner';
import { Button } from '@/components/ui/button';
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@/components/ui/sheet';
type Action = {
id: string;
label: string;
hint: string;
icon: LucideIcon;
tone: string;
href?: string;
};
/**
* The plus in the middle of the navigation: every way a set gets into the
* catalog, in one place. Opens as a sheet from the bottom.
*/
export function AddMenu({
legoImport,
variant = 'nav',
}: {
/** Rebrickable is configured. */
legoImport: boolean;
variant?: 'nav' | 'button';
}) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [scanning, setScanning] = useState(false);
const actions: Action[] = [
{ id: 'scan', label: 'Scannen', hint: 'Strichcode der Packung', icon: ScanLine, tone: 'bg-tone-1' },
{
id: 'link',
label: 'Aus Link',
hint: 'Amazon, idealo, Shop',
icon: Link2,
tone: 'bg-tone-2',
href: '/katalog/neu?weg=link',
},
...(legoImport
? [
{
id: 'lego',
label: 'Lego-Nummer',
hint: 'über Rebrickable',
icon: Hash,
tone: 'bg-tone-3',
href: '/katalog/neu?weg=lego',
},
]
: []),
{
id: 'hand',
label: 'Von Hand',
hint: 'alles selbst eintragen',
icon: PencilLine,
tone: 'bg-card',
href: '/katalog/neu?weg=hand',
},
];
function run(action: Action) {
setOpen(false);
if (action.id === 'scan') {
setScanning(true);
return;
}
if (action.href) router.push(action.href);
}
return (
<>
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
{variant === 'nav' ? (
<button
type="button"
aria-label="Neues Set"
className="relative -mt-7 flex h-14 w-16 items-center justify-center rounded-[calc(var(--radius)*0.9)] border-[length:var(--edge-w)] border-edge bg-primary text-primary-foreground shadow-(--lift) outline-none focus-visible:ring-3 focus-visible:ring-ring/50 active:translate-y-0.5"
>
<Studs count={2} className="left-2.5 gap-3 [&>.stud]:w-3.5 [&>.stud]:bg-primary" />
<Plus className={cn('size-7 transition-transform', open && 'rotate-45')} strokeWidth={3} />
</button>
) : (
<Button size="lg">
<Plus />
Neu
</Button>
)}
</SheetTrigger>
<SheetContent
side="bottom"
className="rounded-t-[calc(var(--radius)*1.6)] border-t-[length:max(1px,var(--edge-w))] border-edge bg-background pb-[calc(env(safe-area-inset-bottom)+1.5rem)]"
>
<SheetHeader>
<SheetTitle className="text-xl">Was kommt dazu?</SheetTitle>
<SheetDescription>Ein Set in den Katalog bringen – auf dem schnellsten Weg.</SheetDescription>
</SheetHeader>
<div className="mx-auto grid w-full max-w-lg grid-cols-2 gap-x-3 gap-y-5 px-4 pt-2">
{actions.map((action) => (
<button
key={action.id}
type="button"
onClick={() => run(action)}
className="relative text-left outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
>
<Studs studClassName={action.tone} />
<span
className={cn(
'brick flex h-full flex-col gap-1 rounded-xl p-3.5 text-foreground',
action.tone,
action.tone !== 'bg-card' && 'text-tone-ink',
)}
>
<action.icon className="size-6" aria-hidden />
<span className="text-base font-bold">{action.label}</span>
<span className="text-xs font-semibold opacity-80">{action.hint}</span>
</span>
</button>
))}
</div>
</SheetContent>
</Sheet>
<BarcodeScannerDialog open={scanning} onOpenChange={setScanning} />
</>
);
}
+44 -7
View File
@@ -1,21 +1,58 @@
import { Suspense } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { Blocks } from 'lucide-react'; import { BrickLogo } from '@/components/brick/logo';
import { DesktopNav } from './bottom-nav'; import { DesktopNav } from './bottom-nav';
import { UserMenu } from './user-menu'; import { CollectorSwitch, type CollectorOption } from './collector-switch';
import type { SessionUser } from '@/lib/session'; import type { SessionUser } from '@/lib/session';
export function AppHeader({ user }: { user: SessionUser }) { function initials(name: string): string {
const parts = name.trim().split(/\s+/);
const letters = (parts[0]?.[0] ?? '') + (parts.length > 1 ? (parts.at(-1)?.[0] ?? '') : '');
return letters.toUpperCase() || '?';
}
export function AppHeader({
user,
collectors,
storedCollectorId,
legoImport,
}: {
user: SessionUser;
collectors: CollectorOption[];
storedCollectorId: string | null;
legoImport: boolean;
}) {
return ( return (
<header className="sticky top-0 z-40 border-b border-border bg-background/95 backdrop-blur"> <header className="sticky top-0 z-40 border-b border-border bg-background/95 backdrop-blur">
<div className="mx-auto flex h-14 max-w-5xl items-center gap-3 px-4"> <div className="mx-auto flex h-14 max-w-5xl items-center gap-3 px-4">
<Link href="/" className="flex items-center gap-2 font-semibold tracking-tight"> <Link
<Blocks className="size-5 text-primary" aria-hidden /> href="/"
className="flex items-center gap-2 text-lg font-bold tracking-tight [font-family:var(--font-display)]"
>
<BrickLogo className="h-6 w-7" />
<span>Bricktrack</span> <span>Bricktrack</span>
</Link> </Link>
<div className="flex-1" /> <div className="flex-1" />
<DesktopNav /> <DesktopNav legoImport={legoImport} />
<UserMenu user={user} /> {/* Account, design and master data: everything that is not daily use. */}
<Link
href="/mehr"
aria-label="Mehr: Konto, Design und Verwaltung"
title="Mehr"
className="flex size-10 items-center justify-center rounded-[calc(var(--radius)*0.7)] border-[length:max(1px,var(--edge-w))] border-edge bg-card text-sm font-bold shadow-(--lift-sm) outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
>
{initials(user.name)}
</Link>
</div> </div>
{collectors.length > 1 ? (
<div className="mx-auto max-w-5xl px-4 pb-2">
{/* useSearchParams needs a boundary; the switch reads ?sammler=. */}
<Suspense fallback={<div className="h-11" />}>
<CollectorSwitch collectors={collectors} storedId={storedCollectorId} />
</Suspense>
</div>
) : null}
</header> </header>
); );
} }
+59 -26
View File
@@ -2,55 +2,85 @@
import Link from 'next/link'; import Link from 'next/link';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { Boxes, Gift, LayoutGrid, User } from 'lucide-react'; import { Boxes, Gift, House, LayoutGrid } from 'lucide-react';
import { cn } from 'cn'; import { cn } from 'cn';
import { AddMenu } from './add-menu';
/**
* Four places and the plus in the middle. Account, design and master data
* sit behind "Mehr" in the header - they are not part of the daily routine.
*/
const items = [ const items = [
{ href: '/', label: 'Start', icon: House },
{ href: '/katalog', label: 'Katalog', icon: LayoutGrid }, { href: '/katalog', label: 'Katalog', icon: LayoutGrid },
{ href: '/sammlung', label: 'Sammlung', icon: Boxes }, { href: '/sammlung', label: 'Sammlung', icon: Boxes },
{ href: '/wunschlisten', label: 'Wünsche', icon: Gift }, { href: '/wunschlisten', label: 'Wünsche', icon: Gift },
{ href: '/konto', label: 'Konto', icon: User },
] as const; ] as const;
export function BottomNav() { function isActive(pathname: string, href: string) {
if (href === '/') return pathname === '/';
return pathname === href || pathname.startsWith(`${href}/`);
}
function NavLink({ item, pathname }: { item: (typeof items)[number]; pathname: string }) {
const active = isActive(pathname, item.href);
return (
<Link
href={item.href}
aria-current={active ? 'page' : undefined}
className={cn(
'flex h-16 flex-col items-center justify-center gap-1 text-xs transition-colors',
active ? 'font-bold text-foreground' : 'font-medium text-muted-foreground hover:text-foreground',
)}
>
{/* The active tab sits on a small plate, like a tile on a baseplate. */}
<span
className={cn(
'flex h-7 w-12 items-center justify-center rounded-[calc(var(--radius)*0.6)] border-[length:var(--edge-w)]',
active ? 'border-edge bg-secondary text-secondary-foreground' : 'border-transparent',
)}
>
<item.icon className="size-5" aria-hidden />
</span>
{item.label}
</Link>
);
}
export function BottomNav({ legoImport }: { legoImport: boolean }) {
const pathname = usePathname(); const pathname = usePathname();
return ( return (
<nav <nav
aria-label="Hauptnavigation" aria-label="Hauptnavigation"
className="fixed inset-x-0 bottom-0 z-40 border-t border-border bg-background/95 pb-[env(safe-area-inset-bottom)] backdrop-blur md:hidden" className="fixed inset-x-0 bottom-0 z-40 border-t-[length:max(1px,var(--edge-w))] border-border bg-card/95 pb-[env(safe-area-inset-bottom)] backdrop-blur md:hidden"
> >
<ul className="mx-auto flex max-w-lg"> <ul className="mx-auto grid max-w-lg grid-cols-5 items-center">
{items.map((item) => { {items.slice(0, 2).map((item) => (
const active = pathname === item.href || pathname.startsWith(`${item.href}/`); <li key={item.href}>
return ( <NavLink item={item} pathname={pathname} />
<li key={item.href} className="flex-1"> </li>
<Link ))}
href={item.href} <li className="flex justify-center">
aria-current={active ? 'page' : undefined} <AddMenu legoImport={legoImport} />
className={cn( </li>
'flex h-16 flex-col items-center justify-center gap-1 text-xs transition-colors', {items.slice(2).map((item) => (
active ? 'text-primary' : 'text-muted-foreground hover:text-foreground', <li key={item.href}>
)} <NavLink item={item} pathname={pathname} />
> </li>
<item.icon className="size-5" aria-hidden /> ))}
{item.label}
</Link>
</li>
);
})}
</ul> </ul>
</nav> </nav>
); );
} }
export function DesktopNav() { export function DesktopNav({ legoImport }: { legoImport: boolean }) {
const pathname = usePathname(); const pathname = usePathname();
return ( return (
<nav aria-label="Hauptnavigation" className="hidden items-center gap-1 md:flex"> <nav aria-label="Hauptnavigation" className="hidden items-center gap-1 md:flex">
{items.map((item) => { {items.map((item) => {
const active = pathname === item.href || pathname.startsWith(`${item.href}/`); const active = isActive(pathname, item.href);
return ( return (
<Link <Link
key={item.href} key={item.href}
@@ -59,7 +89,7 @@ export function DesktopNav() {
className={cn( className={cn(
'flex h-10 items-center gap-2 rounded-lg px-3 text-sm font-medium transition-colors', 'flex h-10 items-center gap-2 rounded-lg px-3 text-sm font-medium transition-colors',
active active
? 'bg-muted text-foreground' ? 'border-[length:var(--edge-w)] border-edge bg-secondary text-secondary-foreground'
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground', : 'text-muted-foreground hover:bg-muted/60 hover:text-foreground',
)} )}
> >
@@ -68,6 +98,9 @@ export function DesktopNav() {
</Link> </Link>
); );
})} })}
<span className="ml-2">
<AddMenu legoImport={legoImport} variant="button" />
</span>
</nav> </nav>
); );
} }
@@ -0,0 +1,90 @@
'use client';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { useTransition } from 'react';
import { toast } from 'sonner';
import { cn } from 'cn';
import { chooseCollectorAction } from '@/app/(app)/collector-actions';
import { collectorDotClass } from '@/lib/collector-colors';
export type CollectorOption = { id: string; name: string; color: string };
/**
* "Für wen?" in the header: picks the child the whole app is looking at.
*
* Only rendered with more than one collector - with a single one the question
* does not exist. The choice is remembered per device; a `?sammler=` in the
* URL (a shared link) takes precedence and is shown as active.
*/
export function CollectorSwitch({
collectors,
storedId,
}: {
collectors: CollectorOption[];
storedId: string | null;
}) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [pending, startTransition] = useTransition();
const fromUrl = searchParams.get('sammler');
const current = fromUrl === 'alle' ? '' : (fromUrl ?? storedId ?? '');
function select(id: string) {
startTransition(async () => {
const result = await chooseCollectorAction({ id });
if (!result.ok) {
toast.error(result.error);
return;
}
// The header choice replaces a collector that came in via the URL.
if (fromUrl !== null) {
const params = new URLSearchParams(searchParams.toString());
params.delete('sammler');
params.delete('seite');
const query = params.toString();
router.replace(query ? `${pathname}?${query}` : pathname);
}
router.refresh();
});
}
const options = [{ id: '', name: 'Alle', color: '' }, ...collectors];
return (
<div role="radiogroup" aria-label="Für wen?" className="flex gap-2 overflow-x-auto pb-1">
{options.map((option) => {
const active = current === option.id;
return (
<button
key={option.id || 'alle'}
type="button"
role="radio"
aria-checked={active}
disabled={pending}
onClick={() => select(option.id)}
className={cn(
'flex h-10 shrink-0 items-center gap-2 rounded-[calc(var(--radius)*0.7)] border-[length:max(1px,var(--edge-w))] px-3 text-sm font-bold transition-colors',
active
? 'border-edge bg-card text-card-foreground shadow-(--lift-sm)'
: 'border-transparent text-muted-foreground hover:bg-muted',
)}
>
<span
aria-hidden
className={cn(
'flex size-6 items-center justify-center rounded-md border-[length:var(--edge-w)] border-edge text-xs font-bold text-white',
option.id ? collectorDotClass(option.color) : 'bg-secondary text-secondary-foreground',
)}
>
{option.id ? option.name.charAt(0).toUpperCase() : '★'}
</span>
{option.name}
</button>
);
})}
</div>
);
}
+115
View File
@@ -0,0 +1,115 @@
import {
Bell,
Building2,
Database,
Mail,
MapPin,
Palette,
Tag,
UserRound,
Users,
type LucideIcon,
} from 'lucide-react';
/**
* Everything that is not part of the daily routine lives behind "Mehr":
* the account, the design, family settings and master data. One list feeds
* the "Mehr" page and the title bar of each of those pages.
*/
export type MoreGroup = 'ich' | 'familie' | 'katalog' | 'daten';
export type MoreItem = {
href: string;
label: string;
description: string;
group: MoreGroup;
icon: LucideIcon;
adminOnly: boolean;
};
export const MORE_GROUPS: Array<{ id: MoreGroup; label: string }> = [
{ id: 'ich', label: 'Ich' },
{ id: 'familie', label: 'Familie' },
{ id: 'katalog', label: 'Katalog pflegen' },
{ id: 'daten', label: 'Daten' },
];
export const MORE_ITEMS: MoreItem[] = [
{
href: '/konto',
label: 'Konto & Passkeys',
description: 'Profil, Anmeldung per Passkey.',
group: 'ich',
icon: UserRound,
adminOnly: false,
},
{
href: '/konto#design',
label: 'Design',
description: 'Wie Bricktrack auf diesem Gerät aussieht.',
group: 'ich',
icon: Palette,
adminOnly: false,
},
{
href: '/verwaltung/sammler',
label: 'Sammler',
description: 'Wer sammelt – jedes Kind mit eigener Sammlung und eigenen Wunschlisten.',
group: 'familie',
icon: Users,
adminOnly: false,
},
{
href: '/verwaltung/einladungen',
label: 'Einladungen',
description: 'Weitere Konten für die Familie.',
group: 'familie',
icon: Mail,
adminOnly: true,
},
{
href: '/verwaltung/erinnerungen',
label: 'Erinnerungen',
description: 'E-Mail, wenn ein Wunschlisten-Termin näher rückt.',
group: 'familie',
icon: Bell,
adminOnly: true,
},
{
href: '/verwaltung/hersteller',
label: 'Hersteller',
description: 'Die Marken, deren Sets im Katalog stehen.',
group: 'katalog',
icon: Building2,
adminOnly: false,
},
{
href: '/verwaltung/themen',
label: 'Themen',
description: 'Serien je Hersteller, auch verschachtelt.',
group: 'katalog',
icon: Tag,
adminOnly: false,
},
{
href: '/verwaltung/standorte',
label: 'Standorte',
description: 'Wo die Sets aufbewahrt werden.',
group: 'katalog',
icon: MapPin,
adminOnly: false,
},
{
href: '/verwaltung/daten',
label: 'Export & Import',
description: 'Bestand sichern, umziehen oder als Tabelle auswerten.',
group: 'daten',
icon: Database,
adminOnly: true,
},
];
export function moreItemsFor(role: string): MoreItem[] {
return MORE_ITEMS.filter((item) => !item.adminOnly || role === 'admin');
}
+29
View File
@@ -0,0 +1,29 @@
'use client';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { Loader2, LogOut } from 'lucide-react';
import { clearOfflineCaches } from '@/components/layout/service-worker';
import { authClient } from '@/lib/auth-client';
import { Button } from '@/components/ui/button';
export function SignOutButton() {
const router = useRouter();
const [pending, setPending] = useState(false);
async function signOut() {
setPending(true);
await authClient.signOut();
// Cached pages must not stay readable on a shared device.
await clearOfflineCaches();
router.push('/anmelden');
router.refresh();
}
return (
<Button variant="outline" size="lg" onClick={() => void signOut()} disabled={pending}>
{pending ? <Loader2 className="animate-spin" /> : <LogOut />}
Abmelden
</Button>
);
}
-65
View File
@@ -1,65 +0,0 @@
'use client';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { LogOut, Settings, UserRound } from 'lucide-react';
import { clearOfflineCaches } from '@/components/layout/service-worker';
import { authClient } from '@/lib/auth-client';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import type { SessionUser } from '@/lib/session';
export function UserMenu({ user }: { user: SessionUser }) {
const router = useRouter();
async function signOut() {
await authClient.signOut();
// Cached pages must not stay readable on a shared device.
await clearOfflineCaches();
router.push('/anmelden');
router.refresh();
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" aria-label="Konto-Menü">
<UserRound />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel className="font-normal">
<span className="block text-sm font-medium">{user.name}</span>
<span className="block truncate text-xs text-muted-foreground">{user.email}</span>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href="/konto">
<UserRound />
Konto und Passkeys
</Link>
</DropdownMenuItem>
{user.role === 'admin' ? (
<DropdownMenuItem asChild>
<Link href="/verwaltung/hersteller">
<Settings />
Verwaltung
</Link>
</DropdownMenuItem>
) : null}
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => void signOut()}>
<LogOut />
Abmelden
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -8,6 +8,7 @@ import { deleteCollectionItem, updateCollectionItem } from '@/app/(app)/sammlung
import type { MissingPartRow } from '@/lib/queries/collection'; import type { MissingPartRow } from '@/lib/queries/collection';
import { MissingPartsDialog } from './missing-parts-dialog'; import { MissingPartsDialog } from './missing-parts-dialog';
import type { CollectionStatus, Completeness, Condition } from '@/db/types'; import type { CollectionStatus, Completeness, Condition } from '@/db/types';
import { collectorBadgeClass } from '@/lib/collector-colors';
import { formatDate, toDateInputValue } from '@/lib/format'; import { formatDate, toDateInputValue } from '@/lib/format';
import { import {
COMPLETENESS_LABELS, COMPLETENESS_LABELS,
@@ -58,14 +59,19 @@ export type CollectionItemRow = {
notes: string | null; notes: string | null;
setName: string; setName: string;
missingParts: MissingPartRow[]; missingParts: MissingPartRow[];
collectorId: string | null;
collectorName: string | null;
collectorColor: string | null;
}; };
export function CollectionItemsSection({ export function CollectionItemsSection({
items, items,
locations, locations,
collectors,
}: { }: {
items: CollectionItemRow[]; items: CollectionItemRow[];
locations: Array<{ id: string; name: string }>; locations: Array<{ id: string; name: string }>;
collectors: Array<{ id: string; name: string }>;
}) { }) {
const router = useRouter(); const router = useRouter();
const [pending, startTransition] = useTransition(); const [pending, startTransition] = useTransition();
@@ -126,6 +132,11 @@ export function CollectionItemsSection({
<li key={item.id} className="flex items-start gap-3 p-3"> <li key={item.id} className="flex items-start gap-3 p-3">
<div className="min-w-0 flex-1 space-y-1"> <div className="min-w-0 flex-1 space-y-1">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{collectors.length > 1 && item.collectorName ? (
<Badge className={collectorBadgeClass(item.collectorColor)}>
{item.collectorName}
</Badge>
) : null}
<Badge variant={item.status === 'sold' ? 'outline' : 'default'}> <Badge variant={item.status === 'sold' ? 'outline' : 'default'}>
{statusLabel(item.status)} {statusLabel(item.status)}
</Badge> </Badge>
@@ -194,6 +205,22 @@ export function CollectionItemsSection({
<form id="collection-item-form" onSubmit={submit} className="space-y-4"> <form id="collection-item-form" onSubmit={submit} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
{collectors.length > 1 ? (
<Field label="Sammler" htmlFor="collectorId" className="sm:col-span-2">
<NativeSelect
id="collectorId"
name="collectorId"
defaultValue={editing?.collectorId ?? collectors[0]?.id ?? 'none'}
>
{collectors.map((entry) => (
<option key={entry.id} value={entry.id}>
{entry.name}
</option>
))}
</NativeSelect>
</Field>
) : null}
<Field label="Status" htmlFor="status"> <Field label="Status" htmlFor="status">
<NativeSelect id="status" name="status" defaultValue={editing?.status ?? 'owned'}> <NativeSelect id="status" name="status" defaultValue={editing?.status ?? 'owned'}>
{Object.entries(STATUS_LABELS).map(([value, label]) => ( {Object.entries(STATUS_LABELS).map(([value, label]) => (
@@ -0,0 +1,87 @@
'use client';
import {
createCollector,
deleteCollector,
updateCollector,
} from '@/app/(app)/verwaltung/sammler/actions';
import { COLLECTOR_COLOR_LABELS } from '@/lib/collector-colors';
import { toDateInputValue } from '@/lib/format';
import { CrudSection, type CrudItem } from './crud-section';
import { Field } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { NativeSelect } from '@/components/ui/native-select';
import { Textarea } from '@/components/ui/textarea';
export type CollectorRowItem = CrudItem & {
color: string;
birthday: Date | null;
notes: string | null;
itemCount: number;
};
export function CollectorList({ collectors }: { collectors: CollectorRowItem[] }) {
return (
<CrudSection<CollectorRowItem>
items={collectors}
emptyText="Noch kein Sammler angelegt."
createLabel="Sammler anlegen"
dialogTitle={(item) => (item ? 'Sammler bearbeiten' : 'Neuer Sammler')}
dialogDescription="Der Katalog bleibt gemeinsam – Sammlung und Wunschlisten gehören je einem Sammler."
onCreate={createCollector}
onUpdate={updateCollector}
onDelete={deleteCollector}
deleteWarning={(item) =>
item.itemCount > 0
? `„${item.title}“ hat ${item.itemCount} Sets in der Sammlung. Ordne sie erst jemand anderem zu – erst dann lässt sich der Sammler löschen.`
: `„${item.title}“ wird gelöscht. Der Katalog bleibt unverändert.`
}
renderFields={(item) => (
<>
<Field label="Name" htmlFor="name" required>
<Input
id="name"
name="name"
defaultValue={item?.title ?? ''}
required
maxLength={60}
placeholder="Max"
/>
</Field>
<Field
label="Farbe"
htmlFor="color"
hint="Nur zur Unterscheidung auf einen Blick."
>
<NativeSelect id="color" name="color" defaultValue={item?.color ?? 'blue'}>
{Object.entries(COLLECTOR_COLOR_LABELS).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</NativeSelect>
</Field>
<Field label="Geburtstag" htmlFor="birthday" hint="Optional.">
<Input
id="birthday"
name="birthday"
type="date"
defaultValue={toDateInputValue(item?.birthday)}
/>
</Field>
<Field label="Notizen" htmlFor="collector-notes">
<Textarea
id="collector-notes"
name="notes"
defaultValue={item?.notes ?? ''}
rows={3}
/>
</Field>
</>
)}
/>
);
}
@@ -0,0 +1,166 @@
'use client';
import { useState, useTransition } from 'react';
import { BellRing, Loader2, Mail, PlugZap } from 'lucide-react';
import { toast } from 'sonner';
import {
runRemindersNow,
sendTestMail,
testMailConnection,
} from '@/app/(app)/verwaltung/erinnerungen/actions';
import { formatDate } from '@/lib/format';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
export type ReminderListRow = {
id: string;
title: string;
eventDate: Date | null;
openItemCount: number;
lastReminderAt: Date | null;
daysLeft: number | null;
};
export function ReminderSettings({
configured,
daysBefore,
lists,
}: {
configured: boolean;
daysBefore: number;
lists: ReminderListRow[];
}) {
const [pending, startTransition] = useTransition();
const [busy, setBusy] = useState<'verify' | 'test' | 'run' | null>(null);
function run(kind: 'verify' | 'test' | 'run') {
setBusy(kind);
startTransition(async () => {
if (kind === 'verify') {
const result = await testMailConnection();
if (result.ok) toast.success('Der SMTP-Server antwortet.');
else toast.error(result.error);
}
if (kind === 'test') {
const result = await sendTestMail();
if (result.ok) toast.success('Testnachricht verschickt. Schau in dein Postfach.');
else toast.error(result.error);
}
if (kind === 'run') {
const result = await runRemindersNow();
if (!result.ok) {
toast.error(result.error);
} else if (result.data.sent === 0 && result.data.failures.length === 0) {
toast.info('Nichts zu tun – gerade steht keine Erinnerung an.');
} else {
toast.success(
`${result.data.sent} Erinnerung(en) verschickt, ${result.data.failures.length} fehlgeschlagen.`,
);
}
}
setBusy(null);
});
}
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<BellRing className="size-5 text-primary" aria-hidden />
Erinnerungen
</CardTitle>
<CardDescription>
Rückt der Termin einer Wunschliste näher und stehen noch Wünsche offen, geht eine
Nachricht an dich raus – {daysBefore} Tage vorher, höchstens einmal pro Woche je Liste.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{configured ? (
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => run('verify')} disabled={pending}>
{busy === 'verify' ? <Loader2 className="animate-spin" /> : <PlugZap />}
Verbindung prüfen
</Button>
<Button variant="outline" onClick={() => run('test')} disabled={pending}>
{busy === 'test' ? <Loader2 className="animate-spin" /> : <Mail />}
Testnachricht an mich
</Button>
<Button onClick={() => run('run')} disabled={pending}>
{busy === 'run' ? <Loader2 className="animate-spin" /> : <BellRing />}
Jetzt prüfen
</Button>
</div>
) : (
<div className="space-y-2 rounded-lg border border-dashed border-border p-4 text-sm text-muted-foreground">
<p>
Es ist kein Mailversand eingerichtet – Erinnerungen sind damit aus, alles andere
funktioniert unverändert.
</p>
<p>Zum Aktivieren in die .env eintragen und den Container neu starten:</p>
<pre className="overflow-x-auto rounded-md bg-muted p-3 font-mono text-xs">
{`SMTP_HOST=mail.example.de
SMTP_PORT=587
SMTP_USER=bricktrack@example.de
SMTP_PASSWORD=…
SMTP_FROM=Bricktrack <bricktrack@example.de>`}
</pre>
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Wunschlisten mit Termin</CardTitle>
<CardDescription>
Nur Listen mit Termin können erinnert werden. Ist alles reserviert, bleibt es still.
</CardDescription>
</CardHeader>
<CardContent>
{lists.length === 0 ? (
<p className="text-sm text-muted-foreground">
Keine Wunschliste hat bisher einen Termin.
</p>
) : (
<ul className="divide-y divide-border rounded-lg border border-border">
{lists.map((list) => (
<li key={list.id} className="flex items-center gap-3 p-3">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{list.title}</p>
<p className="text-xs text-muted-foreground">
{list.eventDate ? formatDate(list.eventDate) : 'ohne Termin'}
{list.lastReminderAt
? ` · zuletzt erinnert am ${formatDate(list.lastReminderAt)}`
: ''}
</p>
</div>
{list.openItemCount > 0 ? (
<Badge variant="outline">{list.openItemCount} offen</Badge>
) : (
<Badge className="bg-emerald-600 text-white hover:bg-emerald-600">
Alles vergeben
</Badge>
)}
{list.daysLeft !== null && list.daysLeft >= 0 ? (
<Badge variant="secondary">
{list.daysLeft === 0 ? 'heute' : `in ${list.daysLeft} Tagen`}
</Badge>
) : (
<Badge variant="outline">vorbei</Badge>
)}
</li>
))}
</ul>
)}
</CardContent>
</Card>
</div>
);
}

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