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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 15:39:55 +01:00
unknown bac948fcb8 Merge: Designauswahl mit Klemmbaustein-Look 2026-10-25 15:26:27 +01:00
unknown 2d3ddc4dae Merge: update.sh ausfuehrbar einchecken 2026-10-25 15:26:27 +01:00
unknownandClaude Opus 5.5 604a8ac113 fix(sammlung): status-badge gruen statt in der aktionsfarbe
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 15:26:27 +01:00
unknownandClaude Opus 5.5 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
99 changed files with 6963 additions and 1337 deletions
+3 -3
View File
@@ -33,9 +33,9 @@ TZ=Europe/Berlin
# 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 # Optional: Mailversand fuer Erinnerungen an Wunschlisten-Termine. Einfacher
# SMTP_HOST und SMTP_FROM sind Erinnerungen komplett aus - alles andere # geht es in der App unter Mehr -> Erinnerungen -> Mailserver. Steht der
# funktioniert unveraendert. # Server hier, hat die .env Vorrang und die App zeigt ihn nur an.
# SMTP_HOST=mail.example.de # SMTP_HOST=mail.example.de
# SMTP_PORT=587 # SMTP_PORT=587
# SMTP_SECURE=false # SMTP_SECURE=false
+83 -1
View File
@@ -7,6 +7,87 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/).
## [Unreleased] ## [Unreleased]
### Hinzugefügt
- **Mailserver in der App einrichten**: Unter Mehr → Erinnerungen → Mailserver
lassen sich Server, Port, Verschlüsselung, Zugang und Absender eintragen –
ohne `.env` und ohne Neustart. Das Passwort wird verschlüsselt gespeichert
(AES-256-GCM, Schlüssel aus `BETTER_AUTH_SECRET`), nie wieder angezeigt und
nicht exportiert. Steht der Server in der `.env`, hat sie weiter Vorrang.
- **Set aus einem Link übernehmen**, für alle Hersteller: Amazon, idealo,
geizhals, Hersteller-Shops, Brickfact oder Merlins Bricks. Bricktrack liest
Produktdaten (schema.org, Open Graph, bei Amazon die Produktseite) und, wenn
die Seite blockt, den Link selbst. Lego wird über Rebrickable ergänzt,
BlueBrixx und Cobi über Merlins Bricks. Das Ergebnis füllt das Formular vor,
das Bild wird beim Speichern lokal abgelegt.
### Geändert
- Der Zeitplan für Erinnerungen läuft immer und prüft bei jedem Durchlauf, ob
ein Mailserver eingerichtet ist. Vorher wurde er nur gestartet, wenn SMTP
schon beim Containerstart in der `.env` stand.
- **Neue Navigation**: unten Start, Katalog, Sammlung, Wünsche und in der
Mitte ein +-Stein mit allen Wegen zu einem neuen Set (Scannen, aus Link,
Lego-Nummer, von Hand).
- **„Mehr" statt Konto-Menü und Verwaltungsreitern**: Konto, Design,
Sammler, Einladungen, Erinnerungen, Hersteller, Themen, Standorte und
Export/Import als Kacheln in Gruppen, hinter dem Button „Mehr" oben rechts.
- **„Für wen?" in der Kopfzeile**: Bei mehreren Sammlern wird das Kind einmal
oben gewählt und gilt für Startseite, Katalog und Sammlung. Jedes Gerät
merkt sich die Wahl; ein `?sammler=` im Link hat weiterhin Vorrang.
### Behoben
- Mitglieder (nicht Admin) kamen nicht an Sammler, Hersteller, Themen und
Standorte heran, obwohl sie diese pflegen dürfen: Der Eintrag
„Verwaltung" stand nur im Menü des Administrators.
### Sicherheit
- Abrufe im Auftrag eines Nutzers gehen nur an öffentliche Adressen: Jede
Adresse wird vor dem Verbindungsaufbau geprüft, Weiterleitungen ebenso,
Größe und Zeit sind begrenzt.
## [0.4.0] - 2026-10-25
Ein neues Gesicht: Bricktrack sieht jetzt aus wie das, was es verwaltet.
Dazu alle bekannten Sicherheitslücken in den Abhängigkeiten geschlossen.
### Sicherheit
- Next.js 15.5.28: schließt unter anderem eine Denial-of-Service-Lücke über
Server Actions und Cache-Poisoning bei selbst gehosteten Instanzen. Die
kritische Lücke in der Bildoptimierung war hier nicht ausnutzbar
(`images.unoptimized`), ist aber ebenfalls behoben.
- nodemailer 10.2: mehrere Fehler im Adressparser (Erinnerungs-Mails).
- better-auth 1.7.12, sharp 0.35.5 (librsvg), zod 4.6.7 (Speicherverbrauch
bei übergroßen Eingaben), drizzle-orm 0.45.4.
- Entwicklungswerkzeuge: vitest, vite, postcss, Playwright und die
shadcn-CLI aktualisiert, transitive Pakete per `pnpm.overrides` angehoben.
Übrig bleibt ein mittlerer Fund in einem alten esbuild, das drizzle-kit
intern mitbringt - nur beim Entwickeln, nicht im Image.
### Hinzugefügt
- **Designauswahl** unter Konto → Design: Klemmbaustein (neuer Standard),
Warm, Nachtblau (dunkel), Bonbon, Bauklotz und Schlicht (der bisherige
Look). Die Wahl gilt pro Gerät und wirkt sofort.
- **Platzhalter für Sets ohne Bild**: Farbe des Herstellers, gezeichneter
Stein und Setnummer statt eines grauen „Bild fehlt".
- **Startseite als Überblick**: Steinreihe für den Fortschritt (bei mehreren
Sammlern in deren Farben), Teile, Wert, nächster Wunschlisten-Termin mit
Countdown und Abkürzungen.
- Neues Logo als Baustein, Schriften lokal gebündelt (kein CDN).
### Geändert
- Karten, Buttons und Badges tragen im Standard-Design Konturen, eine feste
Unterkante und Noppen. In der Sammlung und auf der öffentlichen
Wunschliste zeigen die Noppen den Sammler bzw. ob ein Wunsch reserviert ist.
- Die öffentliche Wunschliste hat einen farbigen Kopf mit Anlass und Datum.
- Screenshots der Anleitung zeigen die feste Navigation nicht mehr mitten
im Bild.
## [0.3.0] - 2026-09-07 ## [0.3.0] - 2026-09-07
Phase 3: mehrere Kinder, Wertentwicklung, Erinnerungen und ein Schutz vor Phase 3: mehrere Kinder, Wertentwicklung, Erinnerungen und ein Schutz vor
@@ -194,7 +275,8 @@ Erste lauffaehige Fassung: Phase 1 vollstaendig, im Docker-Stack verifiziert.
- Unter Windows scheitert `pnpm build` an fehlenden Symlink-Rechten. Der - Unter Windows scheitert `pnpm build` an fehlenden Symlink-Rechten. Der
Docker-Build unter Linux ist davon nicht betroffen. Docker-Build unter Linux ist davon nicht betroffen.
[Unreleased]: https://git.wilde-server.de/florian.wilde/bricktrack/compare/v0.3.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.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
+21 -2
View File
@@ -61,8 +61,23 @@ Bild-URLs, die der Nutzer selbst hinterlegt hat.
| `src/lib/prices.ts` | Wertentwicklung aus notierten Preisen (rein) | | `src/lib/prices.ts` | Wertentwicklung aus notierten Preisen (rein) |
| `src/lib/reminders.ts` | Regeln, wann eine Erinnerung faellig ist (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/reminder-job.ts`, `src/lib/mailer.ts` | Versand und Zeitplan |
| `src/lib/mail-settings.ts`, `src/lib/secret-box.ts` | SMTP aus .env oder App (`app_setting`), Passwort verschluesselt |
| `src/lib/export.ts` | Export und Import des gesamten Bestands | | `src/lib/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
@@ -185,11 +200,13 @@ 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
keine Reservierungen oder Teilen-Token. Das Backup ist `./data`, nicht der keine Reservierungen, Teilen-Token oder `app_setting` (SMTP-Zugang). Das Backup ist `./data`, nicht der
Export. Export.
7. Der Service Worker beantwortet nur GET-Anfragen aus dem Cache und niemals 7. Der Service Worker beantwortet nur GET-Anfragen aus dem Cache und niemals
`/api/auth`, `/api/export` oder den Healthcheck. Beim Abmelden wird der `/api/auth`, `/api/export` oder den Healthcheck. Beim Abmelden wird der
@@ -203,6 +220,8 @@ Session.
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. *Fertig in 0.3.0.* E-Mail-Erinnerungen, Dublettenpruefung. *Fertig in 0.3.0.*
- **Design und Sicherheit** - Designauswahl (Standard Klemmbaustein),
Abhaengigkeiten ohne bekannte Luecken. *Fertig in 0.4.0.*
Details und Stand: `docs/ROADMAP.md`. Zeitaufwaende: `docs/DEVLOG.md`. Details und Stand: `docs/ROADMAP.md`. Zeitaufwaende: `docs/DEVLOG.md`.
+5 -5
View File
@@ -144,16 +144,16 @@ tut.
## 7. Passkey hinterlegen ## 7. Passkey hinterlegen
Unter **Konto → Passkeys → Passkey hinzufügen**. Ab dann meldest du dich per Unter **Mehr → Konto & Passkeys → Passkey hinzufügen** (der Button „Mehr" oben rechts). Ab dann meldest du dich per
Fingerabdruck, Gesichtserkennung oder Geräte-PIN an. Für jedes Gerät ein Fingerabdruck, Gesichtserkennung oder Geräte-PIN an. Für jedes Gerät ein
eigener Passkey – Handy, Tablet, Laptop. eigener Passkey – Handy, Tablet, Laptop.
## 8. Loslegen ## 8. Loslegen
1. **Verwaltung → Hersteller**: Lego, Playmobil, Cobi … anlegen. 1. **Mehr → Hersteller**: Lego, Playmobil, Cobi … anlegen.
2. **Verwaltung → Themen**: „City", „Technic", „Novelmore" … 2. **Mehr → Themen**: „City", „Technic", „Novelmore" …
3. **Verwaltung → Standorte**: „Regal Kinderzimmer", „Kiste Keller 3" … 3. **Mehr → Standorte**: „Regal Kinderzimmer", „Kiste Keller 3" …
4. **Katalog → Set anlegen**: Setnummer, Name, Bild. 4. **+ in der Navigation → Von Hand**: Setnummer, Name, Bild.
5. Auf der Set-Seite **„Zur Sammlung hinzufügen"**, wenn das Set da ist. 5. Auf der Set-Seite **„Zur Sammlung hinzufügen"**, wenn das Set da ist.
6. **Katalog → Filter „Fehlt noch"**: Kachel lange drücken, mehrere auswählen, 6. **Katalog → Filter „Fehlt noch"**: Kachel lange drücken, mehrere auswählen,
**„Zur Wunschliste hinzufügen"**. **„Zur Wunschliste hinzufügen"**.
+103 -32
View File
@@ -32,6 +32,7 @@ Kinderzimmer, nicht für den Schreibtisch.
18. [Erinnerung vor dem Termin](#18-erinnerungen) 18. [Erinnerung vor dem Termin](#18-erinnerungen)
19. [Schutz vor doppelten Einträgen](#19-dubletten) 19. [Schutz vor doppelten Einträgen](#19-dubletten)
20. [Antworten auf die zwei wichtigen Fragen](#20-die-zwei-fragen) 20. [Antworten auf die zwei wichtigen Fragen](#20-die-zwei-fragen)
21. [Ein anderes Design](#21-design)
--- ---
@@ -52,17 +53,34 @@ gleich danach unter „Konto" (siehe [Abschnitt 10](#10-passkeys-einrichten)).
![Startseite](bilder/02-start.png) ![Startseite](bilder/02-start.png)
Die Startseite ist die Abzweigung zu den vier Bereichen: Die Startseite zeigt auf einen Blick, wo die Sammlung steht:
| | | | | |
|---|---| |---|---|
| **Katalog** | Alle bekannten Sets – egal ob vorhanden oder nicht | | **Steinreihe** | Zehn Plätze für den ganzen Katalog. Jeder volle Stein ist ein Zehntel, das schon da ist – bei mehreren Kindern in deren Farbe |
| **Fehlende Sets** | Der direkte Weg zu allem, was noch fehlt | | **Teile und Wert** | Alle verbauten Teile und was die Sammlung heute wert ist |
| **Sammlung** | Was tatsächlich da ist | | **Nächster Termin** | Die nächste Wunschliste mit Datum, wie viele Tage noch bleiben und wie viel offen ist |
| **Wunschlisten** | Zum Teilen mit Familie und Freunden | | **Abkürzungen** | Direkt zu den fehlenden Sets, zu den Wunschlisten oder zu einem neuen Set |
Unten am Bildschirmrand liegt die Navigation – auf dem Handy mit dem Daumen Unten am Bildschirmrand liegt die Navigation – auf dem Handy mit dem Daumen
erreichbar. erreichbar: **Start, Katalog, Sammlung, Wünsche** und in der Mitte der rote
**+-Stein**. Der aktive Bereich sitzt auf einer kleinen Platte.
![Das Plus-Menü](bilder/26-plus-menue.png)
Das **+** bündelt alle Wege, wie ein Set in den Katalog kommt: Strichcode
scannen, aus einem Link übernehmen, Lego-Nummer holen oder von Hand eintragen.
![Die Mehr-Seite](bilder/27-mehr.png)
Alles, was man nicht täglich braucht, liegt hinter dem Button
**„Mehr"** oben rechts: Konto und Passkeys, Design,
Sammler, Einladungen, Erinnerungen, Hersteller, Themen, Standorte sowie
Export und Import. Was nur der Administrator darf, sehen Mitglieder dort gar
nicht erst.
Sets ohne Bild bekommen einen farbigen Platzhalter mit der Setnummer. Jeder
Hersteller hat seine eigene Farbe – Lego gelb, Playmobil blau, Cobi grün.
--- ---
@@ -70,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:
@@ -113,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.
@@ -282,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.
@@ -302,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
@@ -362,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.
@@ -380,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 |
|---|---| |---|---|
@@ -440,14 +486,16 @@ Sammler. Ein Set, das nur die Schwester hat, fällt für den Bruder wieder unter
> vorher. Erst der zweite Sammler schaltet all das frei – und dann willst du > vorher. Erst der zweite Sammler schaltet all das frei – und dann willst du
> es auch. > es auch.
Anlegen unter **Verwaltung → Sammler**. Jeder bekommt einen Namen und eine Anlegen unter **Mehr → Sammler**. Jeder bekommt einen Namen und eine
Farbe zur Unterscheidung. Farbe zur Unterscheidung.
![Nach Sammler filtern](bilder/22-sammler-filter.png) ![Nach Sammler filtern](bilder/22-sammler-filter.png)
Danach steht in Katalog und Sammlung oben ein Umschalter. „Alle zusammen" Danach steht oben unter dem Logo der Umschalter **„Für wen?"**. „Alle"
zeigt den Haushalt als Ganzes, ein Name zeigt genau dessen Sicht – inklusive zeigt den Haushalt als Ganzes, ein Name zeigt genau dessen Sicht – auf der
der Frage, was **diesem** Kind noch fehlt. 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 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 beim Anlegen einer Wunschliste, für wen sie ist: Das bestimmt, wessen Sammlung
@@ -455,7 +503,7 @@ 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 > Die Umstellung einer bestehenden Instanz macht nichts kaputt: Beim Update
> entsteht ein Sammler namens „Sammlung", dem alles Vorhandene gehört. > entsteht ein Sammler namens „Sammlung", dem alles Vorhandene gehört.
> Umbenennen unter **Verwaltung → Sammler**, fertig. > Umbenennen unter **Mehr → Sammler**, fertig.
--- ---
@@ -490,25 +538,27 @@ schickt Bricktrack dir eine E-Mail – standardmäßig 14 Tage vorher, höchsten
einmal pro Woche je Liste. Ist alles reserviert, bleibt es still: eine einmal pro Woche je Liste. Ist alles reserviert, bleibt es still: eine
Erinnerung ohne Handlungsbedarf ist nur Lärm. Erinnerung ohne Handlungsbedarf ist nur Lärm.
Dafür braucht es einen SMTP-Zugang in der `.env`: Dafür braucht es einen Mailserver (SMTP). Den trägst du direkt in der App ein:
**Mehr → Erinnerungen → Mailserver**. Die Angaben – Server, Port,
Verschlüsselung, Benutzername, Passwort und Absender – findest du bei deinem
Mail-Anbieter unter „SMTP" oder „Postausgangsserver". Ein Neustart ist nicht
nötig.
```bash Das Passwort wird verschlüsselt gespeichert und nie wieder angezeigt; zum
SMTP_HOST=mail.example.de Ändern einfach ein neues eintragen. Es landet auch nicht im Export.
SMTP_PORT=587
SMTP_USER=bricktrack@example.de
SMTP_PASSWORD=…
SMTP_FROM=Bricktrack <bricktrack@example.de>
# Optional, Standard ist 14: > Wer lieber mit der `.env` arbeitet, kann den Mailserver weiterhin dort
REMINDER_DAYS_BEFORE=14 > eintragen (`SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`,
``` > `SMTP_FROM`). Dann hat die `.env` Vorrang, und die App zeigt die Werte nur
> an. Wie viele Tage vorher erinnert wird, steht in `REMINDER_DAYS_BEFORE`
> (Standard 14).
Unter **Verwaltung → Erinnerungen** kannst du die Verbindung prüfen, dir eine Unter **Mehr → Erinnerungen** kannst du außerdem die Verbindung prüfen, dir eine
Testnachricht schicken und die Prüfung sofort auslösen, statt auf die nächste 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 Stunde zu warten. Dort steht auch, welche Listen einen Termin haben und wie
viel dort noch offen ist. viel dort noch offen ist.
Ohne SMTP-Zugang ist die Funktion vollständig aus – alles andere funktioniert Ohne Mailserver ist die Funktion vollständig aus – alles andere funktioniert
unverändert. unverändert.
--- ---
@@ -548,6 +598,27 @@ 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
+10
View File
@@ -59,6 +59,16 @@ Laeuft vollstaendig im Docker-Stack.
rueckt und Eintraege unreserviert sind rueckt und Eintraege unreserviert sind
- [x] 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 ## Danach
Phase 1 bis 3 sind umgesetzt. Was noch offen ist, steht hier - ohne Termin Phase 1 bis 3 sind umgesetzt. Was noch offen ist, steht hier - ohne Termin
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: 187 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 294 KiB

After

Width:  |  Height:  |  Size: 445 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 204 KiB

After

Width:  |  Height:  |  Size: 331 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 211 KiB

After

Width:  |  Height:  |  Size: 371 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 212 KiB

After

Width:  |  Height:  |  Size: 490 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 224 KiB

After

Width:  |  Height:  |  Size: 350 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 198 KiB

After

Width:  |  Height:  |  Size: 331 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 326 KiB

After

Width:  |  Height:  |  Size: 497 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 155 KiB

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 244 KiB

After

Width:  |  Height:  |  Size: 426 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 343 KiB

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 328 KiB

After

Width:  |  Height:  |  Size: 498 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 211 KiB

After

Width:  |  Height:  |  Size: 383 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 257 KiB

After

Width:  |  Height:  |  Size: 310 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 238 KiB

After

Width:  |  Height:  |  Size: 380 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 224 KiB

After

Width:  |  Height:  |  Size: 345 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 185 KiB

After

Width:  |  Height:  |  Size: 345 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 451 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 271 KiB

+5
View File
@@ -0,0 +1,5 @@
CREATE TABLE `app_setting` (
`key` text PRIMARY KEY NOT NULL,
`value` text NOT NULL,
`updated_at` integer NOT NULL
);
File diff suppressed because it is too large Load Diff
+7
View File
@@ -29,6 +29,13 @@
"when": 1788765478965, "when": 1788765478965,
"tag": "0003_powerful_sally_floyd", "tag": "0003_powerful_sally_floyd",
"breakpoints": true "breakpoints": true
},
{
"idx": 4,
"version": "6",
"when": 1792963951232,
"tag": "0004_modern_agent_brand",
"breakpoints": true
} }
] ]
} }
+33 -2
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();
@@ -117,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();
@@ -129,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.
@@ -169,10 +197,13 @@ test('Screenshots fuer die Anleitung', async ({ page, browser }) => {
// The switcher only exists because the seed created two collectors. // The switcher only exists because the seed created two collectors.
await visit(page, '/katalog?besitz=fehlt'); await visit(page, '/katalog?besitz=fehlt');
await expect(page.getByRole('button', { name: /Max/ })).toBeVisible(); await expect(page.getByRole('radio', { name: /Max/ })).toBeVisible();
await page.getByRole('button', { name: /Max/ }).click(); await page.getByRole('radio', { name: /Max/ }).click();
await expect(page.getByText('Sets, die Max noch nicht hat')).toBeVisible(); await expect(page.getByText('Sets, die Max noch nicht hat')).toBeVisible();
await shoot(page, '22-sammler-filter'); 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 ----------------------------------------------------------- // --- 17. prices -----------------------------------------------------------
await visit(page, '/katalog?q=60398'); await visit(page, '/katalog?q=60398');
+36 -15
View File
@@ -1,6 +1,6 @@
{ {
"name": "bricktrack", "name": "bricktrack",
"version": "0.1.0", "version": "0.4.0",
"private": true, "private": true,
"description": "Self-hosted Verwaltung fuer Spielzeug-Set-Sammlungen (Lego, Playmobil, Cobi, ...)", "description": "Self-hosted Verwaltung fuer Spielzeug-Set-Sammlungen (Lego, Playmobil, Cobi, ...)",
"packageManager": "pnpm@10.34.5", "packageManager": "pnpm@10.34.5",
@@ -23,33 +23,39 @@
"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.0.0", "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",
@@ -57,15 +63,16 @@
"@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": [
@@ -73,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"
}
} }
} }
+655 -638
View File
File diff suppressed because it is too large Load Diff
+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();
}
+16 -7
View File
@@ -1,7 +1,9 @@
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 { PriceSection } from '@/components/katalog/price-section';
@@ -21,7 +23,8 @@ import {
listPricePoints, listPricePoints,
} from '@/lib/queries/catalog'; } from '@/lib/queries/catalog';
import { listMissingParts } from '@/lib/queries/collection'; import { listMissingParts } from '@/lib/queries/collection';
import { listCollectors, resolveCollectorId } from '@/lib/queries/collectors'; 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';
@@ -45,7 +48,7 @@ export default async function SetDetailPage({ params, searchParams }: Props) {
// Keeps the "vorhanden / fehlt" badge in step with the collector the user // Keeps the "vorhanden / fehlt" badge in step with the collector the user
// was looking at in the catalog. // was looking at in the catalog.
const rawCollector = Array.isArray(query.sammler) ? query.sammler[0] : query.sammler; const rawCollector = Array.isArray(query.sammler) ? query.sammler[0] : query.sammler;
const collectorId = resolveCollectorId(rawCollector); const collectorId = await activeCollectorId(rawCollector);
const collectors = listCollectors(); const collectors = listCollectors();
const set = getSetById(id, collectorId); const set = getSetById(id, collectorId);
@@ -114,16 +117,22 @@ export default async function SetDetailPage({ params, searchParams }: 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">
<Studs count={4} />
<div className="brick overflow-hidden rounded-xl 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="" className="aspect-square w-full object-contain p-3" /> <img src={image} alt="" className="aspect-square w-full object-contain p-3" />
) : ( ) : (
<div className="flex aspect-square items-center justify-center text-muted-foreground"> <SetPlaceholder
<ImageOff className="size-10" aria-hidden /> setNumber={set.setNumber}
</div> manufacturerName={set.manufacturerName}
size="lg"
className="aspect-square"
/>
)} )}
</div> </div>
</div>
<div className="space-y-4"> <div className="space-y-4">
<SetActions <SetActions
+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(),
+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>
); );
} }
+3 -8
View File
@@ -1,7 +1,6 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import Link from 'next/link'; import Link from 'next/link';
import { Plus } from 'lucide-react'; import { Plus } from 'lucide-react';
import { CollectorFilter } from '@/components/layout/collector-filter';
import { BarcodeScannerButton } from '@/components/katalog/barcode-scanner'; import { BarcodeScannerButton } from '@/components/katalog/barcode-scanner';
import { CatalogFilters } from '@/components/katalog/catalog-filters'; import { CatalogFilters } from '@/components/katalog/catalog-filters';
import { CatalogGrid } from '@/components/katalog/catalog-grid'; import { CatalogGrid } from '@/components/katalog/catalog-grid';
@@ -10,7 +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 { listCollectors, resolveCollectorId } from '@/lib/queries/collectors'; 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';
@@ -30,7 +30,7 @@ export default async function KatalogPage({ searchParams }: { searchParams: Sear
const ownership = parseOwnershipFilter(single(params.besitz)); const ownership = parseOwnershipFilter(single(params.besitz));
const collectors = listCollectors(); const collectors = listCollectors();
const collectorId = resolveCollectorId(single(params.sammler)); const collectorId = await activeCollectorId(single(params.sammler));
const activeCollector = collectors.find((row) => row.id === collectorId) ?? null; 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;
@@ -80,11 +80,6 @@ export default async function KatalogPage({ searchParams }: { searchParams: Sear
</div> </div>
</div> </div>
<CollectorFilter
collectors={collectors.map((row) => ({ id: row.id, name: row.name, color: row.color }))}
allLabel="Alle zusammen"
/>
<CatalogFilters options={{ manufacturers, themes, years: listSetYears() }} /> <CatalogFilters options={{ manufacturers, themes, years: listSetYears() }} />
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
+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>
+17 -3
View File
@@ -1,5 +1,8 @@
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 { ensureReminderScheduler } from '@/lib/reminder-job';
import { requireUser } from '@/lib/session'; import { requireUser } from '@/lib/session';
@@ -9,13 +12,24 @@ export default async function AppLayout({ children }: { children: React.ReactNod
ensureReminderScheduler(); 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>
);
}
+184 -44
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> <section className="relative">
<Studs count={3} />
<div className="brick rounded-xl bg-card p-4 text-card-foreground">
<div className="flex items-baseline justify-between gap-3">
<h2 className="text-lg font-semibold">
{activeCollector ? `Sammlung von ${activeCollector.name}` : 'Unsere Sammlung'}
</h2>
<p className="text-sm font-semibold text-muted-foreground">
{formatNumber(stats.setCount)} von {pluralize(totalSets, 'Set', 'Sets')}
</p>
</div> </div>
<div className="grid gap-3 sm:grid-cols-2"> <ol
{shortcuts.map((shortcut) => ( aria-label={`${formatNumber(stats.setCount)} von ${formatNumber(totalSets)} Sets vorhanden`}
<Link key={shortcut.href} href={shortcut.href} className="group"> className="mt-5 grid grid-cols-10 items-end gap-1.5"
<Card className="h-full transition-colors group-hover:border-primary/40"> >
<CardHeader> {bricks.map((key, index) => (
<shortcut.icon className="size-6 text-primary" aria-hidden /> <li key={index} aria-hidden className="relative">
<CardTitle>{shortcut.title}</CardTitle> {key === null ? (
<CardDescription>{shortcut.description}</CardDescription> <span className="block h-7 rounded-[6px] border-2 border-dashed border-muted-foreground/50" />
</CardHeader> ) : (
</Card> <>
</Link> <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> </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>
</Button>
<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>
); );
} }
+27 -18
View File
@@ -1,12 +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 { CollectorFilter } from '@/components/layout/collector-filter'; 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 } from '@/lib/collector-colors'; 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';
@@ -17,7 +18,8 @@ import {
listCollectionItems, listCollectionItems,
type CollectionFilters as Filters, type CollectionFilters as Filters,
} from '@/lib/queries/collection'; } from '@/lib/queries/collection';
import { listCollectors, resolveCollectorId } from '@/lib/queries/collectors'; 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';
@@ -36,7 +38,7 @@ export default async function SammlungPage({ searchParams }: { searchParams: Sea
const params = await searchParams; const params = await searchParams;
const collectors = listCollectors(); const collectors = listCollectors();
const collectorId = resolveCollectorId(single(params.sammler)); const collectorId = await activeCollectorId(single(params.sammler));
const activeCollector = collectors.find((row) => row.id === collectorId) ?? null; const activeCollector = collectors.find((row) => row.id === collectorId) ?? null;
const filters: Filters = { const filters: Filters = {
@@ -76,11 +78,6 @@ export default async function SammlungPage({ searchParams }: { searchParams: Sea
</p> </p>
</div> </div>
<CollectorFilter
collectors={collectors.map((row) => ({ id: row.id, name: row.name, color: row.color }))}
allLabel="Alle zusammen"
/>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4"> <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{tiles.map((tile) => ( {tiles.map((tile) => (
<Card key={tile.label}> <Card key={tile.label}>
@@ -122,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>
@@ -150,7 +155,11 @@ 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' ? (
@@ -1,9 +1,23 @@
'use server'; 'use server';
import { revalidatePath } from 'next/cache'; import { revalidatePath } from 'next/cache';
import { actionData, actionError, type ActionResult } from '@/lib/action-result'; import {
actionData,
actionError,
actionOk,
formToObject,
parseForm,
type ActionResult,
} from '@/lib/action-result';
import { env } from '@/env'; import { env } from '@/env';
import {
deleteMailSettings,
getMailConfig,
isMailConfigured,
saveMailSettings,
} from '@/lib/mail-settings';
import { sendMail, verifyMailConnection } from '@/lib/mailer'; import { sendMail, verifyMailConnection } from '@/lib/mailer';
import { mailSettingsSchema } from '@/lib/validation';
import { runReminderJob, type ReminderRun } from '@/lib/reminder-job'; import { runReminderJob, type ReminderRun } from '@/lib/reminder-job';
import { requireAdmin } from '@/lib/session'; import { requireAdmin } from '@/lib/session';
@@ -17,7 +31,7 @@ export async function testMailConnection(): Promise<ActionResult> {
export async function sendTestMail(): Promise<ActionResult> { export async function sendTestMail(): Promise<ActionResult> {
const admin = await requireAdmin(); const admin = await requireAdmin();
if (!env.isMailConfigured) { if (!isMailConfigured()) {
return actionError('Es ist kein SMTP-Server konfiguriert.'); return actionError('Es ist kein SMTP-Server konfiguriert.');
} }
@@ -41,7 +55,7 @@ export async function sendTestMail(): Promise<ActionResult> {
export async function runRemindersNow(): Promise<ActionResult<ReminderRun>> { export async function runRemindersNow(): Promise<ActionResult<ReminderRun>> {
await requireAdmin(); await requireAdmin();
if (!env.isMailConfigured) { if (!isMailConfigured()) {
return actionError('Es ist kein SMTP-Server konfiguriert.'); return actionError('Es ist kein SMTP-Server konfiguriert.');
} }
@@ -49,3 +63,26 @@ export async function runRemindersNow(): Promise<ActionResult<ReminderRun>> {
revalidatePath('/verwaltung/erinnerungen'); revalidatePath('/verwaltung/erinnerungen');
return actionData(run); return actionData(run);
} }
/** Stores the SMTP server set up on the reminders page. */
export async function saveMailSettingsAction(formData: FormData): Promise<ActionResult> {
await requireAdmin();
if (getMailConfig()?.source === 'env') {
return actionError('Der Mailserver kommt aus der .env. Änderungen bitte dort vornehmen.');
}
const parsed = parseForm(mailSettingsSchema, formToObject(formData));
if (!parsed.ok) return parsed;
saveMailSettings(parsed.data);
revalidatePath('/verwaltung/erinnerungen');
return actionOk();
}
export async function deleteMailSettingsAction(): Promise<ActionResult> {
await requireAdmin();
deleteMailSettings();
revalidatePath('/verwaltung/erinnerungen');
return actionOk();
}
@@ -3,10 +3,12 @@ import { eq, sql } from 'drizzle-orm';
import { db } from '@/db'; import { db } from '@/db';
import { wishlist } from '@/db/schema'; import { wishlist } from '@/db/schema';
import { env } from '@/env'; import { env } from '@/env';
import { MailSettingsForm } from '@/components/verwaltung/mail-settings-form';
import { import {
ReminderSettings, ReminderSettings,
type ReminderListRow, type ReminderListRow,
} from '@/components/verwaltung/reminder-settings'; } from '@/components/verwaltung/reminder-settings';
import { getMailSettingsView } from '@/lib/mail-settings';
import { daysUntil } from '@/lib/reminders'; import { daysUntil } from '@/lib/reminders';
import { requireAdmin } from '@/lib/session'; import { requireAdmin } from '@/lib/session';
@@ -41,11 +43,16 @@ export default async function ErinnerungenPage() {
})) }))
.sort((a, b) => (a.daysLeft ?? 0) - (b.daysLeft ?? 0)); .sort((a, b) => (a.daysLeft ?? 0) - (b.daysLeft ?? 0));
const mail = getMailSettingsView();
return ( return (
<div className="space-y-6">
<ReminderSettings <ReminderSettings
configured={env.isMailConfigured} configured={mail.source !== null}
daysBefore={env.REMINDER_DAYS_BEFORE} daysBefore={env.REMINDER_DAYS_BEFORE}
lists={lists} lists={lists}
/> />
<MailSettingsForm view={mail} />
</div>
); );
} }
+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>
); );
+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>
+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);
}
}
+32 -1
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);
}
}␍ }␍
+27 -8
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() {
return resolveDesign((await cookies()).get(DESIGN_COOKIE)?.value);
}
export async function generateViewport(): Promise<Viewport> {
const design = await currentDesign();
return {
width: 'device-width', width: 'device-width',
initialScale: 1, initialScale: 1,
viewportFit: 'cover', viewportFit: 'cover',
themeColor: [ themeColor: DESIGN_INFO[design].themeColor,
{ media: '(prefers-color-scheme: light)', color: '#ffffff' }, };
{ media: '(prefers-color-scheme: dark)', color: '#0a0a0a' }, }
],
}; 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 />
+23 -15
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';
@@ -54,40 +56,45 @@ 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">
<BrickLogo className="h-5 w-6" />
Bricktrack Bricktrack
</div> </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> </Badge>
{list.eventDate ? ( {list.eventDate ? (
<Badge variant="outline" className="gap-1"> <Badge className="gap-1 bg-card text-card-foreground">
<CalendarDays className="size-3" aria-hidden /> <CalendarDays className="size-3" aria-hidden />
{formatDate(list.eventDate)} {formatDate(list.eventDate)}
</Badge> </Badge>
) : null} ) : null}
<span className="text-sm text-muted-foreground"> <Badge variant="secondary">
{pluralize(openCount, 'Wunsch noch offen', 'Wünsche noch offen')} {pluralize(openCount, 'Wunsch noch offen', 'Wünsche noch offen')}
</span> </Badge>
</div> </div>
</div>
</header>
{list.note ? ( <div className="mx-auto w-full max-w-2xl px-4 pb-8">
<p className="rounded-lg bg-muted p-3 text-sm whitespace-pre-line">{list.note}</p> <div className="relative -mt-5">
) : null} <Studs />
<div className="brick space-y-2 rounded-xl bg-card p-4 text-card-foreground">
{list.note ? <p className="text-sm whitespace-pre-line">{list.note}</p> : null}
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Klick auf „Das schenke ich“, damit niemand dasselbe doppelt kauft. Eine Anmeldung Klick auf „Das schenke ich“, damit niemand dasselbe doppelt kauft. Eine Anmeldung
brauchst du dafür nicht. brauchst du dafür nicht.
</p> </p>
</header> </div>
</div>
<main className="py-6"> <main className="py-6">
{items.length === 0 ? ( {items.length === 0 ? (
@@ -99,5 +106,6 @@ export default async function PublicWishlistPage({ params }: Props) {
)} )}
</main> </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>
);
}
+26 -14
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(() => {
@@ -135,17 +140,6 @@ export function BarcodeScannerButton() {
}, [open, onDetected, stop]); }, [open, onDetected, stop]);
return ( return (
<>
<Button
variant="outline"
size="icon-lg"
aria-label="Barcode scannen"
title="Barcode scannen"
onClick={() => setOpen(true)}
>
<ScanLine />
</Button>
<Dialog <Dialog
open={open} open={open}
onOpenChange={(next) => { onOpenChange={(next) => {
@@ -194,6 +188,24 @@ export function BarcodeScannerButton() {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
);
}
export function BarcodeScannerButton() {
const [open, setOpen] = useState(false);
return (
<>
<Button
variant="outline"
size="icon-lg"
aria-label="Barcode scannen"
title="Barcode scannen"
onClick={() => setOpen(true)}
>
<ScanLine />
</Button>
<BarcodeScannerDialog open={open} onOpenChange={setOpen} />
</> </>
); );
} }
+2 -1
View File
@@ -217,7 +217,8 @@ export function CatalogGrid({
'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}
/>
)}
</>
);
}
@@ -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"
/> />
+15 -10
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,8 +19,10 @@ 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} />
<div className="brick flex h-full flex-col overflow-hidden rounded-xl bg-card transition-colors">
<div className="relative aspect-square border-b-[length:var(--edge-w)] border-edge bg-muted">
{image ? ( {image ? (
// Plain img: images are either local files or a URL the user entered, // Plain img: images are either local files or a URL the user entered,
// and the optimizer must never fetch arbitrary remote hosts. // and the optimizer must never fetch arbitrary remote hosts.
@@ -31,20 +35,18 @@ export function SetCardContent({ set }: { set: CatalogRow }) {
className="size-full object-contain p-2" className="size-full object-contain p-2"
/> />
) : ( ) : (
<div className="flex size-full items-center justify-center text-muted-foreground"> <SetPlaceholder setNumber={set.setNumber} manufacturerName={set.manufacturerName} />
<ImageOff className="size-8" aria-hidden />
</div>
)} )}
<div className="absolute top-2 left-2 flex flex-col items-start gap-1"> <div className="absolute top-2 left-2 flex flex-col items-start gap-1">
{owned ? ( {owned ? (
<Badge className="gap-1 bg-emerald-600 text-white hover:bg-emerald-600"> <Badge className="gap-1 bg-owned text-owned-foreground">
<Check className="size-3" aria-hidden /> <Check className="size-3" aria-hidden />
{set.ownedCopies > 1 ? `${set.ownedCopies}× vorhanden` : 'Vorhanden'} {set.ownedCopies > 1 ? `${set.ownedCopies}× vorhanden` : 'Vorhanden'}
</Badge> </Badge>
) : null} ) : null}
{set.wishlistCount > 0 ? ( {set.wishlistCount > 0 ? (
<Badge variant="secondary" className="gap-1"> <Badge className="gap-1">
<Gift className="size-3" aria-hidden /> <Gift className="size-3" aria-hidden />
Auf Wunschliste Auf Wunschliste
</Badge> </Badge>
@@ -61,7 +63,9 @@ export function SetCardContent({ set }: { set: CatalogRow }) {
</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.themeName, set.year].filter(Boolean).join(' · ')}
</span>
{set.retailPriceCents !== null ? ( {set.retailPriceCents !== null ? (
<span className="shrink-0 font-medium text-foreground"> <span className="shrink-0 font-medium text-foreground">
{formatEuro(set.retailPriceCents)} {formatEuro(set.retailPriceCents)}
@@ -70,6 +74,7 @@ export function SetCardContent({ set }: { set: CatalogRow }) {
</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,
)} )}
> >
+30 -10
View File
@@ -47,19 +47,26 @@ 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 [duplicates, setDuplicates] = useState<DuplicateCandidate[] | null>(null);
const pendingForm = useRef<FormData | null>(null); const pendingForm = useRef<FormData | null>(null);
@@ -167,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"
@@ -179,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"
@@ -192,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}
/> />
@@ -204,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>
@@ -215,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>
@@ -235,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"
/> />
@@ -256,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>
@@ -286,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">
+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} />
</>
);
}
+40 -7
View File
@@ -1,21 +1,54 @@
import { Suspense } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { Blocks } from 'lucide-react'; import { Menu } 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 }) { 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. */}
{/* Labelled on purpose: an initials-only box was not recognisable. */}
<Link
href="/mehr"
title={`Mehr: Konto (${user.name}), Design und Verwaltung`}
className="flex h-10 items-center gap-2 rounded-[calc(var(--radius)*0.7)] border-[length:max(1px,var(--edge-w))] border-edge bg-card px-3 text-sm font-bold shadow-(--lift-sm) outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
>
<Menu className="size-5" aria-hidden />
Mehr
</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>
); );
} }
+52 -19
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) {
const pathname = usePathname(); 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 ( return (
<nav
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"
>
<ul className="mx-auto flex max-w-lg">
{items.map((item) => {
const active = pathname === item.href || pathname.startsWith(`${item.href}/`);
return (
<li key={item.href} className="flex-1">
<Link <Link
href={item.href} href={item.href}
aria-current={active ? 'page' : undefined} aria-current={active ? 'page' : undefined}
className={cn( className={cn(
'flex h-16 flex-col items-center justify-center gap-1 text-xs transition-colors', 'flex h-16 flex-col items-center justify-center gap-1 text-xs transition-colors',
active ? 'text-primary' : 'text-muted-foreground hover:text-foreground', 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 /> <item.icon className="size-5" aria-hidden />
</span>
{item.label} {item.label}
</Link> </Link>
</li>
); );
})} }
export function BottomNav({ legoImport }: { legoImport: boolean }) {
const pathname = usePathname();
return (
<nav
aria-label="Hauptnavigation"
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 grid max-w-lg grid-cols-5 items-center">
{items.slice(0, 2).map((item) => (
<li key={item.href}>
<NavLink item={item} pathname={pathname} />
</li>
))}
<li className="flex justify-center">
<AddMenu legoImport={legoImport} />
</li>
{items.slice(2).map((item) => (
<li key={item.href}>
<NavLink item={item} pathname={pathname} />
</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>
); );
} }
@@ -1,78 +0,0 @@
'use client';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { useTransition } from 'react';
import { cn } from 'cn';
import { collectorDotClass } from '@/lib/collector-colors';
export type CollectorOption = { id: string; name: string; color: string };
/**
* Switches the view between collectors.
*
* Rendered only when there is more than one - with a single collector the
* whole question does not exist, and the app should not ask it. The choice
* lives in the URL (`?sammler=`), so a filtered view stays shareable and the
* back button behaves.
*/
export function CollectorFilter({
collectors,
allLabel = 'Alle',
}: {
collectors: CollectorOption[];
allLabel?: string;
}) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [, startTransition] = useTransition();
if (collectors.length < 2) return null;
const current = searchParams.get('sammler') ?? '';
function select(id: string) {
const params = new URLSearchParams(searchParams.toString());
if (id) params.set('sammler', id);
else params.delete('sammler');
params.delete('seite');
const query = params.toString();
startTransition(() => router.push(query ? `${pathname}?${query}` : pathname));
}
const options = [{ id: '', name: allLabel, color: 'slate' }, ...collectors];
return (
<nav
aria-label="Sammler"
className="flex gap-1 overflow-x-auto rounded-lg bg-muted p-1"
>
{options.map((option) => {
const active = current === option.id;
return (
<button
key={option.id || 'alle'}
type="button"
aria-pressed={active}
onClick={() => select(option.id)}
className={cn(
'flex flex-1 items-center justify-center gap-2 rounded-md px-3 py-2 text-sm font-medium whitespace-nowrap transition-colors',
active
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground',
)}
>
{option.id ? (
<span
className={cn('size-2 rounded-full', collectorDotClass(option.color))}
aria-hidden
/>
) : null}
{option.name}
</button>
);
})}
</nav>
);
}
@@ -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>
);
}
@@ -0,0 +1,230 @@
'use client';
import { useRouter } from 'next/navigation';
import { useState, useTransition } from 'react';
import { Loader2, Save, Server, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import {
deleteMailSettingsAction,
saveMailSettingsAction,
} from '@/app/(app)/verwaltung/erinnerungen/actions';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Field } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { NativeSelect } from '@/components/ui/native-select';
export type MailSettingsFormView = {
source: 'env' | 'app' | null;
host: string;
port: number;
security: 'starttls' | 'tls' | 'none';
user: string;
from: string;
hasPassword: boolean;
passwordUnreadable: boolean;
};
const SECURITY_LABELS = {
starttls: 'STARTTLS (meist Port 587)',
tls: 'SSL/TLS (meist Port 465)',
none: 'Unverschlüsselt (nur im eigenen Netz)',
} as const;
/** The SMTP server for reminders, set up right here instead of in the .env. */
export function MailSettingsForm({ view }: { view: MailSettingsFormView }) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [security, setSecurity] = useState(view.security);
const [port, setPort] = useState(String(view.port));
if (view.source === 'env') {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Server className="size-5 text-primary" aria-hidden />
Mailserver
</CardTitle>
<CardDescription>
Kommt aus der .env und hat dort Vorrang. Ändern lässt er sich nur dort, danach den
Container neu starten.
</CardDescription>
</CardHeader>
<CardContent className="space-y-1 text-sm">
<p>
<span className="text-muted-foreground">Server:</span> {view.host}:{view.port} ·{' '}
{SECURITY_LABELS[view.security].split(' (')[0]}
</p>
<p>
<span className="text-muted-foreground">Absender:</span> {view.from}
</p>
</CardContent>
</Card>
);
}
function changeSecurity(next: 'starttls' | 'tls' | 'none') {
setSecurity(next);
// Follow the usual port while the user has not picked a special one.
if (next === 'tls' && port === '587') setPort('465');
if (next !== 'tls' && port === '465') setPort('587');
}
function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const form = event.currentTarget;
const formData = new FormData(form);
startTransition(async () => {
const result = await saveMailSettingsAction(formData);
if (!result.ok) {
toast.error(result.error);
return;
}
// The password never stays visible, not even as dots.
const password = form.elements.namedItem('password');
if (password instanceof HTMLInputElement) password.value = '';
toast.success('Mailserver gespeichert. Prüf ihn gleich mit „Verbindung prüfen“.');
router.refresh();
});
}
function remove() {
startTransition(async () => {
const result = await deleteMailSettingsAction();
if (!result.ok) {
toast.error(result.error);
return;
}
toast.success('Mailserver entfernt. Erinnerungen sind aus.');
router.refresh();
});
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Server className="size-5 text-primary" aria-hidden />
Mailserver
</CardTitle>
<CardDescription>
Über diesen Server gehen die Erinnerungen raus. Die Angaben findest du bei deinem
Mail-Anbieter unter „SMTP“ oder „Postausgangsserver“. Das Passwort wird verschlüsselt
gespeichert und nie wieder angezeigt.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={submit} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-[1fr_8rem]">
<Field label="SMTP-Server" htmlFor="mail-host" required>
<Input
id="mail-host"
name="host"
defaultValue={view.host}
placeholder="mail.example.de"
autoComplete="off"
required
/>
</Field>
<Field label="Port" htmlFor="mail-port" required>
<Input
id="mail-port"
name="port"
inputMode="numeric"
value={port}
onChange={(event) => setPort(event.target.value)}
required
/>
</Field>
</div>
<Field label="Verschlüsselung" htmlFor="mail-security">
<NativeSelect
id="mail-security"
name="security"
value={security}
onChange={(event) => changeSecurity(event.target.value as typeof security)}
>
{Object.entries(SECURITY_LABELS).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</NativeSelect>
</Field>
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Benutzername" htmlFor="mail-user">
<Input
id="mail-user"
name="user"
defaultValue={view.user}
placeholder="bricktrack@example.de"
autoComplete="off"
/>
</Field>
<Field
label="Passwort"
htmlFor="mail-password"
hint={
view.passwordUnreadable
? 'Das gespeicherte Passwort ist nicht mehr lesbar (BETTER_AUTH_SECRET wurde geändert). Bitte neu eingeben.'
: view.hasPassword
? 'Gespeichert. Leer lassen, um es zu behalten.'
: undefined
}
>
<Input
id="mail-password"
name="password"
type="password"
autoComplete="new-password"
placeholder={view.hasPassword ? '••••••••' : ''}
/>
</Field>
</div>
{view.hasPassword ? (
<label className="flex items-center gap-2 text-sm">
<Checkbox name="clearPassword" value="on" />
Gespeichertes Passwort entfernen
</label>
) : null}
<Field
label="Absender"
htmlFor="mail-from"
required
hint="So steht es im Postfach, z. B. Bricktrack <bricktrack@example.de>."
>
<Input
id="mail-from"
name="from"
defaultValue={view.from}
placeholder="Bricktrack <bricktrack@example.de>"
required
/>
</Field>
<div className="flex flex-wrap gap-2">
<Button type="submit" size="lg" disabled={pending}>
{pending ? <Loader2 className="animate-spin" /> : <Save />}
Speichern
</Button>
{view.source === 'app' ? (
<Button type="button" variant="ghost" size="lg" onClick={remove} disabled={pending}>
<Trash2 />
Entfernen
</Button>
) : null}
</div>
</form>
</CardContent>
</Card>
);
}
@@ -98,17 +98,10 @@ export function ReminderSettings({
) : ( ) : (
<div className="space-y-2 rounded-lg border border-dashed border-border p-4 text-sm text-muted-foreground"> <div className="space-y-2 rounded-lg border border-dashed border-border p-4 text-sm text-muted-foreground">
<p> <p>
Es ist kein Mailversand eingerichtet – Erinnerungen sind damit aus, alles andere Es ist noch kein Mailserver eingerichtet – Erinnerungen sind damit aus, alles andere
funktioniert unverändert. funktioniert unverändert.
</p> </p>
<p>Zum Aktivieren in die .env eintragen und den Container neu starten:</p> <p>Trag ihn unten unter „Mailserver“ ein. Ein Neustart ist nicht nötig.</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> </div>
)} )}
</CardContent> </CardContent>
@@ -0,0 +1,28 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { ChevronLeft } from 'lucide-react';
import { MORE_ITEMS } from '@/components/layout/more-items';
/** Title of a master-data page, with the way back to "Mehr". */
export function VerwaltungHeader() {
const pathname = usePathname();
const item = MORE_ITEMS.find((entry) => entry.href === pathname);
return (
<div className="space-y-2">
<Link
href="/mehr"
className="inline-flex items-center gap-1 text-sm font-semibold text-muted-foreground hover:text-foreground"
>
<ChevronLeft className="size-4" aria-hidden />
Mehr
</Link>
<div>
<h1 className="text-2xl font-semibold tracking-tight">{item?.label ?? 'Verwaltung'}</h1>
{item ? <p className="text-sm text-muted-foreground">{item.description}</p> : null}
</div>
</div>
);
}
@@ -1,43 +0,0 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { cn } from 'cn';
const tabs = [
{ href: '/verwaltung/sammler', label: 'Sammler', adminOnly: false },
{ href: '/verwaltung/hersteller', label: 'Hersteller', adminOnly: false },
{ href: '/verwaltung/themen', label: 'Themen', adminOnly: false },
{ href: '/verwaltung/standorte', label: 'Standorte', adminOnly: false },
{ href: '/verwaltung/einladungen', label: 'Einladungen', adminOnly: true },
{ href: '/verwaltung/erinnerungen', label: 'Erinnerungen', adminOnly: true },
{ href: '/verwaltung/daten', label: 'Daten', adminOnly: true },
] as const;
export function VerwaltungNav({ isAdmin }: { isAdmin: boolean }) {
const pathname = usePathname();
const visible = tabs.filter((tab) => !tab.adminOnly || isAdmin);
return (
<nav className="flex gap-1 overflow-x-auto rounded-lg bg-muted p-1" aria-label="Stammdaten">
{visible.map((tab) => {
const active = pathname === tab.href;
return (
<Link
key={tab.href}
href={tab.href}
aria-current={active ? 'page' : undefined}
className={cn(
'flex-1 whitespace-nowrap rounded-md px-3 py-2 text-center text-sm font-medium transition-colors',
active
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground',
)}
>
{tab.label}
</Link>
);
})}
</nav>
);
}
+15 -11
View File
@@ -1,10 +1,12 @@
'use client'; 'use client';
import { useEffect, useState, useTransition } from 'react'; import { useEffect, useState, useTransition } from 'react';
import { Check, ExternalLink, Gift, ImageOff, Loader2, Undo2 } from 'lucide-react'; import { Check, ExternalLink, Gift, Loader2, Undo2 } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { cancelReservation, reserveItem } from '@/app/w/actions'; import { cancelReservation, reserveItem } from '@/app/w/actions';
import { setImageSrc } from '@/lib/images'; import { setImageSrc } from '@/lib/images';
import { SetPlaceholder } from '@/components/brick/set-placeholder';
import { Studs } from '@/components/brick/studs';
import { formatEuro } from '@/lib/money'; import { formatEuro } from '@/lib/money';
import { priorityLabel } from '@/lib/wishlist-logic'; import { priorityLabel } from '@/lib/wishlist-logic';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@@ -125,24 +127,25 @@ export function PublicWishlist({
} }
return ( return (
<div className="space-y-3"> <div className="space-y-5 pt-2">
{items.map((item) => { {items.map((item) => {
const image = setImageSrc(item); const image = setImageSrc(item);
const mine = Boolean(tokens[item.id]); const mine = Boolean(tokens[item.id]);
return ( return (
<article <article key={item.id} className="relative">
key={item.id} {/* Studs turn green once someone has taken the wish. */}
className="flex gap-3 rounded-xl border border-border bg-card p-3" <Studs count={3} studClassName={mine || item.isReserved ? 'bg-owned' : undefined} />
> <div className="brick flex gap-3 rounded-xl bg-card p-3">
<div className="size-20 shrink-0 overflow-hidden rounded-lg border border-border bg-muted sm:size-28"> <div className="size-20 shrink-0 overflow-hidden rounded-lg border-[length:max(1px,var(--edge-w))] border-edge bg-muted sm:size-28">
{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-6" aria-hidden /> setNumber={item.setNumber}
</div> manufacturerName={item.manufacturerName}
/>
)} )}
</div> </div>
@@ -172,7 +175,7 @@ export function PublicWishlist({
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{mine ? ( {mine ? (
<> <>
<Badge className="gap-1 bg-emerald-600 text-white hover:bg-emerald-600"> <Badge className="gap-1 bg-owned text-owned-foreground">
<Check className="size-3" aria-hidden /> <Check className="size-3" aria-hidden />
Das schenkst du Das schenkst du
</Badge> </Badge>
@@ -211,6 +214,7 @@ export function PublicWishlist({
) : null} ) : null}
</div> </div>
</div> </div>
</div>
</article> </article>
); );
})} })}
@@ -3,7 +3,8 @@
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useState, useTransition } from 'react'; import { useState, useTransition } from 'react';
import { ArrowDown, ArrowUp, Check, ImageOff, Loader2, Trash2 } from 'lucide-react'; import { ArrowDown, ArrowUp, Check, Loader2, Trash2 } from 'lucide-react';
import { SetPlaceholder } from '@/components/brick/set-placeholder';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { import {
removeWishlistItem, removeWishlistItem,
@@ -122,15 +123,17 @@ export function WishlistItems({
<Link <Link
href={`/katalog/${item.setId}`} href={`/katalog/${item.setId}`}
className="size-16 shrink-0 overflow-hidden rounded-lg border border-border bg-muted" 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"
/>
)} )}
</Link> </Link>
+1
View File
@@ -4,4 +4,5 @@ export * from './collector';
export * from './prices'; export * from './prices';
export * from './collection'; export * from './collection';
export * from './wishlist'; export * from './wishlist';
export * from './settings';
export * from './relations'; export * from './relations';
+14
View File
@@ -0,0 +1,14 @@
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
/**
* Settings made in the app rather than in the .env - for now the SMTP server
* for reminders. One row per topic, the value as JSON.
*
* Never part of the export: it may hold credentials (the SMTP password is
* stored encrypted, see src/lib/secret-box.ts).
*/
export const appSetting = sqliteTable('app_setting', {
key: text('key').primaryKey(),
value: text('value', { mode: 'json' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
});
+48
View File
@@ -0,0 +1,48 @@
/**
* The progress row on the dashboard: a fixed number of slots, filled with one
* brick per share of the catalog that is already in the collection. Each
* brick takes the colour of a collector, in proportion to what they own.
*/
export type BrickShare = { key: string; count: number };
/**
* Returns `slots` entries: a collector key for a filled slot, `null` for an
* empty one. Filled slots come first, grouped by collector in input order.
*/
export function brickRow(
ownedSets: number,
totalSets: number,
shares: BrickShare[],
slots = 10,
): Array<string | null> {
const empty = Array<string | null>(slots).fill(null);
if (totalSets <= 0 || ownedSets <= 0) return empty;
// Never round a non-empty collection down to an empty row, and only show a
// full row when everything is really there.
const ratio = Math.min(ownedSets, totalSets) / totalSets;
let filled = Math.round(ratio * slots);
filled = Math.max(1, filled);
if (ratio < 1) filled = Math.min(filled, slots - 1);
const positive = shares.filter((share) => share.count > 0);
const shareTotal = positive.reduce((sum, share) => sum + share.count, 0);
if (shareTotal === 0) return [...Array<string | null>(filled).fill(''), ...empty].slice(0, slots);
// Largest remainder: floor first, then hand out the rest by biggest fraction.
const exact = positive.map((share) => (share.count / shareTotal) * filled);
const counts = exact.map(Math.floor);
let rest = filled - counts.reduce((sum, value) => sum + value, 0);
const order = exact
.map((value, index) => ({ index, fraction: value - Math.floor(value) }))
.sort((a, b) => b.fraction - a.fraction || a.index - b.index);
for (const { index } of order) {
if (rest === 0) break;
counts[index]! += 1;
rest -= 1;
}
const row = positive.flatMap((share, index) => Array<string>(counts[index]!).fill(share.key));
return [...row, ...empty].slice(0, slots);
}
+24
View File
@@ -0,0 +1,24 @@
import 'server-only';
import { cookies } from 'next/headers';
import { resolveCollectorId } from '@/lib/queries/collectors';
/**
* Which collector the app is looking at.
*
* The switch in the header remembers the choice per device (cookie), so Max's
* tablet opens on Max. A `?sammler=` in the URL still wins - shared links and
* the back button keep working - and `?sammler=alle` shows everybody.
*/
export const COLLECTOR_COOKIE = 'bricktrack-sammler';
export const ALL_COLLECTORS = 'alle';
export async function storedCollectorId(): Promise<string | undefined> {
return resolveCollectorId((await cookies()).get(COLLECTOR_COOKIE)?.value);
}
export async function activeCollectorId(param: string | undefined): Promise<string | undefined> {
if (param === ALL_COLLECTORS) return undefined;
if (param) return resolveCollectorId(param);
return storedCollectorId();
}
+110
View File
@@ -0,0 +1,110 @@
/**
* The selectable looks of the app.
*
* A design is nothing but a set of CSS tokens under `[data-design=...]` in
* `src/app/designs.css`; this module only knows the names. The choice lives in
* a cookie, so it is per device and also applies to pages without a session
* (login, public wishlist).
*/
export const DESIGNS = ['klemmbaustein', 'warm', 'nacht', 'bonbon', 'bauklotz', 'schlicht'] as const;
export type Design = (typeof DESIGNS)[number];
export const DEFAULT_DESIGN: Design = 'klemmbaustein';
export const DESIGN_COOKIE = 'bricktrack-design';
export type DesignInfo = {
label: string;
description: string;
/** Dark designs also set the `.dark` class so `dark:` variants apply. */
dark: boolean;
/** Browser chrome colour (address bar, PWA title bar). */
themeColor: string;
/** Four colours for the preview swatch on the account page. */
swatch: [string, string, string, string];
};
export const DESIGN_INFO: Record<Design, DesignInfo> = {
klemmbaustein: {
label: 'Klemmbaustein',
description: 'Noppen, Konturen und kräftige Steinfarben.',
dark: false,
themeColor: '#fff6e5',
swatch: ['#fff6e5', '#d63a30', '#ffc93c', '#2f6bea'],
},
warm: {
label: 'Warm',
description: 'Ruhiger Sandton mit Ziegelrot.',
dark: false,
themeColor: '#f6f3ec',
swatch: ['#f6f3ec', '#c8381f', '#fbe3a6', '#1d1b17'],
},
nacht: {
label: 'Nachtblau',
description: 'Dunkel mit gelbem Akzent, schont abends die Augen.',
dark: true,
themeColor: '#0e1422',
swatch: ['#0e1422', '#ffc93c', '#182136', '#60a5fa'],
},
bonbon: {
label: 'Bonbon',
description: 'Weiches Lila mit Pastellfarben.',
dark: false,
themeColor: '#f4f0ff',
swatch: ['#f4f0ff', '#6d4aff', '#d3f5e7', '#ffe1d1'],
},
bauklotz: {
label: 'Bauklotz',
description: 'Primärfarben und dicke schwarze Konturen.',
dark: false,
themeColor: '#ffd500',
swatch: ['#fffbef', '#1e5eff', '#ffd500', '#e63b2e'],
},
schlicht: {
label: 'Schlicht',
description: 'Neutrales Grau, wie bis Version 0.3.',
dark: false,
themeColor: '#ffffff',
swatch: ['#ffffff', '#171717', '#f5f5f5', '#a3a3a3'],
},
};
export function isDesign(value: unknown): value is Design {
return typeof value === 'string' && (DESIGNS as readonly string[]).includes(value);
}
/** Cookie value to design; anything unknown falls back to the default. */
export function resolveDesign(value: string | null | undefined): Design {
return isDesign(value) ? value : DEFAULT_DESIGN;
}
/** Number of placeholder tones every design defines (`--tone-1` ...). */
export const TONE_COUNT = 5;
/** The manufacturers named in the brief get a fixed, recognisable tone. */
const KNOWN_TONES: Record<string, number> = {
lego: 1,
playmobil: 2,
cobi: 3,
bluebrixx: 4,
megabloks: 5,
};
/**
* Stable tone for a set without an image, derived from its manufacturer, so
* all Lego placeholders share a colour and Playmobil gets another one.
* Returns 1 ... TONE_COUNT.
*/
export function toneIndex(manufacturerName: string): number {
const key = manufacturerName.toLowerCase().replace(/[^a-z0-9]/g, '');
const known = KNOWN_TONES[key];
if (known) return known;
let hash = 0;
for (const char of key) {
hash = (hash * 31 + char.codePointAt(0)!) >>> 0;
}
return (hash % TONE_COUNT) + 1;
}
+380
View File
@@ -0,0 +1,380 @@
import { isValidEan, normalizeEan } from './ean';
/**
* Reads set data out of a pasted link and, where the page can be fetched, out
* of its structured data (schema.org JSON-LD, Open Graph).
*
* Pure: no network, no database. The server action in katalog/link-actions.ts
* does the fetching and decides what to trust.
*/
export type LinkHints = {
manufacturer?: string;
setNumber?: string;
name?: string;
ean?: string;
imageUrl?: string;
pieceCount?: number;
year?: number;
};
/** Brands we recognise in links and page data, with how we spell them. */
const BRANDS: Array<{ name: string; aliases: string[] }> = [
{ name: 'Lego', aliases: ['lego'] },
{ name: 'Playmobil', aliases: ['playmobil'] },
{ name: 'Cobi', aliases: ['cobi'] },
{ name: 'BlueBrixx', aliases: ['bluebrixx', 'bluebrixxpro', 'bb'] },
{ name: 'Mega Bloks', aliases: ['megabloks', 'megaconstrux', 'mega'] },
{ name: 'Mould King', aliases: ['mouldking'] },
{ name: 'Cada', aliases: ['cada'] },
{ name: 'Sluban', aliases: ['sluban'] },
{ name: 'Qman', aliases: ['qman', 'keeppley'] },
{ name: 'Xingbao', aliases: ['xingbao'] },
];
function key(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9]/g, '');
}
/** "LEGO®", "lego", "BlueBrixx Pro" -> "Lego", "BlueBrixx". */
export function canonicalBrand(value: string | undefined | null): string | undefined {
if (!value) return undefined;
const wanted = key(value);
if (!wanted) return undefined;
for (const brand of BRANDS) {
if (brand.aliases.some((alias) => wanted === alias || (alias.length > 3 && wanted.startsWith(alias)))) {
return brand.name;
}
}
return undefined;
}
/** Where a link points, in words for the UI. */
export function sourceLabel(url: URL): string {
const host = url.hostname.replace(/^www\./, '');
if (/(^|\.)amazon\./.test(host) || /^amzn\./.test(host)) return 'Amazon';
if (/(^|\.)idealo\./.test(host)) return 'idealo';
if (/(^|\.)geizhals\./.test(host)) return 'geizhals';
if (/(^|\.)brickmerge\./.test(host)) return 'brickmerge';
if (/(^|\.)brickfact\./.test(host)) return 'Brickfact';
if (/(^|\.)merlinsbricks\./.test(host)) return 'Merlins Bricks';
if (/(^|\.)ebay\./.test(host)) return 'eBay';
return host;
}
// Words that sit in shop slugs but never in a set name.
const STOPWORDS = new Set([
'set', 'sets', 'bausatz', 'bausteine', 'klemmbausteine', 'baustein', 'spielzeug', 'spielset',
'kinder', 'jungen', 'maedchen', 'mädchen', 'ab', 'jahre', 'jahren', 'jahr', 'teile', 'teilig',
'geschenk', 'geschenkidee', 'the', 'and', 'for', 'kids', 'boys',
'girls', 'toy', 'toys', 'building', 'kit', 'bricks', 'brick', 'preisvergleich', 'dp', 'html',
'neu', 'ovp', 'new', 'sealed',
]);
const KEEP_LOWER = new Set(['und', 'mit', 'der', 'die', 'das', 'von', 'im', 'am', 'zum', 'zur']);
function looksLikeYear(value: string): boolean {
const year = Number(value);
return year >= 1950 && year <= 2040;
}
/**
* A set number in a list of slug tokens: 4 to 7 digits, longer ones first,
* and four-digit years only as a last resort. Cobi's "2-30500" style is kept.
*/
function findSetNumber(tokens: string[]): string | undefined {
const joined = tokens.join('-');
const cobi = /(?:^|-)(\d-\d{5})(?:-|$)/.exec(joined);
if (cobi) return cobi[1];
const numbers = tokens.filter((token) => /^\d{4,7}$/.test(token));
const best =
numbers.find((token) => token.length >= 5) ?? numbers.find((token) => !looksLikeYear(token));
return best;
}
function titleCase(word: string): string {
if (KEEP_LOWER.has(word)) return word;
return word.charAt(0).toUpperCase() + word.slice(1);
}
/** The descriptive part of a shop URL, split into words. */
function slugTokens(url: URL): string[] {
const segments = decodeURIComponent(url.pathname)
.split('/')
.filter(Boolean)
.map((segment) => segment.replace(/\.html?$/i, ''));
let slug = '';
const host = url.hostname;
if (/amazon\./.test(host)) {
// amazon.de/LEGO-60398-Familienhaus-Elektroauto/dp/B0...: the part before /dp/.
const dp = segments.findIndex((segment) => segment === 'dp' || segment === 'gp');
slug = dp > 0 ? segments[dp - 1]! : '';
} else if (/idealo\./.test(host)) {
// .../OffersOfProduct/202595377_-familienhaus-mit-elektroauto-60398-lego
slug = (segments.at(-1) ?? '').replace(/^\d+_-?/, '');
} else if (/geizhals\./.test(host)) {
// geizhals.de/lego-city-familienhaus-...-60398-a2875000
slug = (segments.at(-1) ?? '').replace(/-a\d+$/, '');
} else {
// Generic: the longest segment with dashes is usually the product slug.
slug = [...segments].sort((a, b) => b.split(/[-_]/).length - a.split(/[-_]/).length)[0] ?? '';
}
return slug
.toLowerCase()
.split(/[-_+\s]+/)
.map((token) => token.replace(/[^\p{L}\p{N}]/gu, ''))
.filter(Boolean);
}
/** Everything the address alone tells us - useful when the page blocks us. */
export function hintsFromUrl(url: URL): LinkHints {
const host = url.hostname.replace(/^www\./, '');
// merlinsbricks.com/sets/bb-108782, /sets/cobi-2400: brand and number in one.
const merlin = /merlinsbricks\./.test(host)
? /\/sets\/([a-z]+)-([\w-]+?)\/?$/i.exec(url.pathname)
: null;
if (merlin) {
return { manufacturer: canonicalBrand(merlin[1]), setNumber: merlin[2] };
}
const tokens = slugTokens(url);
const setNumber = findSetNumber(tokens);
let manufacturer: string | undefined;
for (const token of tokens) {
manufacturer = canonicalBrand(token);
if (manufacturer) break;
}
manufacturer ??= canonicalBrand(host.split('.').at(-2));
const words = tokens.filter(
(token) =>
token !== setNumber &&
!/^\d+$/.test(token) &&
!STOPWORDS.has(token) &&
!canonicalBrand(token) &&
token.length > 1,
);
const name = words.length > 0 ? words.slice(0, 8).map(titleCase).join(' ') : undefined;
return { manufacturer, setNumber, name };
}
// --- page data ------------------------------------------------------------
const ENTITIES: Record<string, string> = {
amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', '#39': "'",
auml: 'ä', ouml: 'ö', uuml: 'ü', Auml: 'Ä', Ouml: 'Ö', Uuml: 'Ü', szlig: 'ß',
};
function decodeEntities(value: string): string {
return value
.replace(/&#(\d+);/g, (_, code: string) => String.fromCodePoint(Number(code)))
.replace(/&#x([\da-f]+);/gi, (_, code: string) => String.fromCodePoint(parseInt(code, 16)))
.replace(/&([a-z]+|#39);/gi, (match, name: string) => ENTITIES[name] ?? match);
}
function metaContent(html: string, property: string): string | undefined {
const escaped = property.replace(/[.:]/g, '\\$&');
const pattern = new RegExp(
`<meta[^>]+(?:property|name)=["']${escaped}["'][^>]*content=["']([^"']*)["']|<meta[^>]+content=["']([^"']*)["'][^>]*(?:property|name)=["']${escaped}["']`,
'i',
);
const match = pattern.exec(html);
const value = match?.[1] ?? match?.[2];
return value ? decodeEntities(value).trim() : undefined;
}
type JsonObject = Record<string, unknown>;
function isObject(value: unknown): value is JsonObject {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
/** All schema.org Product objects on the page, however they are nested. */
function jsonLdProducts(html: string): JsonObject[] {
const products: JsonObject[] = [];
const pattern = /<script[^>]+application\/ld\+json[^>]*>([\s\S]*?)<\/script>/gi;
const visit = (node: unknown) => {
if (Array.isArray(node)) return node.forEach(visit);
if (!isObject(node)) return;
const type = node['@type'];
const types = Array.isArray(type) ? type : [type];
if (types.includes('Product') || types.includes('ProductGroup')) products.push(node);
if (node['@graph']) visit(node['@graph']);
};
for (const match of html.matchAll(pattern)) {
try {
visit(JSON.parse(match[1]!.trim()));
} catch {
// A broken block on someone else's page is not our problem.
}
}
return products;
}
function text(value: unknown): string | undefined {
if (typeof value === 'string') return decodeEntities(value).trim() || undefined;
if (typeof value === 'number') return String(value);
if (isObject(value)) return text(value.name) ?? text(value.url);
if (Array.isArray(value)) return text(value[0]);
return undefined;
}
function cleanEan(value: unknown): string | undefined {
const raw = text(value);
if (!raw) return undefined;
const ean = normalizeEan(raw);
return isValidEan(ean) ? ean : undefined;
}
function absoluteUrl(value: string | undefined, base: URL): string | undefined {
if (!value) return undefined;
try {
const url = new URL(value, base);
return url.protocol === 'https:' || url.protocol === 'http:' ? url.toString() : undefined;
} catch {
return undefined;
}
}
/** "462 Teile", "450 pieces" in a description. */
function piecesFrom(value: string | undefined): number | undefined {
const match = value && /(\d{1,3}(?:[.,]\d{3})*|\d+)\s*(?:teilen?|pieces|pcs|steinen?|bausteinen?|parts)\b/i.exec(value);
if (!match) return undefined;
const pieces = Number(match[1]!.replace(/[.,]/g, ''));
return pieces > 0 && pieces < 100_000 ? pieces : undefined;
}
/** "from 2025", "aus 2023", "Jahr: 2021" in a description. */
function yearFrom(value: string | undefined): number | undefined {
const match = value && /\b(?:from|aus|jahr:?|released|erschienen)\s+((?:19|20)\d{2})\b/i.exec(value);
return match ? Number(match[1]) : undefined;
}
function stripTags(value: string): string {
return decodeEntities(value.replace(/<[^>]+>/g, ' '))
.replace(/[‎‏]/g, '')
.replace(/\s+/g, ' ')
.trim();
}
/**
* Amazon has no schema.org data, but a stable page skeleton: the title in
* #productTitle, the brand in the byline, pictures as "hiRes" in a script and
* the facts in the "Produktinformation" table or bullet list.
*/
function hintsFromAmazon(html: string): LinkHints {
const facts = new Map<string, string>();
for (const match of html.matchAll(/<th[^>]*>([\s\S]*?)<\/th>\s*<td[^>]*>([\s\S]*?)<\/td>/gi)) {
facts.set(stripTags(match[1]!).toLowerCase(), stripTags(match[2]!));
}
for (const match of html.matchAll(
/<span class="a-text-bold">([\s\S]*?)<\/span>\s*<span[^>]*>([\s\S]*?)<\/span>/gi,
)) {
facts.set(stripTags(match[1]!).replace(/[:\s]+$/, '').toLowerCase(), stripTags(match[2]!));
}
const fact = (...labels: string[]) => {
for (const [label, value] of facts) {
if (labels.some((wanted) => label.includes(wanted))) return value;
}
return undefined;
};
const title = /id="productTitle"[^>]*>([\s\S]*?)<\/span>/i.exec(html);
const byline = /id="bylineInfo"[^>]*>([\s\S]*?)<\/a>/i.exec(html);
const brandText = byline
? stripTags(byline[1]!).replace(/^(marke|brand):\s*/i, '').replace(/^besuche den\s+|-store$|\s+store$/gi, '')
: fact('marke', 'hersteller');
const image = /"hiRes":"(https:[^"]+)"/.exec(html) ?? /data-old-hires="(https:[^"]+)"/.exec(html);
const pieces = Number((fact('anzahl der teile', 'teileanzahl', 'anzahl teile') ?? '').replace(/\D/g, ''));
return {
manufacturer: canonicalBrand(brandText),
setNumber: fact('modellnummer', 'herstellerreferenz')?.replace(/\s/g, ''),
name: title ? stripTags(title[1]!) : undefined,
ean: cleanEan(fact('ean', 'gtin')),
imageUrl: image?.[1],
pieceCount: pieces > 0 && pieces < 100_000 ? pieces : undefined,
};
}
export function hintsFromHtml(html: string, pageUrl: URL): LinkHints {
const hints = /(^|\.)amazon\./.test(pageUrl.hostname) ? hintsFromAmazon(html) : hintsFromStructuredData(html, pageUrl);
// A shop's own article number (an ASIN, a long SKU) is not a set number.
if (hints.setNumber && !/^[\d-]{3,12}$/.test(hints.setNumber)) delete hints.setNumber;
// Titles usually carry the number: "LEGO City 60398 Familienhaus".
if (!hints.setNumber && hints.name) {
const fromName = findSetNumber(hints.name.toLowerCase().split(/[\s()[\]:,/|]+/).filter(Boolean));
if (fromName) hints.setNumber = fromName;
}
return hints;
}
function hintsFromStructuredData(html: string, pageUrl: URL): LinkHints {
const product = jsonLdProducts(html)[0];
const description = text(product?.description) ?? metaContent(html, 'og:description');
const hints: LinkHints = {
manufacturer: canonicalBrand(text(product?.brand)) ?? canonicalBrand(metaContent(html, 'product:brand')),
setNumber: text(product?.mpn) ?? text(product?.sku),
name: text(product?.name) ?? metaContent(html, 'og:title'),
ean: cleanEan(product?.gtin13) ?? cleanEan(product?.gtin) ?? cleanEan(product?.gtin12) ?? cleanEan(product?.gtin8),
imageUrl: absoluteUrl(text(product?.image) ?? metaContent(html, 'og:image'), pageUrl),
pieceCount: piecesFrom(description),
year: yearFrom(description),
};
return hints;
}
/** First value wins per field: pass the most trusted source first. */
export function mergeHints(...sources: Array<LinkHints | null | undefined>): LinkHints {
const merged: LinkHints = {};
for (const source of sources) {
if (!source) continue;
for (const [field, value] of Object.entries(source) as Array<[keyof LinkHints, never]>) {
if (merged[field] === undefined && value !== undefined && value !== '') merged[field] = value;
}
}
return merged;
}
/**
* Shop titles carry everything: "LEGO® City Familienhaus mit Elektroauto
* 60398", "BlueBrixx - Technisches Hilfswerk™ Vehicle | Set 108782". Keeps
* the part that is the set's own name.
*/
export function cleanSetName(name: string, manufacturer?: string, setNumber?: string): string {
let result = name.replace(/[®™©]/g, '');
// Site names and set references after a separator.
result = result.split(/\s+[|•·]\s+/)[0]!;
// Price-comparison titles: "... ab € 4,20 (2026)", "... ab 52,99 €".
result = result.replace(/\s+ab\s+(?:€\s*)?\d[\d.,]*\s*(?:€|EUR)?.*$/i, '');
if (setNumber) {
const escaped = setNumber.replace(/[-]/g, '\\-');
result = result.replace(new RegExp(`\\(?\\b${escaped}\\b\\)?`, 'g'), '');
}
if (manufacturer) {
for (const brand of BRANDS.filter((entry) => entry.name === manufacturer)) {
for (const alias of [brand.name, ...brand.aliases]) {
if (alias.length < 3) continue;
result = result.replace(new RegExp(`^\\s*${alias}\\b\\s*[-–:]?\\s*`, 'i'), '');
}
}
}
return result.replace(/\s{2,}/g, ' ').replace(/^[\s\-–:]+|[\s\-–:,]+$/g, '').trim();
}
+129
View File
@@ -0,0 +1,129 @@
import 'server-only';
import { eq } from 'drizzle-orm';
import { db } from '@/db';
import { appSetting } from '@/db/schema';
import { env } from '@/env';
import { openSecret, sealSecret } from '@/lib/secret-box';
import type { MailSettingsInput } from '@/lib/validation';
/**
* Where the SMTP server comes from.
*
* The .env wins when it names a server: an admin who configured it there
* expects exactly that. Otherwise the settings made on the reminders page
* apply. Without either, reminders stay off.
*/
export type MailSecurity = 'starttls' | 'tls' | 'none';
export type MailConfig = {
source: 'env' | 'app';
host: string;
port: number;
security: MailSecurity;
user: string | null;
password: string | null;
from: string;
};
type StoredMail = {
host: string;
port: number;
security: MailSecurity;
user: string | null;
/** Encrypted, see secret-box.ts. */
password: string | null;
from: string;
};
const KEY = 'smtp';
function readStored(): StoredMail | null {
const row = db.select().from(appSetting).where(eq(appSetting.key, KEY)).get();
return (row?.value as StoredMail | undefined) ?? null;
}
export function getMailConfig(): MailConfig | null {
if (env.isMailConfigured) {
return {
source: 'env',
host: env.SMTP_HOST!,
port: env.SMTP_PORT,
security: env.SMTP_SECURE || env.SMTP_PORT === 465 ? 'tls' : 'starttls',
user: env.SMTP_USER ?? null,
password: env.SMTP_PASSWORD ?? null,
from: env.SMTP_FROM!,
};
}
const stored = readStored();
if (!stored) return null;
return {
source: 'app',
host: stored.host,
port: stored.port,
security: stored.security,
user: stored.user,
password: stored.password ? openSecret(stored.password, env.BETTER_AUTH_SECRET) : null,
from: stored.from,
};
}
export function isMailConfigured(): boolean {
return getMailConfig() !== null;
}
/** What the settings form may show - never the password itself. */
export type MailSettingsView = {
source: 'env' | 'app' | null;
host: string;
port: number;
security: MailSecurity;
user: string;
from: string;
hasPassword: boolean;
/** A password is stored but cannot be decrypted (BETTER_AUTH_SECRET changed). */
passwordUnreadable: boolean;
};
export function getMailSettingsView(): MailSettingsView {
const config = getMailConfig();
const stored = config?.source === 'env' ? null : readStored();
return {
source: config?.source ?? null,
host: config?.host ?? '',
port: config?.port ?? 587,
security: config?.security ?? 'starttls',
user: config?.user ?? '',
from: config?.from ?? '',
hasPassword: Boolean(config?.password),
passwordUnreadable: Boolean(stored?.password && !config?.password),
};
}
export function saveMailSettings(input: MailSettingsInput): void {
const previous = readStored();
let password = previous?.password ?? null;
if (input.clearPassword) password = null;
if (input.password) password = sealSecret(input.password, env.BETTER_AUTH_SECRET);
const value: StoredMail = {
host: input.host,
port: input.port,
security: input.security,
user: input.user,
password,
from: input.from,
};
db.insert(appSetting)
.values({ key: KEY, value, updatedAt: new Date() })
.onConflictDoUpdate({ target: appSetting.key, set: { value, updatedAt: new Date() } })
.run();
}
export function deleteMailSettings(): void {
db.delete(appSetting).where(eq(appSetting.key, KEY)).run();
}
+27 -20
View File
@@ -1,32 +1,39 @@
import 'server-only'; import 'server-only';
import nodemailer, { type Transporter } from 'nodemailer'; import nodemailer, { type Transporter } from 'nodemailer';
import { env } from '@/env'; import { getMailConfig, type MailConfig } from '@/lib/mail-settings';
/** /**
* Outgoing mail. * Outgoing mail.
* *
* Entirely optional: without SMTP_HOST and SMTP_FROM nothing is scheduled and * Entirely optional: without a server - in the .env or on the reminders page -
* nothing is sent. The app never depends on mail working. * nothing is sent. The app never depends on mail working.
*/ */
let transporter: Transporter | null = null; let cached: { key: string; transporter: Transporter; from: string } | null = null;
function getTransporter(): Transporter | null { function connectionKey(config: MailConfig): string {
if (!env.isMailConfigured) return null; return JSON.stringify([config.host, config.port, config.security, config.user, config.password]);
if (transporter) return transporter; }
transporter = nodemailer.createTransport({ function getTransport(): { transporter: Transporter; from: string } | null {
host: env.SMTP_HOST, const config = getMailConfig();
port: env.SMTP_PORT, if (!config) return null;
// Settings can change at runtime now; a new connection follows them.
const key = connectionKey(config);
if (cached?.key === key) return { transporter: cached.transporter, from: config.from };
const transporter = nodemailer.createTransport({
host: config.host,
port: config.port,
// Implicit TLS on 465, STARTTLS on 587 - the usual pair. // Implicit TLS on 465, STARTTLS on 587 - the usual pair.
secure: env.SMTP_SECURE || env.SMTP_PORT === 465, secure: config.security === 'tls',
auth: ignoreTLS: config.security === 'none',
env.SMTP_USER && env.SMTP_PASSWORD auth: config.user && config.password ? { user: config.user, pass: config.password } : undefined,
? { user: env.SMTP_USER, pass: env.SMTP_PASSWORD }
: undefined,
}); });
return transporter; cached = { key, transporter, from: config.from };
return { transporter, from: config.from };
} }
export type MailResult = { ok: true } | { ok: false; error: string }; export type MailResult = { ok: true } | { ok: false; error: string };
@@ -36,14 +43,14 @@ export async function sendMail(input: {
subject: string; subject: string;
text: string; text: string;
}): Promise<MailResult> { }): Promise<MailResult> {
const transport = getTransporter(); const transport = getTransport();
if (!transport) { if (!transport) {
return { ok: false, error: 'Es ist kein SMTP-Server konfiguriert.' }; return { ok: false, error: 'Es ist kein SMTP-Server konfiguriert.' };
} }
try { try {
await transport.sendMail({ await transport.transporter.sendMail({
from: env.SMTP_FROM, from: transport.from,
to: input.to, to: input.to,
subject: input.subject, subject: input.subject,
text: input.text, text: input.text,
@@ -60,13 +67,13 @@ export async function sendMail(input: {
/** Checks the connection without sending anything. */ /** Checks the connection without sending anything. */
export async function verifyMailConnection(): Promise<MailResult> { export async function verifyMailConnection(): Promise<MailResult> {
const transport = getTransporter(); const transport = getTransport();
if (!transport) { if (!transport) {
return { ok: false, error: 'Es ist kein SMTP-Server konfiguriert.' }; return { ok: false, error: 'Es ist kein SMTP-Server konfiguriert.' };
} }
try { try {
await transport.verify(); await transport.transporter.verify();
return { ok: true }; return { ok: true };
} catch (error) { } catch (error) {
return { return {
+66
View File
@@ -0,0 +1,66 @@
import { BlockList, isIP } from 'node:net';
/**
* Which addresses the server may fetch on a user's behalf.
*
* The link import downloads pages and pictures from URLs people paste. Without
* a guard, a link to http://192.168.1.1/ would make the server talk to the
* router - or to any other device in the home network. Only public unicast
* addresses pass. Pure, so it is unit tested.
*/
const blocked = new BlockList();
for (const [network, prefix] of [
['0.0.0.0', 8], // "this" network
['10.0.0.0', 8], // private
['100.64.0.0', 10], // carrier-grade NAT
['127.0.0.0', 8], // loopback
['169.254.0.0', 16], // link-local, cloud metadata
['172.16.0.0', 12], // private
['192.0.0.0', 24], // IETF protocol assignments
['192.0.2.0', 24], // documentation
['192.168.0.0', 16], // private
['198.18.0.0', 15], // benchmarking
['198.51.100.0', 24], // documentation
['203.0.113.0', 24], // documentation
['224.0.0.0', 4], // multicast
['240.0.0.0', 4], // reserved, broadcast
] as const) {
blocked.addSubnet(network, prefix, 'ipv4');
}
for (const [network, prefix] of [
['::', 128], // unspecified
['::1', 128], // loopback
['64:ff9b::', 96], // NAT64 - would reach IPv4 space behind it
['100::', 64], // discard
['2001:db8::', 32], // documentation
['fc00::', 7], // unique local
['fe80::', 10], // link-local
['ff00::', 8], // multicast
] as const) {
blocked.addSubnet(network, prefix, 'ipv6');
}
export function isPublicAddress(address: string): boolean {
const version = isIP(address);
if (version === 0) return false;
if (version === 6) {
// An IPv4 address in IPv6 clothing is judged as what it really is.
const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(address);
if (mapped) return isPublicAddress(mapped[1]!);
return !blocked.check(address, 'ipv6');
}
return !blocked.check(address, 'ipv4');
}
/** Only plain web URLs on the standard ports; no credentials in the URL. */
export function isAllowedWebUrl(url: URL): boolean {
if (url.protocol !== 'https:' && url.protocol !== 'http:') return false;
if (url.username || url.password) return false;
if (url.port && url.port !== '80' && url.port !== '443') return false;
return true;
}
+7 -7
View File
@@ -3,6 +3,7 @@ import { eq, sql } from 'drizzle-orm';
import { db } from '@/db'; import { db } from '@/db';
import { user, wishlist } from '@/db/schema'; import { user, wishlist } from '@/db/schema';
import { env } from '@/env'; import { env } from '@/env';
import { isMailConfigured } from './mail-settings';
import { sendMail } from './mailer'; import { sendMail } from './mailer';
import { import {
reminderSubject, reminderSubject,
@@ -59,7 +60,7 @@ function loadCandidates(): Candidate[] {
export async function runReminderJob(now = new Date()): Promise<ReminderRun> { export async function runReminderJob(now = new Date()): Promise<ReminderRun> {
const result: ReminderRun = { checked: 0, sent: 0, skipped: 0, failures: [] }; const result: ReminderRun = { checked: 0, sent: 0, skipped: 0, failures: [] };
if (!env.isMailConfigured) return result; if (!isMailConfigured()) return result;
const candidates = loadCandidates(); const candidates = loadCandidates();
result.checked = candidates.length; result.checked = candidates.length;
@@ -129,13 +130,12 @@ export function ensureReminderScheduler(): void {
* cooldown keeps that from turning into repeated mail. * cooldown keeps that from turning into repeated mail.
*/ */
export function startReminderScheduler(): void { export function startReminderScheduler(): void {
if (!env.isMailConfigured) { // Always scheduled: the mail server can be set up in the app at any time,
console.log('[reminders] Kein SMTP konfiguriert - Erinnerungen sind aus.'); // and each run checks whether one is configured.
return;
}
console.log( console.log(
`[reminders] Aktiv: Erinnerung ${env.REMINDER_DAYS_BEFORE} Tage vor dem Termin, stuendliche Pruefung.`, isMailConfigured()
? `[reminders] Aktiv: Erinnerung ${env.REMINDER_DAYS_BEFORE} Tage vor dem Termin, stuendliche Pruefung.`
: '[reminders] Noch kein SMTP-Server eingerichtet - Erinnerungen starten, sobald es einen gibt.',
); );
const tick = () => { const tick = () => {
+167
View File
@@ -0,0 +1,167 @@
import 'server-only';
import { lookup as dnsLookup, type LookupAddress } from 'node:dns';
import http from 'node:http';
import https from 'node:https';
import { isIP, type LookupFunction } from 'node:net';
import { Readable } from 'node:stream';
import zlib from 'node:zlib';
import { isAllowedWebUrl, isPublicAddress } from './net-guard';
/**
* Fetches a URL a user pasted - and nothing else.
*
* - Every hostname is resolved first and refused if any of its addresses is
* not public (see net-guard.ts). The check happens inside the socket's DNS
* lookup, so a second resolution cannot swap in a private address.
* - Redirects are followed by hand, and every hop passes the same checks.
* - Size and time are capped, compressed bodies included.
*/
export class SafeFetchError extends Error {}
export type SafeResponse = {
status: number;
url: string;
contentType: string;
body: Buffer;
};
type Options = {
maxBytes?: number;
timeoutMs?: number;
accept?: string;
};
const MAX_REDIRECTS = 4;
// A regular browser identity: some shops answer anything else with an error
// page. It is one request per paste, the same a browser preview would make.
const USER_AGENT =
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0 Safari/537.36';
const guardedLookup = ((
hostname: string,
options: { all?: boolean; family?: number },
callback: (error: Error | null, address: string | LookupAddress[], family?: number) => void,
) => {
dnsLookup(hostname, { family: options.family ?? 0, all: true }, (error, addresses) => {
if (error) return callback(error, '', 4);
if (addresses.length === 0 || addresses.some((entry) => !isPublicAddress(entry.address))) {
return callback(new SafeFetchError(`${hostname} zeigt auf eine interne Adresse.`), '', 4);
}
if (options.all) return callback(null, addresses);
callback(null, addresses[0]!.address, addresses[0]!.family);
});
}) as unknown as LookupFunction;
function checkUrl(url: URL) {
if (!isAllowedWebUrl(url)) throw new SafeFetchError('Nur normale http- und https-Links.');
// Literal IPs never reach the DNS lookup, so they are checked here.
const host = url.hostname.replace(/^\[|\]$/g, '');
if (isIP(host) && !isPublicAddress(host)) {
throw new SafeFetchError('Interne Adressen werden nicht abgerufen.');
}
}
function decode(response: http.IncomingMessage): Readable {
switch ((response.headers['content-encoding'] ?? '').toLowerCase()) {
case 'gzip':
case 'x-gzip':
return response.pipe(zlib.createGunzip());
case 'deflate':
return response.pipe(zlib.createInflate());
case 'br':
return response.pipe(zlib.createBrotliDecompress());
default:
return response;
}
}
function requestOnce(url: URL, options: Required<Options>, signal: AbortSignal) {
return new Promise<{ status: number; location: string | null; contentType: string; body: Buffer }>(
(resolve, reject) => {
const client = url.protocol === 'https:' ? https : http;
const request = client.get(
url,
{
lookup: guardedLookup,
signal,
headers: {
'user-agent': USER_AGENT,
accept: options.accept,
'accept-language': 'de-DE,de;q=0.9,en;q=0.6',
'accept-encoding': 'gzip, deflate, br',
},
},
(response) => {
const status = response.statusCode ?? 0;
const location = response.headers.location ?? null;
const contentType = response.headers['content-type'] ?? '';
if (status >= 300 && status < 400 && location) {
response.resume();
resolve({ status, location, contentType, body: Buffer.alloc(0) });
return;
}
const chunks: Buffer[] = [];
let size = 0;
const stream = decode(response);
stream.on('data', (chunk: Buffer) => {
size += chunk.byteLength;
if (size > options.maxBytes) {
request.destroy();
stream.destroy();
reject(new SafeFetchError('Die Seite ist zu groß.'));
return;
}
chunks.push(chunk);
});
stream.on('end', () =>
resolve({ status, location: null, contentType, body: Buffer.concat(chunks) }),
);
stream.on('error', reject);
},
);
request.on('error', reject);
},
);
}
export async function safeFetch(rawUrl: string | URL, options: Options = {}): Promise<SafeResponse> {
const settings: Required<Options> = {
maxBytes: options.maxBytes ?? 3 * 1024 * 1024,
timeoutMs: options.timeoutMs ?? 10_000,
accept: options.accept ?? 'text/html,application/xhtml+xml;q=0.9,*/*;q=0.5',
};
const signal = AbortSignal.timeout(settings.timeoutMs);
let url = new URL(rawUrl);
for (let hop = 0; hop <= MAX_REDIRECTS; hop += 1) {
checkUrl(url);
let result;
try {
result = await requestOnce(url, settings, signal);
} catch (error) {
if (error instanceof SafeFetchError) throw error;
if (signal.aborted) throw new SafeFetchError('Die Seite hat zu lange gebraucht.');
throw new SafeFetchError('Die Seite war nicht erreichbar.');
}
if (result.location) {
url = new URL(result.location, url);
continue;
}
return {
status: result.status,
url: url.toString(),
contentType: result.contentType,
body: result.body,
};
}
throw new SafeFetchError('Zu viele Weiterleitungen.');
}
+41
View File
@@ -0,0 +1,41 @@
import { createCipheriv, createDecipheriv, hkdfSync, randomBytes } from 'node:crypto';
/**
* Encrypts secrets that settings must keep, such as the SMTP password.
*
* AES-256-GCM with a key derived from BETTER_AUTH_SECRET: a copy of the
* database alone does not reveal the password. Changing BETTER_AUTH_SECRET
* makes stored secrets unreadable - `openSecret` then returns null and the
* password has to be entered again. Pure apart from node:crypto, so tested.
*/
const VERSION = 'v1';
function deriveKey(masterSecret: string): Buffer {
return Buffer.from(hkdfSync('sha256', masterSecret, 'bricktrack', 'app-setting-secret', 32));
}
export function sealSecret(plain: string, masterSecret: string): string {
const iv = randomBytes(12);
const cipher = createCipheriv('aes-256-gcm', deriveKey(masterSecret), iv);
const encrypted = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return [VERSION, iv.toString('base64'), tag.toString('base64'), encrypted.toString('base64')].join(':');
}
export function openSecret(sealed: string, masterSecret: string): string | null {
const [version, iv, tag, encrypted] = sealed.split(':');
if (version !== VERSION || !iv || !tag || encrypted === undefined) return null;
try {
const decipher = createDecipheriv('aes-256-gcm', deriveKey(masterSecret), Buffer.from(iv, 'base64'));
decipher.setAuthTag(Buffer.from(tag, 'base64'));
return Buffer.concat([
decipher.update(Buffer.from(encrypted, 'base64')),
decipher.final(),
]).toString('utf8');
} catch {
// Wrong key or tampered value: treat as "no password stored".
return null;
}
}
+33
View File
@@ -219,6 +219,39 @@ export const reservationSchema = z.object({
export type ManufacturerInput = z.infer<typeof manufacturerSchema>; export type ManufacturerInput = z.infer<typeof manufacturerSchema>;
export type ThemeInput = z.infer<typeof themeSchema>; export type ThemeInput = z.infer<typeof themeSchema>;
// ---------------------------------------------------------------------------
// Mail (reminders)
// ---------------------------------------------------------------------------
export const mailSecurityEnum = z.enum(['starttls', 'tls', 'none']);
export const mailSettingsSchema = z.object({
host: z
.string()
.trim()
.min(1, 'Bitte den SMTP-Server angeben.')
.max(255)
.regex(/^[a-z0-9.-]+$/i, 'Nur der Servername, z. B. mail.example.de – ohne https:// und Port.'),
port: z.coerce.number().int().min(1, 'Ungültiger Port.').max(65535, 'Ungültiger Port.').default(587),
security: mailSecurityEnum.default('starttls'),
user: optionalText(255),
/** Empty keeps the stored password; see clearPassword to remove it. Not trimmed. */
password: z
.string()
.max(500)
.nullish()
.transform((value) => (value ? value : null)),
clearPassword: checkbox,
from: z
.string()
.trim()
.min(3, 'Bitte einen Absender angeben.')
.max(255)
.refine((value) => /@[^@\s>]+\.[^@\s>]+/.test(value), 'Der Absender braucht eine E-Mail-Adresse.'),
});
export type MailSettingsInput = z.infer<typeof mailSettingsSchema>;
export type LocationInput = z.infer<typeof locationSchema>; export type LocationInput = z.infer<typeof locationSchema>;
export type SetInput = z.infer<typeof setSchema>; export type SetInput = z.infer<typeof setSchema>;
export type CollectionItemInput = z.infer<typeof collectionItemSchema>; export type CollectionItemInput = z.infer<typeof collectionItemSchema>;
+80
View File
@@ -0,0 +1,80 @@
import { describe, expect, it } from 'vitest';
import { brickRow } from '@/lib/brick-row';
import { DEFAULT_DESIGN, DESIGNS, DESIGN_INFO, resolveDesign, TONE_COUNT, toneIndex } from '@/lib/design';
describe('Design aus dem Cookie', () => {
it('nimmt ein bekanntes Design', () => {
expect(resolveDesign('nacht')).toBe('nacht');
expect(resolveDesign('schlicht')).toBe('schlicht');
});
it('faellt bei Unbekanntem auf Klemmbaustein zurueck', () => {
expect(DEFAULT_DESIGN).toBe('klemmbaustein');
expect(resolveDesign(undefined)).toBe('klemmbaustein');
expect(resolveDesign('')).toBe('klemmbaustein');
expect(resolveDesign('"><script>')).toBe('klemmbaustein');
});
it('beschreibt jedes Design', () => {
for (const design of DESIGNS) {
expect(DESIGN_INFO[design].label.length).toBeGreaterThan(0);
expect(DESIGN_INFO[design].themeColor).toMatch(/^#[0-9a-f]{6}$/);
}
expect(DESIGNS.filter((design) => DESIGN_INFO[design].dark)).toEqual(['nacht']);
});
});
describe('Farbton fuer Sets ohne Bild', () => {
it('gibt den bekannten Herstellern feste, verschiedene Toene', () => {
const tones = ['Lego', 'Playmobil', 'Cobi', 'BlueBrixx', 'Mega Bloks'].map(toneIndex);
expect(new Set(tones).size).toBe(5);
expect(toneIndex('Lego')).toBe(1);
expect(toneIndex('LEGO')).toBe(1);
});
it('bleibt fuer andere Hersteller stabil und im Bereich', () => {
expect(toneIndex('Sluban')).toBe(toneIndex('Sluban'));
for (const name of ['Sluban', 'Qman', 'Wange', '', 'Ölfabrik']) {
const tone = toneIndex(name);
expect(tone).toBeGreaterThanOrEqual(1);
expect(tone).toBeLessThanOrEqual(TONE_COUNT);
}
});
});
describe('Steinreihe auf der Startseite', () => {
it('ist leer ohne Katalog oder ohne Sammlung', () => {
expect(brickRow(0, 10, [])).toEqual(Array(10).fill(null));
expect(brickRow(3, 0, [])).toEqual(Array(10).fill(null));
});
it('verteilt die Steine nach Anteil auf die Sammler', () => {
const row = brickRow(4, 10, [
{ key: 'max', count: 2 },
{ key: 'mia', count: 2 },
]);
expect(row).toEqual(['max', 'max', 'mia', 'mia', null, null, null, null, null, null]);
});
it('zeigt mindestens einen Stein, aber nie eine volle Reihe vor dem Ziel', () => {
expect(brickRow(1, 500, [{ key: 'max', count: 1 }]).filter(Boolean)).toHaveLength(1);
expect(brickRow(499, 500, [{ key: 'max', count: 499 }]).filter(Boolean)).toHaveLength(9);
expect(brickRow(500, 500, [{ key: 'max', count: 500 }]).filter(Boolean)).toHaveLength(10);
});
it('vergibt Reststeine nach dem groessten Bruchteil', () => {
const row = brickRow(10, 20, [
{ key: 'a', count: 1 },
{ key: 'b', count: 1 },
{ key: 'c', count: 1 },
]);
// 5 Steine auf drei gleiche Anteile: 2, 2, 1 - die vorderen zuerst.
expect(row.filter((key) => key === 'a')).toHaveLength(2);
expect(row.filter((key) => key === 'b')).toHaveLength(2);
expect(row.filter((key) => key === 'c')).toHaveLength(1);
});
it('fuellt ohne Sammler-Zuordnung neutral', () => {
expect(brickRow(5, 10, []).slice(0, 5)).toEqual(['', '', '', '', '']);
});
});
+209
View File
@@ -0,0 +1,209 @@
import { describe, expect, it } from 'vitest';
import {
canonicalBrand,
cleanSetName,
hintsFromHtml,
hintsFromUrl,
mergeHints,
sourceLabel,
} from '@/lib/link-import';
import { isAllowedWebUrl, isPublicAddress } from '@/lib/net-guard';
describe('Hersteller erkennen', () => {
it('kennt die gaengigen Schreibweisen', () => {
expect(canonicalBrand('LEGO®')).toBe('Lego');
expect(canonicalBrand('Blue Brixx')).toBe('BlueBrixx');
expect(canonicalBrand('BlueBrixx Pro')).toBe('BlueBrixx');
expect(canonicalBrand('Mega Construx')).toBe('Mega Bloks');
expect(canonicalBrand('COBI')).toBe('Cobi');
});
it('raet nicht bei Unbekanntem', () => {
expect(canonicalBrand('Ravensburger')).toBeUndefined();
expect(canonicalBrand('')).toBeUndefined();
expect(canonicalBrand('legoland')).toBe('Lego');
});
});
describe('Daten aus dem Link', () => {
it('liest Amazon-Links mit Produktnamen', () => {
const url = new URL(
'https://www.amazon.de/LEGO-60398-Familienhaus-Elektroauto-Spielzeug/dp/B0BYZJXGZQ/ref=sr_1_1?keywords=lego',
);
expect(sourceLabel(url)).toBe('Amazon');
expect(hintsFromUrl(url)).toEqual({
manufacturer: 'Lego',
setNumber: '60398',
name: 'Familienhaus Elektroauto',
});
});
it('nimmt aus einem Amazon-Link ohne Namen nicht die ASIN', () => {
const hints = hintsFromUrl(new URL('https://www.amazon.de/dp/B0BYZJXGZQ'));
expect(hints.setNumber).toBeUndefined();
});
it('liest idealo und geizhals', () => {
expect(
hintsFromUrl(
new URL(
'https://www.idealo.de/preisvergleich/OffersOfProduct/202595377_-familienhaus-mit-elektroauto-60398-lego.html',
),
),
).toEqual({ manufacturer: 'Lego', setNumber: '60398', name: 'Familienhaus mit Elektroauto' });
expect(
hintsFromUrl(new URL('https://geizhals.de/bluebrixx-burg-blaustein-101400-a2101234.html')),
).toEqual({ manufacturer: 'BlueBrixx', setNumber: '101400', name: 'Burg Blaustein' });
});
it('erkennt Cobi-Nummern mit Bindestrich und Jahreszahlen nicht als Setnummer', () => {
expect(hintsFromUrl(new URL('https://example.com/shop/cobi-2-30500-ford-mustang-gt')).setNumber).toBe(
'2-30500',
);
expect(
hintsFromUrl(new URL('https://example.com/p/playmobil-adventskalender-2024-71472')).setNumber,
).toBe('71472');
});
it('liest Marke und Nummer aus Merlins-Bricks-Adressen', () => {
expect(hintsFromUrl(new URL('https://www.merlinsbricks.com/sets/bb-108782/'))).toEqual({
manufacturer: 'BlueBrixx',
setNumber: '108782',
});
expect(hintsFromUrl(new URL('https://www.merlinsbricks.com/sets/cobi-2400'))).toEqual({
manufacturer: 'Cobi',
setNumber: '2400',
});
});
it('erkennt den Hersteller am Shop', () => {
expect(hintsFromUrl(new URL('https://www.bluebrixx.com/de/star-trek/108123/raumstation')).manufacturer).toBe(
'BlueBrixx',
);
});
});
describe('Daten aus der Seite', () => {
const page = new URL('https://brickfact.com/de-de/sets/lego-city-familienhaus-60398');
it('nimmt schema.org-Produktdaten', () => {
const html = `<html><head>
<script type="application/ld+json">{"@context":"https://schema.org","@graph":[
{"@type":"WebSite","name":"Brickfact"},
{"@type":"Product","name":"LEGO&#174; City Familienhaus mit Elektroauto 60398",
"sku":"60398","mpn":"60398","gtin13":"5702017462363",
"brand":{"@type":"Brand","name":"LEGO"},
"image":["/img/60398.webp"],
"description":"Ein Set mit 462 Teilen aus 2023."}]}
</script></head></html>`;
expect(hintsFromHtml(html, page)).toEqual({
manufacturer: 'Lego',
setNumber: '60398',
name: 'LEGO® City Familienhaus mit Elektroauto 60398',
ean: '5702017462363',
imageUrl: 'https://brickfact.com/img/60398.webp',
pieceCount: 462,
year: 2023,
});
});
it('faellt auf Open Graph zurueck und verwirft ungueltige EAN', () => {
const html = `<meta property="og:title" content="Burg Blaustein &amp; Zubeh&ouml;r">
<meta content="https://cdn.example.com/burg.jpg" property="og:image">
<script type="application/ld+json">{"@type":"Product","gtin13":"1234567890123"}</script>`;
const hints = hintsFromHtml(html, new URL('https://example.com/burg'));
expect(hints.name).toBe('Burg Blaustein & Zubehör');
expect(hints.imageUrl).toBe('https://cdn.example.com/burg.jpg');
expect(hints.ean).toBeUndefined();
});
it('haelt Shop-Artikelnummern nicht fuer Setnummern', () => {
const html = '<script type="application/ld+json">{"@type":"Product","sku":"B0BYZJXGZQ"}</script>';
expect(hintsFromHtml(html, page).setNumber).toBeUndefined();
});
it('liest Amazon-Seiten ohne schema.org', () => {
const html = `
<span id="productTitle" class="a-size-large"> LEGO City Familienhaus mit Elektroauto, 60398 </span>
<a id="bylineInfo" href="/stores/LEGO">Besuche den LEGO-Store</a>
<script>var data = {"hiRes":"https://m.media-amazon.com/images/I/81abc.jpg","thumb":"x"};</script>
<table><tr><th class="prodDetSectionEntry"> Modellnummer </th><td> &lrm;60398 </td></tr>
<tr><th class="prodDetSectionEntry"> Anzahl der Teile </th><td> 462 </td></tr></table>`;
expect(hintsFromHtml(html, new URL('https://www.amazon.de/dp/B0BYZJXGZQ'))).toEqual({
manufacturer: 'Lego',
setNumber: '60398',
name: 'LEGO City Familienhaus mit Elektroauto, 60398',
ean: undefined,
imageUrl: 'https://m.media-amazon.com/images/I/81abc.jpg',
pieceCount: 462,
});
});
it('findet die Setnummer im Produktnamen', () => {
const html = '<meta property="og:title" content="Cobi 2400 Ford GP ab € 12,90 (2026)">';
const hints = hintsFromHtml(html, new URL('https://geizhals.de/x-a1.html'));
expect(hints.setNumber).toBe('2400');
expect(cleanSetName(hints.name!, 'Cobi', hints.setNumber)).toBe('Ford GP');
});
it('uebersteht kaputtes JSON', () => {
const html = '<script type="application/ld+json">{ kaputt </script><meta property="og:title" content="X">';
expect(hintsFromHtml(html, page).name).toBe('X');
});
});
describe('Zusammenfuehren und Namen putzen', () => {
it('nimmt pro Feld die erste Quelle mit Wert', () => {
expect(
mergeHints({ name: 'Seite', pieceCount: undefined }, { name: 'Link', pieceCount: 450 }, null),
).toEqual({ name: 'Seite', pieceCount: 450 });
});
it('entfernt Marke, Nummer und Seitennamen', () => {
expect(cleanSetName('LEGO® City Familienhaus mit Elektroauto 60398', 'Lego', '60398')).toBe(
'City Familienhaus mit Elektroauto',
);
expect(
cleanSetName('BlueBrixx - Technisches Hilfswerk™ Bi-Directional Vehicle | Set 108782', 'BlueBrixx', '108782'),
).toBe('Technisches Hilfswerk Bi-Directional Vehicle');
expect(cleanSetName('Cobi Ford GP • Set 2400 • SetDB • Merlins Bricks', 'Cobi', '2400')).toBe('Ford GP');
});
});
describe('Nur oeffentliche Adressen', () => {
it('sperrt Heimnetz, Loopback und Metadaten-Adressen', () => {
for (const address of [
'127.0.0.1',
'10.0.0.5',
'172.16.3.4',
'192.168.178.1',
'169.254.169.254',
'100.64.0.1',
'0.0.0.0',
'::1',
'fd00::1',
'fe80::1',
'::ffff:192.168.1.1',
]) {
expect(isPublicAddress(address), address).toBe(false);
}
});
it('laesst oeffentliche Adressen durch', () => {
expect(isPublicAddress('93.184.216.34')).toBe(true);
expect(isPublicAddress('2a00:1450:4001:80b::200e')).toBe(true);
expect(isPublicAddress('::ffff:93.184.216.34')).toBe(true);
});
it('erlaubt nur normale Web-Links', () => {
expect(isAllowedWebUrl(new URL('https://example.com/a'))).toBe(true);
expect(isAllowedWebUrl(new URL('http://example.com:80/a'))).toBe(true);
expect(isAllowedWebUrl(new URL('file:///etc/passwd'))).toBe(false);
expect(isAllowedWebUrl(new URL('https://example.com:8443/'))).toBe(false);
expect(isAllowedWebUrl(new URL('https://user:pw@example.com/'))).toBe(false);
});
});
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest';
import { openSecret, sealSecret } from '@/lib/secret-box';
import { mailSettingsSchema } from '@/lib/validation';
const SECRET = 'test-secret-0123456789-abcdefghijklmnop';
describe('Passwort verschluesselt speichern', () => {
it('kommt mit demselben Schluessel unveraendert zurueck', () => {
const sealed = sealSecret('geheim mit Leerzeichen ', SECRET);
expect(sealed).not.toContain('geheim');
expect(openSecret(sealed, SECRET)).toBe('geheim mit Leerzeichen ');
});
it('verschluesselt dasselbe Passwort jedes Mal anders', () => {
expect(sealSecret('x', SECRET)).not.toBe(sealSecret('x', SECRET));
});
it('liefert nichts bei anderem Schluessel oder veraendertem Wert', () => {
const sealed = sealSecret('geheim', SECRET);
expect(openSecret(sealed, 'ein-anderes-secret-0123456789-abcdefgh')).toBeNull();
const [version, iv, tag, data] = sealed.split(':');
const flipped = Buffer.from(data!, 'base64');
flipped[0] = flipped[0]! ^ 0xff;
expect(openSecret([version, iv, tag, flipped.toString('base64')].join(':'), SECRET)).toBeNull();
expect(openSecret('kaputt', SECRET)).toBeNull();
});
});
describe('SMTP-Eingaben pruefen', () => {
const valid = {
host: 'mail.example.de',
port: '587',
security: 'starttls',
user: 'bricktrack@example.de',
password: ' mit Rand ',
from: 'Bricktrack <bricktrack@example.de>',
};
it('nimmt gueltige Angaben und laesst das Passwort, wie es ist', () => {
const parsed = mailSettingsSchema.parse(valid);
expect(parsed.port).toBe(587);
expect(parsed.password).toBe(' mit Rand ');
expect(parsed.clearPassword).toBe(false);
});
it('macht aus einem leeren Passwort "unveraendert"', () => {
expect(mailSettingsSchema.parse({ ...valid, password: '' }).password).toBeNull();
});
it('lehnt URLs, Ports und Absender ohne Adresse ab', () => {
expect(mailSettingsSchema.safeParse({ ...valid, host: 'https://mail.example.de' }).success).toBe(false);
expect(mailSettingsSchema.safeParse({ ...valid, host: 'mail.example.de:587' }).success).toBe(false);
expect(mailSettingsSchema.safeParse({ ...valid, port: '70000' }).success).toBe(false);
expect(mailSettingsSchema.safeParse({ ...valid, from: 'Bricktrack' }).success).toBe(false);
expect(mailSettingsSchema.safeParse({ ...valid, security: 'ssl3' }).success).toBe(false);
});
});