Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebab5627de | ||
|
|
01bbb3ba9a | ||
|
|
ebdd6c313a | ||
|
|
7d75e4345a | ||
|
|
7d7711eb8d | ||
|
|
a8d933bf5b | ||
|
|
295da414bb | ||
|
|
d5ffcbae60 | ||
|
|
d578eac743 | ||
|
|
95a28640e1 | ||
|
|
498a5c6226 | ||
|
|
78ae755761 | ||
|
|
62045468ce | ||
|
|
bac948fcb8 | ||
|
|
2d3ddc4dae | ||
|
|
604a8ac113 | ||
|
|
f2304b28e5 | ||
|
|
d009230dc2 | ||
|
|
1b825b44c4 | ||
|
|
e9093d0fad | ||
|
|
e52557b4bf | ||
|
|
0d08df0bff | ||
|
|
91fb66b13b | ||
|
|
87131b8e70 | ||
|
|
c23b754515 | ||
|
|
7265c03548 |
@@ -32,3 +32,16 @@ TZ=Europe/Berlin
|
||||
# und Bild werden uebernommen. Ohne Key funktioniert alles manuell weiter.
|
||||
# Kostenlos anlegen: https://rebrickable.com/api/ (Konto -> Settings -> API)
|
||||
# REBRICKABLE_API_KEY=
|
||||
|
||||
# Optional: Mailversand fuer Erinnerungen an Wunschlisten-Termine. Einfacher
|
||||
# geht es in der App unter Mehr -> Erinnerungen -> Mailserver. Steht der
|
||||
# Server hier, hat die .env Vorrang und die App zeigt ihn nur an.
|
||||
# SMTP_HOST=mail.example.de
|
||||
# SMTP_PORT=587
|
||||
# SMTP_SECURE=false
|
||||
# SMTP_USER=bricktrack@example.de
|
||||
# SMTP_PASSWORD=
|
||||
# SMTP_FROM=Bricktrack <bricktrack@example.de>
|
||||
|
||||
# Wie viele Tage vor dem Termin erinnert wird (Standard: 14).
|
||||
# REMINDER_DAYS_BEFORE=14
|
||||
|
||||
@@ -7,6 +7,134 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
- **Mailserver in der App einrichten**: Unter Mehr → Erinnerungen → Mailserver
|
||||
lassen sich Server, Port, Verschlüsselung, Zugang und Absender eintragen –
|
||||
ohne `.env` und ohne Neustart. Das Passwort wird verschlüsselt gespeichert
|
||||
(AES-256-GCM, Schlüssel aus `BETTER_AUTH_SECRET`), nie wieder angezeigt und
|
||||
nicht exportiert. Steht der Server in der `.env`, hat sie weiter Vorrang.
|
||||
- **Set aus einem Link übernehmen**, für alle Hersteller: Amazon, idealo,
|
||||
geizhals, Hersteller-Shops, Brickfact oder Merlins Bricks. Bricktrack liest
|
||||
Produktdaten (schema.org, Open Graph, bei Amazon die Produktseite) und, wenn
|
||||
die Seite blockt, den Link selbst. Lego wird über Rebrickable ergänzt,
|
||||
BlueBrixx und Cobi über Merlins Bricks. Das Ergebnis füllt das Formular vor,
|
||||
das Bild wird beim Speichern lokal abgelegt.
|
||||
|
||||
### Geändert
|
||||
|
||||
- Der Zeitplan für Erinnerungen läuft immer und prüft bei jedem Durchlauf, ob
|
||||
ein Mailserver eingerichtet ist. Vorher wurde er nur gestartet, wenn SMTP
|
||||
schon beim Containerstart in der `.env` stand.
|
||||
- **Neue Navigation**: unten Start, Katalog, Sammlung, Wünsche und in der
|
||||
Mitte ein +-Stein mit allen Wegen zu einem neuen Set (Scannen, aus Link,
|
||||
Lego-Nummer, von Hand).
|
||||
- **„Mehr" statt Konto-Menü und Verwaltungsreitern**: Konto, Design,
|
||||
Sammler, Einladungen, Erinnerungen, Hersteller, Themen, Standorte und
|
||||
Export/Import als Kacheln in Gruppen, hinter dem Button „Mehr" oben rechts.
|
||||
- **„Für wen?" in der Kopfzeile**: Bei mehreren Sammlern wird das Kind einmal
|
||||
oben gewählt und gilt für Startseite, Katalog und Sammlung. Jedes Gerät
|
||||
merkt sich die Wahl; ein `?sammler=` im Link hat weiterhin Vorrang.
|
||||
|
||||
### Behoben
|
||||
|
||||
- Mitglieder (nicht Admin) kamen nicht an Sammler, Hersteller, Themen und
|
||||
Standorte heran, obwohl sie diese pflegen dürfen: Der Eintrag
|
||||
„Verwaltung" stand nur im Menü des Administrators.
|
||||
|
||||
### Sicherheit
|
||||
|
||||
- Abrufe im Auftrag eines Nutzers gehen nur an öffentliche Adressen: Jede
|
||||
Adresse wird vor dem Verbindungsaufbau geprüft, Weiterleitungen ebenso,
|
||||
Größe und Zeit sind begrenzt.
|
||||
|
||||
## [0.4.0] - 2026-10-25
|
||||
|
||||
Ein neues Gesicht: Bricktrack sieht jetzt aus wie das, was es verwaltet.
|
||||
Dazu alle bekannten Sicherheitslücken in den Abhängigkeiten geschlossen.
|
||||
|
||||
### Sicherheit
|
||||
|
||||
- Next.js 15.5.28: schließt unter anderem eine Denial-of-Service-Lücke über
|
||||
Server Actions und Cache-Poisoning bei selbst gehosteten Instanzen. Die
|
||||
kritische Lücke in der Bildoptimierung war hier nicht ausnutzbar
|
||||
(`images.unoptimized`), ist aber ebenfalls behoben.
|
||||
- nodemailer 10.2: mehrere Fehler im Adressparser (Erinnerungs-Mails).
|
||||
- better-auth 1.7.12, sharp 0.35.5 (librsvg), zod 4.6.7 (Speicherverbrauch
|
||||
bei übergroßen Eingaben), drizzle-orm 0.45.4.
|
||||
- Entwicklungswerkzeuge: vitest, vite, postcss, Playwright und die
|
||||
shadcn-CLI aktualisiert, transitive Pakete per `pnpm.overrides` angehoben.
|
||||
Übrig bleibt ein mittlerer Fund in einem alten esbuild, das drizzle-kit
|
||||
intern mitbringt - nur beim Entwickeln, nicht im Image.
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
- **Designauswahl** unter Konto → Design: Klemmbaustein (neuer Standard),
|
||||
Warm, Nachtblau (dunkel), Bonbon, Bauklotz und Schlicht (der bisherige
|
||||
Look). Die Wahl gilt pro Gerät und wirkt sofort.
|
||||
- **Platzhalter für Sets ohne Bild**: Farbe des Herstellers, gezeichneter
|
||||
Stein und Setnummer statt eines grauen „Bild fehlt".
|
||||
- **Startseite als Überblick**: Steinreihe für den Fortschritt (bei mehreren
|
||||
Sammlern in deren Farben), Teile, Wert, nächster Wunschlisten-Termin mit
|
||||
Countdown und Abkürzungen.
|
||||
- Neues Logo als Baustein, Schriften lokal gebündelt (kein CDN).
|
||||
|
||||
### Geändert
|
||||
|
||||
- Karten, Buttons und Badges tragen im Standard-Design Konturen, eine feste
|
||||
Unterkante und Noppen. In der Sammlung und auf der öffentlichen
|
||||
Wunschliste zeigen die Noppen den Sammler bzw. ob ein Wunsch reserviert ist.
|
||||
- Die öffentliche Wunschliste hat einen farbigen Kopf mit Anlass und Datum.
|
||||
- Screenshots der Anleitung zeigen die feste Navigation nicht mehr mitten
|
||||
im Bild.
|
||||
|
||||
## [0.3.0] - 2026-09-07
|
||||
|
||||
Phase 3: mehrere Kinder, Wertentwicklung, Erinnerungen und ein Schutz vor
|
||||
doppelten Katalogeintraegen.
|
||||
|
||||
### Hinzugefuegt
|
||||
|
||||
**Mehrere Sammler**
|
||||
- Der Katalog bleibt gemeinsam, Sammlung und Wunschlisten gehoeren je einem
|
||||
Sammler. "Hat er das schon?" beantwortet damit die Frage fuer *ihn*: ein Set,
|
||||
das nur die Schwester besitzt, faellt fuer den Bruder unter "fehlt noch".
|
||||
- Umschalter in Katalog und Sammlung, Farbmarkierung je Sammler, Zuordnung im
|
||||
Sammlungseintrag und je Wunschliste.
|
||||
- Solange es nur einen Sammler gibt, ist davon nichts zu sehen - die App
|
||||
bleibt genauso einfach wie vorher.
|
||||
|
||||
**Preisbeobachtung**
|
||||
- Notierte Preise je Set mit Betrag, Zustand, Datum, Quelle und Notiz.
|
||||
- Wertentwicklung gegen die UVP, in der Sammlung als Kachel "Aktueller Wert".
|
||||
|
||||
**Erinnerungen**
|
||||
- E-Mail an den Besitzer, wenn der Termin einer Wunschliste naeher rueckt und
|
||||
noch Wuensche offen sind - standardmaessig 14 Tage vorher, hoechstens einmal
|
||||
pro Woche je Liste.
|
||||
- Verwaltungsseite mit Verbindungstest, Testnachricht und sofortiger Pruefung.
|
||||
- Ohne SMTP-Konfiguration bleibt die Funktion vollstaendig aus.
|
||||
|
||||
**Dublettenpruefung**
|
||||
- Vor dem Anlegen eines Sets warnt die App vor gleicher Setnummer beim
|
||||
gleichen Hersteller, sehr aehnlichem Namen und gleicher Nummer bei einem
|
||||
anderen Hersteller - mit Link zum Verdaechtigen und der Moeglichkeit,
|
||||
trotzdem anzulegen.
|
||||
|
||||
### Geaendert
|
||||
|
||||
- Die Migration legt einen Sammler "Sammlung" an und ordnet ihm alle
|
||||
vorhandenen Sammlungseintraege und Wunschlisten zu. Umbenennen unter
|
||||
Verwaltung -> Sammler.
|
||||
- Der Smoke-Test laeuft gegen das Docker-Image statt gegen den Dev-Server.
|
||||
|
||||
### Nicht umgesetzt
|
||||
|
||||
- Der BrickLink-Preisfuehrer war im Plan als optional vermerkt und wurde
|
||||
bewusst weggelassen: OAuth1 mit einer pro Nutzer registrierten Anwendung ist
|
||||
viel Apparat fuer eine Zahl, die sich in zehn Sekunden eintippen laesst. Das
|
||||
Feld "Wo" nimmt jede Quelle auf, die Datenstruktur bleibt offen.
|
||||
|
||||
## [0.2.0] - 2026-09-07
|
||||
|
||||
Phase 2: die Handgriffe, die den Alltag schneller machen.
|
||||
@@ -147,6 +275,8 @@ Erste lauffaehige Fassung: Phase 1 vollstaendig, im Docker-Stack verifiziert.
|
||||
- Unter Windows scheitert `pnpm build` an fehlenden Symlink-Rechten. Der
|
||||
Docker-Build unter Linux ist davon nicht betroffen.
|
||||
|
||||
[Unreleased]: https://git.wilde-server.de/florian.wilde/bricktrack/compare/v0.2.0...HEAD
|
||||
[Unreleased]: https://git.wilde-server.de/florian.wilde/bricktrack/compare/v0.4.0...HEAD
|
||||
[0.4.0]: https://git.wilde-server.de/florian.wilde/bricktrack/releases/tag/v0.4.0
|
||||
[0.3.0]: https://git.wilde-server.de/florian.wilde/bricktrack/releases/tag/v0.3.0
|
||||
[0.2.0]: https://git.wilde-server.de/florian.wilde/bricktrack/releases/tag/v0.2.0
|
||||
[0.1.0]: https://git.wilde-server.de/florian.wilde/bricktrack/releases/tag/v0.1.0
|
||||
|
||||
@@ -57,8 +57,27 @@ Bild-URLs, die der Nutzer selbst hinterlegt hat.
|
||||
| `src/lib/rebrickable.ts` | Lego-Import, ausschliesslich serverseitig |
|
||||
| `src/lib/set-number.ts`, `src/lib/ean.ts` | Setnummern und Barcodes (rein, testbar) |
|
||||
| `src/lib/uploads.ts` | Datei-Uploads, Magic Bytes, Thumbnails |
|
||||
| `src/lib/similarity.ts` | Namensvergleich fuer die Dublettenpruefung (rein) |
|
||||
| `src/lib/prices.ts` | Wertentwicklung aus notierten Preisen (rein) |
|
||||
| `src/lib/reminders.ts` | Regeln, wann eine Erinnerung faellig ist (rein) |
|
||||
| `src/lib/reminder-job.ts`, `src/lib/mailer.ts` | Versand und Zeitplan |
|
||||
| `src/lib/mail-settings.ts`, `src/lib/secret-box.ts` | SMTP aus .env oder App (`app_setting`), Passwort verschluesselt |
|
||||
| `src/lib/export.ts` | Export und Import des gesamten Bestands |
|
||||
| `src/lib/money.ts`, `src/lib/format.ts` | Cent-Umrechnung, dd.MM.yyyy |
|
||||
| `src/components/layout/more-items.ts` | Eintraege der Seite „Mehr" (Konto, Design, Stammdaten), auch Titel der Verwaltungsseiten |
|
||||
| `src/lib/collector-choice.ts` | Aktiver Sammler: `?sammler=` vor Cookie aus dem Umschalter „Fuer wen?" |
|
||||
| `src/lib/link-import.ts` | Set-Daten aus Link und Seite lesen (rein, testbar) |
|
||||
| `src/lib/safe-fetch.ts`, `src/lib/net-guard.ts` | Abruf fremder URLs nur an oeffentliche Adressen |
|
||||
| `src/lib/design.ts`, `src/app/designs.css` | Designs: Namen und Cookie bzw. Tokens je `data-design` |
|
||||
| `src/components/brick/` | Noppen, Logo, Platzhalter fuer Sets ohne Bild |
|
||||
|
||||
### Designs
|
||||
|
||||
Das Design steht als `data-design` am `<html>` (Cookie `bricktrack-design`,
|
||||
pro Geraet, Standard `klemmbaustein`). Komponenten benutzen nur Tokens
|
||||
(`bg-primary`, `border-edge`, `--edge-w`, `--lift`, `bg-tone-1` ...), nie feste
|
||||
Farben - sonst stimmt ein Design nicht mehr. Karten mit Noppen: `<Studs />`
|
||||
neben die Karte in einen `relative`-Wrapper, die Karte selbst mit `brick`.
|
||||
|
||||
## Befehle
|
||||
|
||||
@@ -114,8 +133,16 @@ Diese Bedingung steht an genau einer Stelle: `src/lib/ownership.ts`.
|
||||
|
||||
### Besitz
|
||||
|
||||
Mehrere **Sammler** teilen sich den Katalog, nicht den Besitz: `collector`
|
||||
haelt Name und Farbe, `collection_item.collector_id` und
|
||||
`wishlist.collector_id` ordnen zu. Solange nur ein Sammler existiert, blendet
|
||||
die Oberflaeche das Thema vollstaendig aus - Umschalter, Abfragen und Badges
|
||||
erscheinen erst ab dem zweiten.
|
||||
|
||||
| Tabelle | Zweck | Wichtige Felder |
|
||||
|---|---|---|
|
||||
| `collector` | ein Kind | `name`, `color`, `birthday`, `sort_order` |
|
||||
| `price_point` | beobachteter Preis je Set | `price_cents`, `condition`, `observed_at`, `source` |
|
||||
| `collection_item` | ein besessenes Exemplar | `status` = `owned` / `built` / `disassembled` / `lent_out` / `sold`, `completeness`, `condition`, `location_id`, `acquired_*`, `gifted_by` |
|
||||
| `location` | Aufbewahrungsort, hierarchisch | `parent_location_id` |
|
||||
| `missing_part` | Fehlteile je Exemplar | `part_number`, `color_name`, `quantity`, `resolved` |
|
||||
@@ -173,11 +200,13 @@ Session.
|
||||
nichts preis ausser den Wunschlisten-Daten - keine Nutzernamen,
|
||||
keine Sammlungsdaten, keine IDs anderer Objekte.
|
||||
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`.
|
||||
Rate-Limiting auf den Login-Endpunkten.
|
||||
6. Der Export enthaelt **keine** Konten, Passwoerter oder Passkeys - und
|
||||
keine Reservierungen oder Teilen-Token. Das Backup ist `./data`, nicht der
|
||||
keine Reservierungen, Teilen-Token oder `app_setting` (SMTP-Zugang). Das Backup ist `./data`, nicht der
|
||||
Export.
|
||||
7. Der Service Worker beantwortet nur GET-Anfragen aus dem Cache und niemals
|
||||
`/api/auth`, `/api/export` oder den Healthcheck. Beim Abmelden wird der
|
||||
@@ -190,7 +219,9 @@ Session.
|
||||
- **Phase 2 (Komfort)** - Rebrickable-Import, Barcode-Scanner, Fehlteile,
|
||||
Einladungen, Export/Import, PWA. *Fertig in 0.2.0.*
|
||||
- **Phase 3 (Nice to have)** - mehrere Sammler, Preisbeobachtung,
|
||||
E-Mail-Erinnerungen, Dublettenpruefung.
|
||||
E-Mail-Erinnerungen, Dublettenpruefung. *Fertig in 0.3.0.*
|
||||
- **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`.
|
||||
|
||||
|
||||
@@ -144,16 +144,16 @@ tut.
|
||||
|
||||
## 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
|
||||
eigener Passkey – Handy, Tablet, Laptop.
|
||||
|
||||
## 8. Loslegen
|
||||
|
||||
1. **Verwaltung → Hersteller**: Lego, Playmobil, Cobi … anlegen.
|
||||
2. **Verwaltung → Themen**: „City", „Technic", „Novelmore" …
|
||||
3. **Verwaltung → Standorte**: „Regal Kinderzimmer", „Kiste Keller 3" …
|
||||
4. **Katalog → Set anlegen**: Setnummer, Name, Bild.
|
||||
1. **Mehr → Hersteller**: Lego, Playmobil, Cobi … anlegen.
|
||||
2. **Mehr → Themen**: „City", „Technic", „Novelmore" …
|
||||
3. **Mehr → Standorte**: „Regal Kinderzimmer", „Kiste Keller 3" …
|
||||
4. **+ in der Navigation → Von Hand**: Setnummer, Name, Bild.
|
||||
5. Auf der Set-Seite **„Zur Sammlung hinzufügen"**, wenn das Set da ist.
|
||||
6. **Katalog → Filter „Fehlt noch"**: Kachel lange drücken, mehrere auswählen,
|
||||
**„Zur Wunschliste hinzufügen"**.
|
||||
|
||||
@@ -29,6 +29,9 @@ du per Link mit Familie und Freunden teilst. Die reservieren dort ihr Geschenk
|
||||
| **Fotos** | Eigene Bilder je Set – direkt aus der Handykamera |
|
||||
| **Wunschlisten** | Mehrfachauswahl aus den fehlenden Sets, Priorität, Reihenfolge, Termin |
|
||||
| **Teilen** | Öffentlicher Link ohne Anmeldung, Reservierung mit Namen, Storno-Link, Überraschungsmodus |
|
||||
| **Mehrere Kinder** | Gemeinsamer Katalog, getrennte Sammlungen und Wunschlisten – sichtbar erst ab dem zweiten Sammler |
|
||||
| **Preise** | Notierte Preise je Set, Wertentwicklung gegen die UVP |
|
||||
| **Erinnerungen** | E-Mail, wenn ein Termin näher rückt und noch Wünsche offen sind (SMTP optional) |
|
||||
| **Anmeldung** | E-Mail und Passwort oder Passkey (WebAuthn), mehrere Geräte je Konto |
|
||||
|
||||
Mobil zuerst gebaut: Die App wird im Laden und im Kinderzimmer benutzt, nicht
|
||||
@@ -81,7 +84,9 @@ Alle Werte kommen aus `.env` (Vorlage: `.env.example`).
|
||||
| `TZ` | nein | `Europe/Berlin` | Zeitzone des Containers. |
|
||||
| `BIND_ADDRESS` | nein | `127.0.0.1` | Auf welcher Host-Adresse der Container lauscht. `0.0.0.0`, wenn der Proxy auf einem anderen Host läuft. |
|
||||
| `HOST_PORT` | nein | `3000` | Port auf dem Host, falls 3000 belegt ist. |
|
||||
| `REBRICKABLE_API_KEY` | nein | – | Ab Version 0.2.0: Lego-Import. Ohne Key läuft alles manuell. |
|
||||
| `REBRICKABLE_API_KEY` | nein | – | Lego-Import. Ohne Key läuft alles manuell. |
|
||||
| `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`, `SMTP_FROM` | nein | – | Mailversand für Wunschlisten-Erinnerungen. Ohne `SMTP_HOST` und `SMTP_FROM` ist die Funktion aus. |
|
||||
| `REMINDER_DAYS_BEFORE` | nein | `14` | Wie viele Tage vor dem Termin erinnert wird. |
|
||||
|
||||
### Reverse Proxy
|
||||
|
||||
@@ -208,6 +213,10 @@ Ein verkauftes Exemplar zählt also nicht mehr als Besitz – das Set taucht
|
||||
wieder unter „fehlt noch" auf und darf zurück auf eine Wunschliste. Diese
|
||||
Regel steht an genau einer Stelle im Code: `src/lib/ownership.ts`.
|
||||
|
||||
Gibt es mehrere **Sammler**, bekommt die Frage ein „für wen": Der Katalog ist
|
||||
gemeinsam, der Besitz nicht. Ein Set, das nur das Geschwisterkind hat, fehlt
|
||||
dem anderen weiterhin.
|
||||
|
||||
Das vollständige Modell inklusive aller Tabellen: [CLAUDE.md](CLAUDE.md#datenmodell).
|
||||
|
||||
## Sicherheit
|
||||
|
||||
@@ -27,7 +27,12 @@ Kinderzimmer, nicht für den Schreibtisch.
|
||||
13. [Noch ein Konto für die Familie](#13-einladungen)
|
||||
14. [Daten sichern, umziehen, auswerten](#14-daten)
|
||||
15. [Auf den Startbildschirm legen](#15-startbildschirm)
|
||||
16. [Antworten auf die zwei wichtigen Fragen](#16-die-zwei-fragen)
|
||||
16. [Zwei Kinder, ein Katalog](#16-mehrere-sammler)
|
||||
17. [Was ein Set heute wert ist](#17-preise)
|
||||
18. [Erinnerung vor dem Termin](#18-erinnerungen)
|
||||
19. [Schutz vor doppelten Einträgen](#19-dubletten)
|
||||
20. [Antworten auf die zwei wichtigen Fragen](#20-die-zwei-fragen)
|
||||
21. [Ein anderes Design](#21-design)
|
||||
|
||||
---
|
||||
|
||||
@@ -48,17 +53,34 @@ gleich danach unter „Konto" (siehe [Abschnitt 10](#10-passkeys-einrichten)).
|
||||
|
||||

|
||||
|
||||
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 |
|
||||
| **Fehlende Sets** | Der direkte Weg zu allem, was noch fehlt |
|
||||
| **Sammlung** | Was tatsächlich da ist |
|
||||
| **Wunschlisten** | Zum Teilen mit Familie und Freunden |
|
||||
| **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 |
|
||||
| **Teile und Wert** | Alle verbauten Teile und was die Sammlung heute wert ist |
|
||||
| **Nächster Termin** | Die nächste Wunschliste mit Datum, wie viele Tage noch bleiben und wie viel offen ist |
|
||||
| **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
|
||||
erreichbar.
|
||||
erreichbar: **Start, Katalog, Sammlung, Wünsche** und in der Mitte der rote
|
||||
**+-Stein**. Der aktive Bereich sitzt auf einer kleinen Platte.
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
@@ -66,8 +88,9 @@ erreichbar.
|
||||
|
||||

|
||||
|
||||
Bevor du Sets anlegst, brauchst du mindestens einen **Hersteller**. Zu finden
|
||||
über das Konto-Menü oben rechts → **Verwaltung**.
|
||||
Bevor du Sets von Hand anlegst, brauchst du mindestens einen **Hersteller**.
|
||||
Zu finden unter **Mehr → Hersteller**. Der Lego-Import und die Übernahme aus
|
||||
einem Link legen fehlende Hersteller selbst an.
|
||||
|
||||
Drei Arten von Stammdaten:
|
||||
|
||||
@@ -109,7 +132,7 @@ Die Kacheln zeigen auf einen Blick:
|
||||
- **„Auf Wunschliste"** – steht auf mindestens einer Liste
|
||||
- Setnummer, Hersteller, Thema, Jahr und UVP
|
||||
|
||||
Mit **„Set anlegen"** oben rechts kommt ein neues Set dazu. Pflicht sind nur
|
||||
Über das **+** in der Navigation kommt ein neues Set dazu. Pflicht sind nur
|
||||
Hersteller, Setnummer und Name; alles andere kannst du nachtragen. Ein Bild
|
||||
lädst du entweder hoch (es wird verkleinert und lokal gespeichert) oder du
|
||||
hinterlegst eine Bild-URL.
|
||||
@@ -278,7 +301,7 @@ zweites Mal.
|
||||
|
||||

|
||||
|
||||
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
|
||||
Anmeldeseite ein Tipp auf „Mit Passkey anmelden" – auf vielen Geräten schlägt
|
||||
der Browser den Passkey sogar von selbst vor.
|
||||
@@ -298,14 +321,41 @@ gib ihnen sprechende Namen.
|
||||
|
||||
## 11. Import und Scanner
|
||||
|
||||
Sets von Hand einzutippen ist der langsame Weg. Für Lego gibt es zwei
|
||||
schnellere.
|
||||
Sets von Hand einzutippen ist der langsame Weg. Es gibt drei schnellere.
|
||||
|
||||
### Aus einem Link übernehmen – für alle Hersteller
|
||||
|
||||
Über **+ → Aus Link** landest du im Feld „Aus einem Link übernehmen".
|
||||
Dort passt jeder Link zu einem Set hinein: Amazon, idealo, geizhals, der Shop
|
||||
des Herstellers oder eine Set-Datenbank wie
|
||||
[Brickfact](https://brickfact.com) (Lego) und
|
||||
[Merlins Bricks](https://www.merlinsbricks.com) (BlueBrixx, Cobi und viele
|
||||
mehr). „Übernehmen" füllt das Formular darunter aus – **gespeichert wird erst,
|
||||
wenn du „Set anlegen" drückst**. Ein Blick auf Name und Nummer lohnt sich,
|
||||
denn Shops schreiben gern das halbe Sortiment in den Titel.
|
||||
|
||||
Was dabei passiert:
|
||||
|
||||
1. Bricktrack liest die Seite und übernimmt Name, Setnummer, EAN, Bild,
|
||||
Teile und Jahr, soweit sie dort stehen.
|
||||
2. Manche Shops lassen sich nicht abfragen. Dann reicht oft schon der Link
|
||||
selbst: In `amazon.de/LEGO-60398-Familienhaus-…` stecken Hersteller,
|
||||
Nummer und Name.
|
||||
3. Mit Hersteller und Nummer ergänzt Bricktrack den Rest: Lego über
|
||||
Rebrickable (mit API-Key), BlueBrixx und Cobi über Merlins Bricks.
|
||||
|
||||
Das Bild wird beim Speichern lokal abgelegt, die Quelle steht in den Notizen.
|
||||
Gibt es den Hersteller noch nicht, wird er angelegt. Steht das Set schon im
|
||||
Katalog, kommt statt des Formulars ein Link zum vorhandenen Eintrag.
|
||||
|
||||
> Bricktrack ruft dafür nur öffentliche Adressen ab. Links ins eigene Netz –
|
||||
> Router, NAS, andere Container – werden abgewiesen.
|
||||
|
||||
### Lego-Import über die Setnummer
|
||||
|
||||

|
||||
|
||||
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
|
||||
Thema stehen drin. Das Bild wird **lokal gespeichert**, funktioniert also auch
|
||||
weiter, wenn die Quelle es irgendwann austauscht. Fehlen Hersteller oder
|
||||
@@ -358,7 +408,7 @@ der Sammlung stimmt damit, ohne dass du daran denken musst.
|
||||
|
||||
Die Registrierung ist nach dem ersten Konto geschlossen – sonst könnte sich
|
||||
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
|
||||
und Gültigkeit.
|
||||
@@ -376,7 +426,7 @@ zusätzlich einladen und Daten exportieren.
|
||||
|
||||

|
||||
|
||||
Unter **Verwaltung → Daten** gibt es drei Downloads:
|
||||
Unter **Mehr → Export & Import** gibt es drei Downloads:
|
||||
|
||||
| | Wofür |
|
||||
|---|---|
|
||||
@@ -419,7 +469,119 @@ den Zwischenspeicher, damit auf einem geteilten Gerät nichts lesbar bleibt.
|
||||
|
||||
---
|
||||
|
||||
## 16. Die zwei Fragen
|
||||
## 16. Mehrere Sammler
|
||||
|
||||

|
||||
|
||||
Kommt ein zweites Kind dazu, wird aus „hat er das schon?" die Frage „hat
|
||||
**er** das schon?". Genau dafür gibt es Sammler.
|
||||
|
||||
Der **Katalog bleibt gemeinsam**: Ein Set existiert einmal, unabhängig davon,
|
||||
wer es besitzt. **Sammlung und Wunschlisten** gehören dagegen je einem
|
||||
Sammler. Ein Set, das nur die Schwester hat, fällt für den Bruder wieder unter
|
||||
„fehlt noch" – und darf auf seine Wunschliste.
|
||||
|
||||
> **Solange es nur einen Sammler gibt, siehst du davon nichts.** Kein
|
||||
> Umschalter, keine Abfrage, keine Farben. Die App bleibt genauso einfach wie
|
||||
> vorher. Erst der zweite Sammler schaltet all das frei – und dann willst du
|
||||
> es auch.
|
||||
|
||||
Anlegen unter **Mehr → Sammler**. Jeder bekommt einen Namen und eine
|
||||
Farbe zur Unterscheidung.
|
||||
|
||||

|
||||
|
||||
Danach steht oben unter dem Logo der Umschalter **„Für wen?"**. „Alle"
|
||||
zeigt den Haushalt als Ganzes, ein Name zeigt genau dessen Sicht – auf der
|
||||
Startseite, im Katalog und in der Sammlung, inklusive der Frage, was
|
||||
**diesem** Kind noch fehlt. Die Wahl merkt sich jedes Gerät: Das Tablet im
|
||||
Kinderzimmer öffnet direkt bei Max.
|
||||
|
||||
Beim Hinzufügen zur Sammlung fragt die App jetzt, wer das Set bekommt. Und
|
||||
beim Anlegen einer Wunschliste, für wen sie ist: Das bestimmt, wessen Sammlung
|
||||
als „hat er schon" zählt und wann ein Wunsch automatisch als erfüllt gilt.
|
||||
|
||||
> Die Umstellung einer bestehenden Instanz macht nichts kaputt: Beim Update
|
||||
> entsteht ein Sammler namens „Sammlung", dem alles Vorhandene gehört.
|
||||
> Umbenennen unter **Mehr → Sammler**, fertig.
|
||||
|
||||
---
|
||||
|
||||
## 17. Preise
|
||||
|
||||

|
||||
|
||||
Auf jeder Set-Seite gibt es **„Preis notieren"**: Betrag, Zustand (neu oder
|
||||
gebraucht), wann und wo gesehen. Daraus wird eine Reihe – und damit eine
|
||||
Antwort auf die eigentlich interessante Frage: Was hat das Set seit dem Kauf
|
||||
gemacht?
|
||||
|
||||
- **Ursprünglich** ist die UVP des Sets, sonst der älteste notierte Preis.
|
||||
- **Aktuell** ist der neueste notierte Preis.
|
||||
- **Entwicklung** ist die Differenz in Prozent.
|
||||
|
||||
In der Sammlung wird die Kachel „Wert nach UVP" automatisch zu **„Aktueller
|
||||
Wert"**, sobald irgendwo ein Preis notiert ist.
|
||||
|
||||
> Es wird **kein** Preisdienst abgefragt. Was hier steht, hast du selbst
|
||||
> eingetragen – das ist ehrlicher als eine Zahl, deren Herkunft niemand kennt,
|
||||
> und es geht nichts nach draußen.
|
||||
|
||||
---
|
||||
|
||||
## 18. Erinnerungen
|
||||
|
||||

|
||||
|
||||
Wenn ein Wunschlisten-Termin näher rückt und **noch Wünsche offen sind**,
|
||||
schickt Bricktrack dir eine E-Mail – standardmäßig 14 Tage vorher, höchstens
|
||||
einmal pro Woche je Liste. Ist alles reserviert, bleibt es still: eine
|
||||
Erinnerung ohne Handlungsbedarf ist nur Lärm.
|
||||
|
||||
Dafür braucht es einen Mailserver (SMTP). Den trägst du direkt in der App ein:
|
||||
**Mehr → Erinnerungen → Mailserver**. Die Angaben – Server, Port,
|
||||
Verschlüsselung, Benutzername, Passwort und Absender – findest du bei deinem
|
||||
Mail-Anbieter unter „SMTP" oder „Postausgangsserver". Ein Neustart ist nicht
|
||||
nötig.
|
||||
|
||||
Das Passwort wird verschlüsselt gespeichert und nie wieder angezeigt; zum
|
||||
Ändern einfach ein neues eintragen. Es landet auch nicht im Export.
|
||||
|
||||
> Wer lieber mit der `.env` arbeitet, kann den Mailserver weiterhin dort
|
||||
> eintragen (`SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`,
|
||||
> `SMTP_FROM`). Dann hat die `.env` Vorrang, und die App zeigt die Werte nur
|
||||
> an. Wie viele Tage vorher erinnert wird, steht in `REMINDER_DAYS_BEFORE`
|
||||
> (Standard 14).
|
||||
|
||||
Unter **Mehr → Erinnerungen** kannst du außerdem die Verbindung prüfen, dir eine
|
||||
Testnachricht schicken und die Prüfung sofort auslösen, statt auf die nächste
|
||||
Stunde zu warten. Dort steht auch, welche Listen einen Termin haben und wie
|
||||
viel dort noch offen ist.
|
||||
|
||||
Ohne Mailserver ist die Funktion vollständig aus – alles andere funktioniert
|
||||
unverändert.
|
||||
|
||||
---
|
||||
|
||||
## 19. Dubletten
|
||||
|
||||
Beim Anlegen eines neuen Sets schaut Bricktrack nach, ob es das schon gibt:
|
||||
|
||||
- **gleiche Setnummer beim gleichen Hersteller** – dann ist es dasselbe Set,
|
||||
- **sehr ähnlicher Name** – meist ein Tippfehler oder eine andere Schreibweise,
|
||||
- **gleiche Nummer bei einem anderen Hersteller** – meist der falsche
|
||||
Hersteller im Formular.
|
||||
|
||||
Passt etwas, erscheint ein Hinweis mit Link zu den Verdächtigen. Von dort
|
||||
kommst du direkt zum vorhandenen Eintrag – oder legst mit „Trotzdem anlegen"
|
||||
weiter an, wenn es wirklich ein anderes Set ist.
|
||||
|
||||
Die Erkennung ist bewusst zurückhaltend eingestellt: Tippfehler schlagen an,
|
||||
zwei echte Sets desselben Themas nicht.
|
||||
|
||||
---
|
||||
|
||||
## 20. Die zwei Fragen
|
||||
|
||||
Dafür ist die ganze App gebaut:
|
||||
|
||||
@@ -428,13 +590,35 @@ Dafür ist die ganze App gebaut:
|
||||
|
||||
**„Welche Sets hat er noch nicht?"**
|
||||
→ Katalog, Filter „Fehlt noch". Von dort in zwei Handgriffen auf eine
|
||||
Wunschliste und per Link zu Oma.
|
||||
Wunschliste und per Link zu Oma. Bei mehreren Kindern beantwortet der
|
||||
Umschalter oben die Frage für jedes einzeln.
|
||||
|
||||
Im Laden vor dem Regal: Scanner öffnen, Strichcode einlesen. Steht ein grüner
|
||||
Haken dran, habt ihr es schon.
|
||||
|
||||
---
|
||||
|
||||
## 21. Design
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||
- **Filter stecken in der URL.** Ein Lesezeichen auf
|
||||
|
||||
@@ -45,6 +45,20 @@ Commit-Spalte verweist auf den Stand am Ende des Schritts.
|
||||
|
||||
**Summe Phase 2: 0:55**
|
||||
|
||||
## Phase 3 – Nice to have (v0.3.0)
|
||||
|
||||
### 07.09.2026
|
||||
|
||||
| Von | Bis | Dauer | Schritt | Ergebnis | Commit |
|
||||
|---|---|---|---|---|---|
|
||||
| 08:35 | 09:00 | 0:25 | Mehrere Sammler | Neue Tabelle, Migration mit Nachziehen der Bestandsdaten, Besitzlogik um eine Dimension erweitert, Umschalter in Katalog und Sammlung, Zuordnung in Sammlungseintrag und Wunschliste | `c23b754` |
|
||||
| 09:00 | 09:13 | 0:13 | Dublettenprüfung | Ähnlichkeitsvergleich mit konservativer Schwelle, Warndialog mit Links, E2E-Test | `87131b8` |
|
||||
| 09:13 | 09:17 | 0:04 | Preisbeobachtung | Preisreihe je Set, Wertentwicklung gegen die UVP, Kachel „Aktueller Wert" | `91fb66b` |
|
||||
| 09:17 | 09:22 | 0:05 | Erinnerungen | SMTP optional, stündliche Prüfung, Sperrfrist je Liste, Verwaltungsseite mit Testfunktionen | `0d08df0` |
|
||||
| 09:22 | 09:50 | 0:28 | Verifikation und Dokumentation | Docker-Build, Smoke-Test, Screenshots, Changelog 0.3.0, Anleitung | folgt |
|
||||
|
||||
**Summe Phase 3: 1:15**
|
||||
|
||||
---
|
||||
|
||||
## Notizen und Entscheidungen
|
||||
@@ -120,6 +134,21 @@ Commit-Spalte verweist auf den Stand am Ende des Schritts.
|
||||
Konfliktregeln für jede Tabelle und wäre im Ergebnis nicht vorhersagbar.
|
||||
Der Import ersetzt deshalb – dafür mit getipptem `ERSETZEN` als Bremse.
|
||||
|
||||
### Phase 3
|
||||
|
||||
- **Migration mit Bestandsdaten**: Die generierte SQL-Datei wurde von Hand um
|
||||
drei Anweisungen ergänzt, die einen Sammler anlegen und alles Vorhandene
|
||||
zuordnen. Ohne das wären nach dem Update alle Sammlungseinträge ohne
|
||||
Besitzer gewesen.
|
||||
- **Instrumentation und native Module**: Der Zeitplan für Erinnerungen liess
|
||||
sich nicht aus Next' `instrumentation.ts` starten – die Datei wird auch für
|
||||
die Edge-Runtime übersetzt, und das native SQLite-Modul bricht dort den
|
||||
Build. Der Zeitplan startet jetzt aus dem Healthcheck heraus, den der
|
||||
Container ohnehin alle 30 Sekunden aufruft.
|
||||
- **BrickLink bewusst weggelassen**: OAuth1 mit einer pro Nutzer registrierten
|
||||
Anwendung ist viel Apparat für eine Zahl, die man in zehn Sekunden eintippt.
|
||||
Im Plan war der Punkt als optional vermerkt.
|
||||
|
||||
### Beim Testen gefunden und behoben
|
||||
|
||||
- Eine spontan aus dem Katalog angelegte Wunschliste war **nicht teilbar**:
|
||||
@@ -130,3 +159,8 @@ Commit-Spalte verweist auf den Stand am Ende des Schritts.
|
||||
`required` des Eingabefeldes.
|
||||
- In der Mehrfachauswahl verdeckte die Aktionsleiste die letzte Kachelreihe;
|
||||
das Raster bekommt im Auswahlmodus zusätzliches Polster nach unten.
|
||||
- Die Sperrfrist der Erinnerungen rechnete mit umgedrehtem Vorzeichen und
|
||||
hätte jede Erinnerung dauerhaft unterdrückt. Der Test dafür war zuerst da.
|
||||
- Der Screenshot-Lauf wählte plötzlich die falschen Kacheln aus: Der neue
|
||||
Sammler-Umschalter benutzt ebenfalls `aria-pressed`. Die Auswahlkacheln
|
||||
tragen jetzt ein eigenes `data-set-card`.
|
||||
|
||||
@@ -46,12 +46,35 @@ Laeuft vollstaendig im Docker-Stack.
|
||||
Sammlung zusaetzlich als CSV
|
||||
- [x] PWA: Manifest, Service Worker, Katalog offline lesbar
|
||||
|
||||
## Phase 3 - Nice to have (Ziel: v0.3.0)
|
||||
## Phase 3 - Nice to have (v0.3.0, abgeschlossen am 07.09.2026)
|
||||
|
||||
- [ ] Mehrere Sammler mit eigener Sammlung und eigenen Wunschlisten,
|
||||
- [x] Mehrere Sammler mit eigener Sammlung und eigenen Wunschlisten,
|
||||
gemeinsamer Katalog
|
||||
- [ ] Preisbeobachtung und Wertentwicklung (manuell gepflegt,
|
||||
optional BrickLink-Preisfuehrer)
|
||||
- [ ] E-Mail-Erinnerung (SMTP), wenn ein Wunschlisten-Termin naeher
|
||||
- [x] Preisbeobachtung und Wertentwicklung (manuell gepflegt).
|
||||
Der BrickLink-Preisfuehrer war als optional vermerkt und wurde
|
||||
bewusst weggelassen: er verlangt OAuth1 mit einer pro Nutzer
|
||||
registrierten Anwendung - viel Apparat fuer eine Zahl, die sich in
|
||||
zehn Sekunden eintippen laesst. Die Datenstruktur ist offen dafuer.
|
||||
- [x] E-Mail-Erinnerung (SMTP), wenn ein Wunschlisten-Termin naeher
|
||||
rueckt und Eintraege unreserviert sind
|
||||
- [ ] Dublettenpruefung beim Anlegen von Sets
|
||||
- [x] Dublettenpruefung beim Anlegen von Sets
|
||||
|
||||
## Design und Sicherheit (v0.4.0, abgeschlossen am 25.10.2026)
|
||||
|
||||
- [x] Abhängigkeiten mit bekannten Sicherheitslücken aktualisiert
|
||||
|
||||
- [x] Designauswahl pro Gerät, sechs Designs, Standard „Klemmbaustein"
|
||||
- [x] Platzhalter für Sets ohne Bild, neues Logo, Schriften lokal
|
||||
- [x] Startseite als Überblick mit Steinreihe und nächstem Termin
|
||||
- [ ] Filter im Katalog als Reiter statt Auswahlfelder
|
||||
- [ ] Scan-Button mittig in der unteren Navigation
|
||||
|
||||
## Danach
|
||||
|
||||
Phase 1 bis 3 sind umgesetzt. Was noch offen ist, steht hier - ohne Termin
|
||||
und ohne Anspruch auf Vollstaendigkeit:
|
||||
|
||||
- [ ] BrickLink-Preisfuehrer anbinden, falls der Aufwand sich doch lohnt
|
||||
- [ ] Mehrere Fotos je Sammlungseintrag (bisher nur je Set)
|
||||
- [ ] Anleitungen im PDF-Viewer durchsuchen
|
||||
- [ ] Statistik ueber die Zeit: was kam wann dazu
|
||||
|
||||
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 187 KiB |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 115 KiB |
|
Before Width: | Height: | Size: 288 KiB After Width: | Height: | Size: 445 KiB |
|
Before Width: | Height: | Size: 201 KiB After Width: | Height: | Size: 331 KiB |
|
Before Width: | Height: | Size: 209 KiB After Width: | Height: | Size: 371 KiB |
|
Before Width: | Height: | Size: 236 KiB After Width: | Height: | Size: 490 KiB |
|
Before Width: | Height: | Size: 185 KiB After Width: | Height: | Size: 350 KiB |
|
Before Width: | Height: | Size: 184 KiB After Width: | Height: | Size: 331 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 293 KiB After Width: | Height: | Size: 497 KiB |
|
Before Width: | Height: | Size: 155 KiB After Width: | Height: | Size: 234 KiB |
|
Before Width: | Height: | Size: 244 KiB After Width: | Height: | Size: 426 KiB |
|
Before Width: | Height: | Size: 301 KiB After Width: | Height: | Size: 248 KiB |
|
Before Width: | Height: | Size: 294 KiB After Width: | Height: | Size: 498 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 302 KiB |
|
Before Width: | Height: | Size: 211 KiB After Width: | Height: | Size: 383 KiB |
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 245 KiB |
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 259 KiB |
|
Before Width: | Height: | Size: 257 KiB After Width: | Height: | Size: 310 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 380 KiB |
|
After Width: | Height: | Size: 345 KiB |
|
After Width: | Height: | Size: 345 KiB |
|
After Width: | Height: | Size: 154 KiB |
|
After Width: | Height: | Size: 451 KiB |
|
After Width: | Height: | Size: 271 KiB |
@@ -0,0 +1,28 @@
|
||||
CREATE TABLE `collector` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`color` text DEFAULT 'blue' NOT NULL,
|
||||
`birthday` integer,
|
||||
`notes` text,
|
||||
`sort_order` integer DEFAULT 0 NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `collector_sort_idx` ON `collector` (`sort_order`,`name`);--> statement-breakpoint
|
||||
ALTER TABLE `collection_item` ADD `collector_id` text REFERENCES collector(id);--> statement-breakpoint
|
||||
CREATE INDEX `collection_item_collector_idx` ON `collection_item` (`collector_id`);--> statement-breakpoint
|
||||
ALTER TABLE `wishlist` ADD `collector_id` text REFERENCES collector(id);--> statement-breakpoint
|
||||
-- Von Hand ergaenzt: Bestandsdaten nachziehen.
|
||||
-- Vor dieser Migration gab es genau eine Sammlung. Sie bekommt einen Sammler,
|
||||
-- damit alles Vorhandene zugeordnet bleibt; umbenennen laesst er sich unter
|
||||
-- Verwaltung -> Sammler.
|
||||
INSERT INTO `collector` (`id`, `name`, `color`, `sort_order`, `created_at`, `updated_at`)
|
||||
SELECT 'c0000000-0000-4000-8000-000000000001', 'Sammlung', 'blue', 0, unixepoch(), unixepoch()
|
||||
WHERE NOT EXISTS (SELECT 1 FROM `collector`);--> statement-breakpoint
|
||||
UPDATE `collection_item`
|
||||
SET `collector_id` = (SELECT `id` FROM `collector` ORDER BY `sort_order`, `name` LIMIT 1)
|
||||
WHERE `collector_id` IS NULL;--> statement-breakpoint
|
||||
UPDATE `wishlist`
|
||||
SET `collector_id` = (SELECT `id` FROM `collector` ORDER BY `sort_order`, `name` LIMIT 1)
|
||||
WHERE `collector_id` IS NULL;
|
||||
@@ -0,0 +1,14 @@
|
||||
CREATE TABLE `price_point` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`set_id` text NOT NULL,
|
||||
`price_cents` integer NOT NULL,
|
||||
`condition` text DEFAULT 'new' NOT NULL,
|
||||
`source` text,
|
||||
`observed_at` integer NOT NULL,
|
||||
`note` text,
|
||||
`created_at` integer NOT NULL,
|
||||
FOREIGN KEY (`set_id`) REFERENCES `set`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
CONSTRAINT "price_point_positive" CHECK("price_point"."price_cents" >= 0)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `price_point_set_idx` ON `price_point` (`set_id`,`observed_at`);
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `wishlist` ADD `last_reminder_at` integer;
|
||||
@@ -0,0 +1,5 @@
|
||||
CREATE TABLE `app_setting` (
|
||||
`key` text PRIMARY KEY NOT NULL,
|
||||
`value` text NOT NULL,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
@@ -8,6 +8,34 @@
|
||||
"when": 1788726503038,
|
||||
"tag": "0000_rapid_gargoyle",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "6",
|
||||
"when": 1788763704015,
|
||||
"tag": "0001_lumpy_bill_hollister",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "6",
|
||||
"when": 1788765229376,
|
||||
"tag": "0002_aspiring_whiplash",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "6",
|
||||
"when": 1788765478965,
|
||||
"tag": "0003_powerful_sally_floyd",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "6",
|
||||
"when": 1792963951232,
|
||||
"tag": "0004_modern_agent_brand",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -19,6 +19,9 @@ function shotPath(name: string) {
|
||||
async function shoot(page: Page, name: string) {
|
||||
// Let fonts and images settle so the picture is not half-rendered.
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -49,6 +52,17 @@ test('Screenshots fuer die Anleitung', async ({ page, browser }) => {
|
||||
await visit(page, '/');
|
||||
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 -------------------------------------------------------
|
||||
await visit(page, '/verwaltung/hersteller');
|
||||
await expect(page.getByText('Lego', { exact: true })).toBeVisible();
|
||||
@@ -66,7 +80,8 @@ test('Screenshots fuer die Anleitung', async ({ page, browser }) => {
|
||||
|
||||
await page.getByRole('button', { name: 'Auswählen' }).click();
|
||||
await expect(page.getByText('Tippe die Sets an')).toBeVisible();
|
||||
const cards = page.locator('button[aria-pressed]');
|
||||
// data-set-card, not aria-pressed: the collector switcher uses that too.
|
||||
const cards = page.locator('button[data-set-card]');
|
||||
await cards.nth(0).click();
|
||||
await cards.nth(1).click();
|
||||
await expect(page.getByText('2 ausgewählt')).toBeVisible();
|
||||
@@ -116,6 +131,8 @@ test('Screenshots fuer die Anleitung', async ({ page, browser }) => {
|
||||
await visitor.getByRole('button', { name: 'Reservieren' }).click();
|
||||
await expect(visitor.getByText('Reservierung gespeichert')).toBeVisible();
|
||||
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 visitorContext.close();
|
||||
|
||||
@@ -128,6 +145,18 @@ test('Screenshots fuer die Anleitung', async ({ page, browser }) => {
|
||||
await expect(page.getByText('Passkeys', { exact: true })).toBeVisible();
|
||||
await shoot(page, '16-konto-passkeys');
|
||||
|
||||
// The same start page in the dark design, then back to the default.
|
||||
await page.getByText('Nachtblau').click();
|
||||
await expect(page.locator('html')).toHaveAttribute('data-design', 'nacht');
|
||||
// The look switches at once; the cookie follows with the server action.
|
||||
await page.waitForLoadState('networkidle');
|
||||
await visit(page, '/');
|
||||
await expect(page.locator('html')).toHaveAttribute('data-design', 'nacht');
|
||||
await shoot(page, '25-design-nachtblau');
|
||||
await visit(page, '/konto');
|
||||
await page.getByText('Klemmbaustein').click();
|
||||
await expect(page.locator('html')).toHaveAttribute('data-design', 'klemmbaustein');
|
||||
|
||||
// --- 12. Lego import ------------------------------------------------------
|
||||
// The screenshot container runs with a placeholder API key so the manual
|
||||
// shows the form, not the "not configured" hint. Nothing is submitted.
|
||||
@@ -160,4 +189,36 @@ test('Screenshots fuer die Anleitung', async ({ page, browser }) => {
|
||||
await visit(page, '/verwaltung/daten');
|
||||
await expect(page.getByText('Alles als ZIP')).toBeVisible();
|
||||
await shoot(page, '20-daten');
|
||||
|
||||
// --- 16. collectors -------------------------------------------------------
|
||||
await visit(page, '/verwaltung/sammler');
|
||||
await expect(page.getByText('Der Katalog ist gemeinsam')).toBeVisible();
|
||||
await shoot(page, '21-sammler');
|
||||
|
||||
// The switcher only exists because the seed created two collectors.
|
||||
await visit(page, '/katalog?besitz=fehlt');
|
||||
await expect(page.getByRole('radio', { name: /Max/ })).toBeVisible();
|
||||
await page.getByRole('radio', { name: /Max/ }).click();
|
||||
await expect(page.getByText('Sets, die Max noch nicht hat')).toBeVisible();
|
||||
await shoot(page, '22-sammler-filter');
|
||||
// Back to everybody, so the following pictures show the whole family.
|
||||
await page.getByRole('radio', { name: /Alle/ }).click();
|
||||
await expect(page.getByText('Sets, die noch nicht in der Sammlung sind')).toBeVisible();
|
||||
|
||||
// --- 17. prices -----------------------------------------------------------
|
||||
await visit(page, '/katalog?q=60398');
|
||||
await page.getByText('Familienhaus und Elektroauto').first().click();
|
||||
await expect(page.getByText('Preis und Wertentwicklung')).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Preis notieren' }).click();
|
||||
// Role, not label: the label text carries the required marker.
|
||||
await page.getByRole('textbox', { name: 'Preis', exact: true }).fill('79,99');
|
||||
await page.getByLabel('Wo').fill('Amazon');
|
||||
await page.getByRole('button', { name: 'Speichern' }).click();
|
||||
await expect(page.getByText('Amazon')).toBeVisible();
|
||||
await shoot(page, '23-preise');
|
||||
|
||||
// --- 18. reminders --------------------------------------------------------
|
||||
await visit(page, '/verwaltung/erinnerungen');
|
||||
await expect(page.getByText('Erinnerungen').first()).toBeVisible();
|
||||
await shoot(page, '24-erinnerungen');
|
||||
});
|
||||
|
||||
@@ -81,6 +81,30 @@ test('Registrieren, Set anlegen, Wunschliste teilen und reservieren', async ({ p
|
||||
await visitorContext.close();
|
||||
});
|
||||
|
||||
test('Dublettenpruefung warnt vor einem fast gleichen Set', async ({ page }) => {
|
||||
await page.goto('/anmelden');
|
||||
await page.getByLabel('E-Mail').fill('test@bricktrack.local');
|
||||
await page.getByLabel('Passwort').fill('smoketest-passwort');
|
||||
await page.getByRole('button', { name: 'Anmelden', exact: true }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Hallo Florian' })).toBeVisible();
|
||||
|
||||
// Dasselbe Set noch einmal, mit Tippfehler im Namen und anderer Nummer.
|
||||
await page.goto('/katalog/neu');
|
||||
await page.getByLabel('Setnummer').fill('60399');
|
||||
await page
|
||||
.getByRole('textbox', { name: 'Name', exact: true })
|
||||
.fill('Familienhaus und Elektroauo');
|
||||
await page.getByRole('button', { name: 'Set anlegen' }).click();
|
||||
|
||||
await expect(page.getByText('Gibt es das vielleicht schon?')).toBeVisible();
|
||||
await expect(page.getByText('Sehr ähnlicher Name')).toBeVisible();
|
||||
|
||||
// Zurueck ins Formular: nichts wurde angelegt.
|
||||
await page.getByRole('button', { name: 'Zurück zum Formular' }).click();
|
||||
await page.goto('/katalog?q=60399');
|
||||
await expect(page.getByText('0 Sets')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Einladung: zweiter Elternteil bekommt ein eigenes Konto', async ({ page, browser }) => {
|
||||
// The admin from the first test is still the only account, so log in.
|
||||
await page.goto('/anmelden');
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bricktrack",
|
||||
"version": "0.1.0",
|
||||
"version": "0.4.0",
|
||||
"private": true,
|
||||
"description": "Self-hosted Verwaltung fuer Spielzeug-Set-Sammlungen (Lego, Playmobil, Cobi, ...)",
|
||||
"packageManager": "pnpm@10.34.5",
|
||||
@@ -23,47 +23,56 @@
|
||||
"screenshots": "node scripts/screenshots-prepare.mjs && playwright test --config=playwright.screenshots.config.ts"
|
||||
},
|
||||
"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/library": "^0.23.0",
|
||||
"better-auth": "^1.7.3",
|
||||
"better-auth": "^1.7.12",
|
||||
"better-sqlite3": "^13.0.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"cn": "^0.2.6",
|
||||
"date-fns": "^4.4.0",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"drizzle-orm": "^0.45.4",
|
||||
"fflate": "^0.8.3",
|
||||
"lucide-react": "^1.41.0",
|
||||
"next": "15.5.25",
|
||||
"next": "15.5.28",
|
||||
"next-themes": "^0.4.6",
|
||||
"nodemailer": "^10.2.1",
|
||||
"pdfjs-dist": "^6.3.289",
|
||||
"radix-ui": "^1.6.7",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"server-only": "^0.0.1",
|
||||
"sharp": "^0.35.4",
|
||||
"sharp": "^0.35.5",
|
||||
"sonner": "^2.0.8",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"zod": "^4.5.4"
|
||||
"zod": "^4.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@playwright/test": "^1.63.0",
|
||||
"@playwright/test": "^1.64.1",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/better-sqlite3": "^9.6.0",
|
||||
"@types/node": "^20",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"dotenv": "^17.4.2",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"drizzle-kit": "^0.31.11",
|
||||
"esbuild": "^0.25.12",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.5.25",
|
||||
"shadcn": "^4.21.0",
|
||||
"eslint-config-next": "15.5.28",
|
||||
"shadcn": "^4.22.1",
|
||||
"tailwindcss": "^4",
|
||||
"tsx": "^4.23.13",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "^5",
|
||||
"vitest": "^5.0.0"
|
||||
"vite": "^8.3.5",
|
||||
"vitest": "^5.0.5"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
@@ -71,6 +80,20 @@
|
||||
"esbuild",
|
||||
"sharp",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,13 @@ fs.mkdirSync(path.join(dataDir, 'db'), { recursive: true });
|
||||
fs.mkdirSync(path.join(dataDir, 'files'), { recursive: true });
|
||||
console.log(`[e2e] ${dataDir} zurueckgesetzt.`);
|
||||
|
||||
// A container left behind by an aborted run would hold the port.
|
||||
try {
|
||||
execFileSync('docker', ['rm', '-f', 'bricktrack-e2e'], { stdio: 'ignore' });
|
||||
} catch {
|
||||
// Nothing to remove - the normal case.
|
||||
}
|
||||
|
||||
console.log('[e2e] Image wird gebaut (bricktrack:e2e) …');
|
||||
execFileSync('docker', ['build', '-t', 'bricktrack:e2e', '.'], { stdio: 'inherit' });
|
||||
console.log('[e2e] Image ist bereit.');
|
||||
|
||||
@@ -16,6 +16,13 @@ fs.mkdirSync(path.join(dataDir, 'db'), { recursive: true });
|
||||
fs.mkdirSync(path.join(dataDir, 'files'), { recursive: true });
|
||||
console.log(`[screenshots] ${dataDir} zurueckgesetzt.`);
|
||||
|
||||
// A container left behind by an aborted run would hold the port.
|
||||
try {
|
||||
execFileSync('docker', ['rm', '-f', 'bricktrack-shots'], { stdio: 'ignore' });
|
||||
} catch {
|
||||
// Nothing to remove - the normal case.
|
||||
}
|
||||
|
||||
console.log('[screenshots] Image wird gebaut (bricktrack:shots) …');
|
||||
execFileSync('docker', ['build', '-t', 'bricktrack:shots', '.'], { stdio: 'inherit' });
|
||||
console.log('[screenshots] Image ist bereit.');
|
||||
|
||||
@@ -33,11 +33,12 @@ type SeedSet = {
|
||||
minifigCount: number | null;
|
||||
retailPriceCents: number;
|
||||
ageRange: string;
|
||||
/** Copies owned - 0 means the set is only in the catalog. */
|
||||
/** Copies owned - absent means the set is only in the catalog. */
|
||||
owned?: {
|
||||
status: 'owned' | 'built' | 'disassembled' | 'lent_out' | 'sold';
|
||||
completeness: 'complete' | 'missing_parts' | 'unknown';
|
||||
location: string;
|
||||
collector: string;
|
||||
giftedBy?: string;
|
||||
};
|
||||
};
|
||||
@@ -59,6 +60,13 @@ const THEMES: Array<{ manufacturer: string; name: string }> = [
|
||||
|
||||
const LOCATIONS = ['Regal Kinderzimmer', 'Kiste Keller 3', 'Vitrine Wohnzimmer'];
|
||||
|
||||
// Two collectors, so the shared catalog and the separate collections are
|
||||
// visible right away. With only one, the whole concept stays hidden in the UI.
|
||||
const COLLECTORS = [
|
||||
{ name: 'Max', color: 'blue' as const },
|
||||
{ name: 'Mia', color: 'rose' as const },
|
||||
];
|
||||
|
||||
const SETS: SeedSet[] = [
|
||||
{
|
||||
manufacturer: 'Lego',
|
||||
@@ -70,7 +78,12 @@ const SETS: SeedSet[] = [
|
||||
minifigCount: 4,
|
||||
retailPriceCents: 4999,
|
||||
ageRange: '6-12',
|
||||
owned: { status: 'built', completeness: 'complete', location: 'Regal Kinderzimmer' },
|
||||
owned: {
|
||||
status: 'built',
|
||||
completeness: 'complete',
|
||||
location: 'Regal Kinderzimmer',
|
||||
collector: 'Max',
|
||||
},
|
||||
},
|
||||
{
|
||||
manufacturer: 'Lego',
|
||||
@@ -86,6 +99,7 @@ const SETS: SeedSet[] = [
|
||||
status: 'owned',
|
||||
completeness: 'missing_parts',
|
||||
location: 'Kiste Keller 3',
|
||||
collector: 'Max',
|
||||
giftedBy: 'Oma Helga',
|
||||
},
|
||||
},
|
||||
@@ -99,7 +113,12 @@ const SETS: SeedSet[] = [
|
||||
minifigCount: null,
|
||||
retailPriceCents: 999,
|
||||
ageRange: '7-14',
|
||||
owned: { status: 'disassembled', completeness: 'unknown', location: 'Kiste Keller 3' },
|
||||
owned: {
|
||||
status: 'disassembled',
|
||||
completeness: 'unknown',
|
||||
location: 'Kiste Keller 3',
|
||||
collector: 'Mia',
|
||||
},
|
||||
},
|
||||
{
|
||||
manufacturer: 'Lego',
|
||||
@@ -144,7 +163,12 @@ const SETS: SeedSet[] = [
|
||||
minifigCount: 5,
|
||||
retailPriceCents: 12999,
|
||||
ageRange: '4-10',
|
||||
owned: { status: 'built', completeness: 'complete', location: 'Regal Kinderzimmer' },
|
||||
owned: {
|
||||
status: 'built',
|
||||
completeness: 'complete',
|
||||
location: 'Regal Kinderzimmer',
|
||||
collector: 'Mia',
|
||||
},
|
||||
},
|
||||
{
|
||||
manufacturer: 'Playmobil',
|
||||
@@ -241,6 +265,59 @@ function seedThemes(manufacturers: Map<string, string>) {
|
||||
return byKey;
|
||||
}
|
||||
|
||||
function seedCollectors() {
|
||||
const byName = new Map<string, string>();
|
||||
|
||||
COLLECTORS.forEach((entry, index) => {
|
||||
const all = db
|
||||
.select({ id: schema.collector.id, name: schema.collector.name })
|
||||
.from(schema.collector)
|
||||
.all();
|
||||
|
||||
const existing = all.find((row) => row.name === entry.name);
|
||||
if (existing) {
|
||||
byName.set(entry.name, existing.id);
|
||||
return;
|
||||
}
|
||||
|
||||
// A fresh install already has the collector the migration created. Rather
|
||||
// than adding a third one next to it, the seed takes it over.
|
||||
const untouchedDefault = all.find(
|
||||
(row) =>
|
||||
row.name === 'Sammlung' &&
|
||||
!db
|
||||
.select({ id: schema.collectionItem.id })
|
||||
.from(schema.collectionItem)
|
||||
.where(eq(schema.collectionItem.collectorId, row.id))
|
||||
.get(),
|
||||
);
|
||||
|
||||
if (untouchedDefault) {
|
||||
db.update(schema.collector)
|
||||
.set({ name: entry.name, color: entry.color, sortOrder: index, updatedAt: now })
|
||||
.where(eq(schema.collector.id, untouchedDefault.id))
|
||||
.run();
|
||||
byName.set(entry.name, untouchedDefault.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const newId = id();
|
||||
db.insert(schema.collector)
|
||||
.values({
|
||||
id: newId,
|
||||
name: entry.name,
|
||||
color: entry.color,
|
||||
sortOrder: index,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.run();
|
||||
byName.set(entry.name, newId);
|
||||
});
|
||||
|
||||
return byName;
|
||||
}
|
||||
|
||||
function seedLocations() {
|
||||
const byName = new Map<string, string>();
|
||||
|
||||
@@ -270,6 +347,7 @@ function seedSets(
|
||||
manufacturers: Map<string, string>,
|
||||
themes: Map<string, string>,
|
||||
locations: Map<string, string>,
|
||||
collectors: Map<string, string>,
|
||||
) {
|
||||
const setIds = new Map<string, string>();
|
||||
|
||||
@@ -317,6 +395,7 @@ function seedSets(
|
||||
completeness: entry.owned.completeness,
|
||||
condition: 'good',
|
||||
locationId: locations.get(entry.owned.location) ?? null,
|
||||
collectorId: collectors.get(entry.owned.collector) ?? null,
|
||||
acquiredAt: new Date(entry.year, 11, 24),
|
||||
acquiredPriceCents: entry.retailPriceCents,
|
||||
giftedBy: entry.owned.giftedBy ?? null,
|
||||
@@ -330,7 +409,7 @@ function seedSets(
|
||||
return setIds;
|
||||
}
|
||||
|
||||
function seedWishlist(setIds: Map<string, string>) {
|
||||
function seedWishlist(setIds: Map<string, string>, collectors: Map<string, string>) {
|
||||
const owner = db.select({ id: schema.user.id }).from(schema.user).get();
|
||||
if (!owner) {
|
||||
console.log('[seed] Kein Nutzer vorhanden - die Beispiel-Wunschliste wird uebersprungen.');
|
||||
@@ -353,6 +432,7 @@ function seedWishlist(setIds: Map<string, string>) {
|
||||
.values({
|
||||
id: wishlistId,
|
||||
ownerUserId: owner.id,
|
||||
collectorId: collectors.get('Max') ?? null,
|
||||
title: 'Weihnachten 2026',
|
||||
occasion: 'christmas',
|
||||
eventDate: new Date(2026, 11, 24),
|
||||
@@ -389,10 +469,12 @@ console.log('[seed] Beispieldaten werden eingespielt …');
|
||||
const manufacturers = seedManufacturers();
|
||||
const themes = seedThemes(manufacturers);
|
||||
const locations = seedLocations();
|
||||
const setIds = seedSets(manufacturers, themes, locations);
|
||||
seedWishlist(setIds);
|
||||
const collectors = seedCollectors();
|
||||
const setIds = seedSets(manufacturers, themes, locations, collectors);
|
||||
seedWishlist(setIds, collectors);
|
||||
|
||||
console.log(
|
||||
`[seed] Fertig: ${manufacturers.size} Hersteller, ${themes.size} Themen, ${setIds.size} Sets.`,
|
||||
`[seed] Fertig: ${collectors.size} Sammler, ${manufacturers.size} Hersteller, ` +
|
||||
`${themes.size} Themen, ${setIds.size} Sets.`,
|
||||
);
|
||||
sqlite.close();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { Check, ExternalLink, Gift, ImageOff, Pencil } from 'lucide-react';
|
||||
import { 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 { PhotosSection } from '@/components/katalog/photos-section';
|
||||
import { PriceSection } from '@/components/katalog/price-section';
|
||||
import { SetActions } from '@/components/katalog/set-actions';
|
||||
import { CollectionItemsSection } from '@/components/sammlung/collection-items-section';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -17,25 +20,38 @@ import {
|
||||
listCollectionItemsForSet,
|
||||
listInstructionsForSet,
|
||||
listPhotosForSet,
|
||||
listPricePoints,
|
||||
} from '@/lib/queries/catalog';
|
||||
import { listMissingParts } from '@/lib/queries/collection';
|
||||
import { activeCollectorId } from '@/lib/collector-choice';
|
||||
import { listCollectors } from '@/lib/queries/collectors';
|
||||
import { listLocations } from '@/lib/queries/master-data';
|
||||
import { listWishlistsForSet, listWishlistTargets } from '@/lib/queries/wishlists';
|
||||
import { requireUser } from '@/lib/session';
|
||||
|
||||
type Props = { params: Promise<{ id: string }> };
|
||||
type Props = {
|
||||
params: Promise<{ id: string }>;
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
};
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
export async function generateMetadata({ params }: Pick<Props, 'params'>): Promise<Metadata> {
|
||||
const { id } = await params;
|
||||
const set = getSetById(id);
|
||||
return { title: set ? `${set.setNumber} · ${set.name}` : 'Set' };
|
||||
}
|
||||
|
||||
export default async function SetDetailPage({ params }: Props) {
|
||||
export default async function SetDetailPage({ params, searchParams }: Props) {
|
||||
await requireUser();
|
||||
const { id } = await params;
|
||||
const query = await searchParams;
|
||||
|
||||
const set = getSetById(id);
|
||||
// Keeps the "vorhanden / fehlt" badge in step with the collector the user
|
||||
// was looking at in the catalog.
|
||||
const rawCollector = Array.isArray(query.sammler) ? query.sammler[0] : query.sammler;
|
||||
const collectorId = await activeCollectorId(rawCollector);
|
||||
const collectors = listCollectors();
|
||||
|
||||
const set = getSetById(id, collectorId);
|
||||
if (!set) notFound();
|
||||
|
||||
const image = setImageSrc(set);
|
||||
@@ -43,11 +59,16 @@ export default async function SetDetailPage({ params }: Props) {
|
||||
const locations = listLocations().map((row) => ({ id: row.id, name: row.name }));
|
||||
const locationNames = new Map(locations.map((row) => [row.id, row.name]));
|
||||
|
||||
const collectorsById = new Map(collectors.map((row) => [row.id, row]));
|
||||
const collectionItems = listCollectionItemsForSet(set.id).map((item) => ({
|
||||
...item,
|
||||
locationName: item.locationId ? (locationNames.get(item.locationId) ?? null) : null,
|
||||
setName: `${set.setNumber} · ${set.name}`,
|
||||
missingParts: listMissingParts(item.id),
|
||||
collectorName: item.collectorId ? (collectorsById.get(item.collectorId)?.name ?? null) : null,
|
||||
collectorColor: item.collectorId
|
||||
? (collectorsById.get(item.collectorId)?.color ?? null)
|
||||
: null,
|
||||
}));
|
||||
|
||||
const facts: Array<[string, string]> = [
|
||||
@@ -96,15 +117,21 @@ export default async function SetDetailPage({ params }: Props) {
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-[minmax(0,320px)_1fr]">
|
||||
<div className="overflow-hidden rounded-xl border border-border bg-muted">
|
||||
{image ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={image} alt="" className="aspect-square w-full object-contain p-3" />
|
||||
) : (
|
||||
<div className="flex aspect-square items-center justify-center text-muted-foreground">
|
||||
<ImageOff className="size-10" aria-hidden />
|
||||
</div>
|
||||
)}
|
||||
<div className="relative self-start">
|
||||
<Studs count={4} />
|
||||
<div className="brick overflow-hidden rounded-xl bg-muted">
|
||||
{image ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={image} alt="" className="aspect-square w-full object-contain p-3" />
|
||||
) : (
|
||||
<SetPlaceholder
|
||||
setNumber={set.setNumber}
|
||||
manufacturerName={set.manufacturerName}
|
||||
size="lg"
|
||||
className="aspect-square"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
@@ -113,6 +140,7 @@ export default async function SetDetailPage({ params }: Props) {
|
||||
setName={`${set.setNumber} · ${set.name}`}
|
||||
owned={owned}
|
||||
wishlists={listWishlistTargets()}
|
||||
collectors={collectors.map((row) => ({ id: row.id, name: row.name }))}
|
||||
/>
|
||||
|
||||
<dl className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm sm:grid-cols-3">
|
||||
@@ -144,7 +172,24 @@ export default async function SetDetailPage({ params }: Props) {
|
||||
<CardTitle>In der Sammlung</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CollectionItemsSection items={collectionItems} locations={locations} />
|
||||
<CollectionItemsSection
|
||||
items={collectionItems}
|
||||
locations={locations}
|
||||
collectors={collectors.map((row) => ({ id: row.id, name: row.name }))}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Preis und Wertentwicklung</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<PriceSection
|
||||
setId={set.id}
|
||||
prices={listPricePoints(set.id)}
|
||||
retailPriceCents={set.retailPriceCents}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -5,8 +5,10 @@ import { and, eq, ne } from 'drizzle-orm';
|
||||
import { db } from '@/db';
|
||||
import { instruction, photo, set } from '@/db/schema';
|
||||
import { actionData, actionError, actionOk, formToObject, parseForm, type ActionResult } from '@/lib/action-result';
|
||||
import { env } from '@/env';
|
||||
import { safeFetch, SafeFetchError } from '@/lib/safe-fetch';
|
||||
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 { z } from 'zod';
|
||||
|
||||
@@ -20,6 +22,23 @@ function revalidateSet(id?: string) {
|
||||
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 {
|
||||
const value = formData.get(field);
|
||||
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);
|
||||
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)
|
||||
.values({
|
||||
id,
|
||||
...parsed.data,
|
||||
imageUrl: imagePath ? null : parsed.data.imageUrl,
|
||||
imagePath,
|
||||
createdAt: now(),
|
||||
updatedAt: now(),
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
'use server';
|
||||
|
||||
import { and, eq, ne } from 'drizzle-orm';
|
||||
import { db } from '@/db';
|
||||
import { manufacturer, set } from '@/db/schema';
|
||||
import { actionData, type ActionResult } from '@/lib/action-result';
|
||||
import { requireUser } from '@/lib/session';
|
||||
import { isSimilarName } from '@/lib/similarity';
|
||||
|
||||
export type DuplicateCandidate = {
|
||||
id: string;
|
||||
setNumber: string;
|
||||
name: string;
|
||||
manufacturerName: string;
|
||||
/** Why we are showing it. */
|
||||
reason: 'same-number' | 'similar-name' | 'number-elsewhere';
|
||||
};
|
||||
|
||||
/**
|
||||
* Looks for sets that the one being created might be a duplicate of.
|
||||
*
|
||||
* Three cases, in descending order of certainty:
|
||||
* - the same number at the same manufacturer (that is a real duplicate and is
|
||||
* rejected on save anyway),
|
||||
* - a suspiciously similar name at the same manufacturer,
|
||||
* - the same number at a different manufacturer, which usually means the
|
||||
* wrong manufacturer was selected.
|
||||
*/
|
||||
export async function findDuplicateCandidates(input: {
|
||||
manufacturerId: string;
|
||||
setNumber: string;
|
||||
name: string;
|
||||
excludeSetId?: string;
|
||||
}): Promise<ActionResult<{ candidates: DuplicateCandidate[] }>> {
|
||||
await requireUser();
|
||||
|
||||
const setNumber = input.setNumber?.trim() ?? '';
|
||||
const name = input.name?.trim() ?? '';
|
||||
if (!input.manufacturerId || (setNumber === '' && name === '')) {
|
||||
return actionData({ candidates: [] });
|
||||
}
|
||||
|
||||
const rows = db
|
||||
.select({
|
||||
id: set.id,
|
||||
setNumber: set.setNumber,
|
||||
name: set.name,
|
||||
manufacturerId: set.manufacturerId,
|
||||
manufacturerName: manufacturer.name,
|
||||
})
|
||||
.from(set)
|
||||
.innerJoin(manufacturer, eq(manufacturer.id, set.manufacturerId))
|
||||
.where(input.excludeSetId ? ne(set.id, input.excludeSetId) : undefined)
|
||||
.all();
|
||||
|
||||
const candidates: DuplicateCandidate[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
const sameManufacturer = row.manufacturerId === input.manufacturerId;
|
||||
const sameNumber = setNumber !== '' && row.setNumber.toLowerCase() === setNumber.toLowerCase();
|
||||
|
||||
if (sameManufacturer && sameNumber) {
|
||||
candidates.push({ ...row, reason: 'same-number' });
|
||||
continue;
|
||||
}
|
||||
if (sameManufacturer && name !== '' && isSimilarName(row.name, name)) {
|
||||
candidates.push({ ...row, reason: 'similar-name' });
|
||||
continue;
|
||||
}
|
||||
if (!sameManufacturer && sameNumber) {
|
||||
candidates.push({ ...row, reason: 'number-elsewhere' });
|
||||
}
|
||||
}
|
||||
|
||||
// Certain matches first, and never more than a handful.
|
||||
const order: Record<DuplicateCandidate['reason'], number> = {
|
||||
'same-number': 0,
|
||||
'similar-name': 1,
|
||||
'number-elsewhere': 2,
|
||||
};
|
||||
candidates.sort((a, b) => order[a.reason] - order[b.reason]);
|
||||
|
||||
return actionData({ candidates: candidates.slice(0, 5) });
|
||||
}
|
||||
|
||||
/** Used by the import to point at an existing entry instead of failing blindly. */
|
||||
export async function findSetByNumber(
|
||||
manufacturerId: string,
|
||||
setNumber: string,
|
||||
): Promise<ActionResult<{ id: string } | null>> {
|
||||
await requireUser();
|
||||
|
||||
const row = db
|
||||
.select({ id: set.id })
|
||||
.from(set)
|
||||
.where(and(eq(set.manufacturerId, manufacturerId), eq(set.setNumber, setNumber)))
|
||||
.get();
|
||||
|
||||
return actionData(row ?? null);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { NewSetEntry } from '@/components/katalog/link-import';
|
||||
import { RebrickableImport } from '@/components/katalog/rebrickable-import';
|
||||
import { SetForm } from '@/components/katalog/set-form';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { listManufacturers, listThemes } from '@/lib/queries/master-data';
|
||||
import { isRebrickableEnabled } from '@/lib/rebrickable';
|
||||
@@ -15,6 +15,8 @@ export default async function NeuesSetPage({ searchParams }: { searchParams: Sea
|
||||
await requireUser();
|
||||
const params = await searchParams;
|
||||
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 themes = listThemes().map((row) => ({
|
||||
@@ -25,26 +27,8 @@ export default async function NeuesSetPage({ searchParams }: { searchParams: Sea
|
||||
|
||||
const importEnabled = isRebrickableEnabled();
|
||||
|
||||
if (manufacturers.length === 0) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Neues Set</h1>
|
||||
|
||||
{/* The Lego import creates its manufacturer itself, so it works even
|
||||
on a completely empty instance. */}
|
||||
<RebrickableImport enabled={importEnabled} />
|
||||
|
||||
<p className="rounded-lg border border-dashed border-border p-6 text-sm text-muted-foreground">
|
||||
Für ein Set von Hand brauchst du zuerst einen Hersteller – Lego, Playmobil, Cobi oder
|
||||
was auch immer im Kinderzimmer steht.
|
||||
</p>
|
||||
<Button asChild size="lg">
|
||||
<Link href="/verwaltung/hersteller">Hersteller anlegen</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// One layout for both cases: the link import may create the first
|
||||
// manufacturer, and its draft must survive the refresh that follows.
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
@@ -54,15 +38,24 @@ export default async function NeuesSetPage({ searchParams }: { searchParams: Sea
|
||||
Sammlung hinzufügen“ fest.
|
||||
</p>
|
||||
</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">
|
||||
<span className="h-px flex-1 bg-border" />
|
||||
oder von Hand
|
||||
<span className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
|
||||
<SetForm manufacturers={manufacturers} themes={themes} defaultEan={ean} />
|
||||
<NewSetEntry
|
||||
manufacturers={manufacturers}
|
||||
themes={themes}
|
||||
defaultEan={ean}
|
||||
focus={way === 'link' || way === 'hand' ? way : undefined}
|
||||
>
|
||||
<p className="rounded-lg border border-dashed border-border p-6 text-sm text-muted-foreground">
|
||||
Für ein Set von Hand brauchst du zuerst einen Hersteller – Lego, Playmobil, Cobi oder
|
||||
was auch immer im Kinderzimmer steht.
|
||||
</p>
|
||||
<Button asChild size="lg">
|
||||
<Link href="/verwaltung/hersteller">Hersteller anlegen</Link>
|
||||
</Button>
|
||||
</NewSetEntry>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import { Button } from '@/components/ui/button';
|
||||
import { pluralize } from '@/lib/format';
|
||||
import { OWNERSHIP_FILTER_LABELS, parseOwnershipFilter } from '@/lib/ownership';
|
||||
import { CATALOG_PAGE_SIZE, listSets, listSetYears } from '@/lib/queries/catalog';
|
||||
import { activeCollectorId } from '@/lib/collector-choice';
|
||||
import { listCollectors } from '@/lib/queries/collectors';
|
||||
import { listManufacturers, listThemes } from '@/lib/queries/master-data';
|
||||
import { listWishlistTargets } from '@/lib/queries/wishlists';
|
||||
import { requireUser } from '@/lib/session';
|
||||
@@ -27,6 +29,9 @@ export default async function KatalogPage({ searchParams }: { searchParams: Sear
|
||||
const params = await searchParams;
|
||||
|
||||
const ownership = parseOwnershipFilter(single(params.besitz));
|
||||
const collectors = listCollectors();
|
||||
const collectorId = await activeCollectorId(single(params.sammler));
|
||||
const activeCollector = collectors.find((row) => row.id === collectorId) ?? null;
|
||||
const yearParam = single(params.jahr);
|
||||
const page = Number(single(params.seite) ?? '1') || 1;
|
||||
|
||||
@@ -36,6 +41,7 @@ export default async function KatalogPage({ searchParams }: { searchParams: Sear
|
||||
themeId: single(params.thema),
|
||||
year: yearParam ? Number(yearParam) : undefined,
|
||||
ownership,
|
||||
collectorId,
|
||||
page,
|
||||
};
|
||||
|
||||
@@ -57,7 +63,9 @@ export default async function KatalogPage({ searchParams }: { searchParams: Sear
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{ownership === 'fehlt'
|
||||
? 'Sets, die noch nicht in der Sammlung sind.'
|
||||
? activeCollector
|
||||
? `Sets, die ${activeCollector.name} noch nicht hat.`
|
||||
: 'Sets, die noch nicht in der Sammlung sind.'
|
||||
: 'Alle bekannten Sets – unabhängig davon, ob sie schon da sind.'}
|
||||
</p>
|
||||
</div>
|
||||
@@ -79,7 +87,7 @@ export default async function KatalogPage({ searchParams }: { searchParams: Sear
|
||||
{ownership !== 'alle' ? ` · ${OWNERSHIP_FILTER_LABELS[ownership]}` : ''}
|
||||
</p>
|
||||
|
||||
<CatalogGrid sets={rows} wishlists={wishlists} />
|
||||
<CatalogGrid sets={rows} wishlists={wishlists} collectorId={collectorId} />
|
||||
|
||||
<Pagination page={page} total={total} pageSize={CATALOG_PAGE_SIZE} />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import { db } from '@/db';
|
||||
import { pricePoint } from '@/db/schema';
|
||||
import { actionError, actionOk, parseForm, type ActionResult } from '@/lib/action-result';
|
||||
import { requireUser } from '@/lib/session';
|
||||
import { optionalDate, optionalText, optionalPriceCents } from '@/lib/validation';
|
||||
|
||||
const schema = z.object({
|
||||
priceCents: optionalPriceCents,
|
||||
condition: z.enum(['new', 'used']).default('new'),
|
||||
source: optionalText(80),
|
||||
observedAt: optionalDate,
|
||||
note: optionalText(200),
|
||||
});
|
||||
|
||||
export async function addPricePoint(setId: string, input: unknown): Promise<ActionResult> {
|
||||
await requireUser();
|
||||
|
||||
const parsed = parseForm(schema, input);
|
||||
if (!parsed.ok) return parsed;
|
||||
if (parsed.data.priceCents === null) return actionError('Bitte gib einen Preis an.');
|
||||
|
||||
db.insert(pricePoint)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
setId,
|
||||
priceCents: parsed.data.priceCents,
|
||||
condition: parsed.data.condition,
|
||||
source: parsed.data.source,
|
||||
// No date given means "seen today".
|
||||
observedAt: parsed.data.observedAt ?? new Date(),
|
||||
note: parsed.data.note,
|
||||
createdAt: new Date(),
|
||||
})
|
||||
.run();
|
||||
|
||||
revalidatePath(`/katalog/${setId}`);
|
||||
revalidatePath('/sammlung');
|
||||
return actionOk();
|
||||
}
|
||||
|
||||
export async function deletePricePoint(id: string): Promise<ActionResult> {
|
||||
await requireUser();
|
||||
|
||||
const existing = db
|
||||
.select({ setId: pricePoint.setId })
|
||||
.from(pricePoint)
|
||||
.where(eq(pricePoint.id, id))
|
||||
.get();
|
||||
if (!existing) return actionOk();
|
||||
|
||||
db.delete(pricePoint).where(eq(pricePoint.id, id)).run();
|
||||
|
||||
revalidatePath(`/katalog/${existing.setId}`);
|
||||
revalidatePath('/sammlung');
|
||||
return actionOk();
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { headers } from 'next/headers';
|
||||
import { cookies, headers } from 'next/headers';
|
||||
import { z } from 'zod';
|
||||
import { env } from '@/env';
|
||||
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 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 };
|
||||
|
||||
@@ -43,3 +47,26 @@ export async function renamePasskeyAction(input: unknown): Promise<ActionResult>
|
||||
revalidatePath('/konto');
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
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 { DesignPicker } from '@/components/konto/design-picker';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { DESIGN_COOKIE, resolveDesign } from '@/lib/design';
|
||||
import { requireUser } from '@/lib/session';
|
||||
|
||||
export const metadata: Metadata = { title: 'Konto' };
|
||||
@@ -17,6 +19,7 @@ export default async function AccountPage() {
|
||||
name: passkey.name ?? null,
|
||||
createdAt: passkey.createdAt ? new Date(passkey.createdAt) : null,
|
||||
}));
|
||||
const design = resolveDesign((await cookies()).get(DESIGN_COOKIE)?.value);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -45,6 +48,18 @@ export default async function AccountPage() {
|
||||
</CardContent>
|
||||
</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>
|
||||
<CardHeader>
|
||||
<CardTitle>Passkeys</CardTitle>
|
||||
|
||||
@@ -1,16 +1,35 @@
|
||||
import { AppHeader } from '@/components/layout/app-header';
|
||||
import { BottomNav } from '@/components/layout/bottom-nav';
|
||||
import { storedCollectorId } from '@/lib/collector-choice';
|
||||
import { listCollectors } from '@/lib/queries/collectors';
|
||||
import { isRebrickableEnabled } from '@/lib/rebrickable';
|
||||
import { ensureReminderScheduler } from '@/lib/reminder-job';
|
||||
import { requireUser } from '@/lib/session';
|
||||
|
||||
export default async function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
// Second entry point besides the healthcheck, in case someone turns that
|
||||
// off. The call is idempotent.
|
||||
ensureReminderScheduler();
|
||||
|
||||
const user = await requireUser();
|
||||
const collectors = listCollectors().map((row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
color: row.color,
|
||||
}));
|
||||
const legoImport = isRebrickableEnabled();
|
||||
|
||||
return (
|
||||
<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. */}
|
||||
<main className="mx-auto w-full max-w-5xl flex-1 px-4 pt-4 pb-24 md:pb-10">{children}</main>
|
||||
<BottomNav />
|
||||
<main className="mx-auto w-full max-w-5xl flex-1 px-4 pt-4 pb-28 md:pb-10">{children}</main>
|
||||
<BottomNav legoImport={legoImport} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,58 +1,198 @@
|
||||
import Link from 'next/link';
|
||||
import { Boxes, Gift, LayoutGrid, PackageSearch } from 'lucide-react';
|
||||
import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { ChevronRight, Gift, PackageSearch, Plus } from 'lucide-react';
|
||||
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';
|
||||
|
||||
const shortcuts = [
|
||||
{
|
||||
href: '/katalog',
|
||||
icon: LayoutGrid,
|
||||
title: 'Katalog',
|
||||
description: 'Alle bekannten Sets durchsuchen und neue anlegen.',
|
||||
},
|
||||
{
|
||||
href: '/katalog?besitz=fehlt',
|
||||
icon: PackageSearch,
|
||||
title: 'Fehlende Sets',
|
||||
description: 'Was noch nicht in der Sammlung ist – direkt auf die Wunschliste.',
|
||||
},
|
||||
{
|
||||
href: '/sammlung',
|
||||
icon: Boxes,
|
||||
title: 'Sammlung',
|
||||
description: 'Alles, was tatsächlich da ist – mit Standort und Zustand.',
|
||||
},
|
||||
{
|
||||
href: '/wunschlisten',
|
||||
icon: Gift,
|
||||
title: 'Wunschlisten',
|
||||
description: 'Listen anlegen und per Link mit Familie und Freunden teilen.',
|
||||
},
|
||||
] as const;
|
||||
function daysLabel(days: number): string {
|
||||
if (days === 0) return 'heute';
|
||||
if (days === 1) return 'morgen';
|
||||
return `in ${days} Tagen`;
|
||||
}
|
||||
|
||||
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 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 (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Hallo {user.name}</h1>
|
||||
<p className="text-sm text-muted-foreground">Was möchtest du tun?</p>
|
||||
</div>
|
||||
<div className="mx-auto max-w-2xl space-y-7">
|
||||
<h1 className="text-3xl font-semibold tracking-tight">Hallo {user.name}!</h1>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{shortcuts.map((shortcut) => (
|
||||
<Link key={shortcut.href} href={shortcut.href} className="group">
|
||||
<Card className="h-full transition-colors group-hover:border-primary/40">
|
||||
<CardHeader>
|
||||
<shortcut.icon className="size-6 text-primary" aria-hidden />
|
||||
<CardTitle>{shortcut.title}</CardTitle>
|
||||
<CardDescription>{shortcut.description}</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<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>
|
||||
|
||||
<ol
|
||||
aria-label={`${formatNumber(stats.setCount)} von ${formatNumber(totalSets)} Sets vorhanden`}
|
||||
className="mt-5 grid grid-cols-10 items-end gap-1.5"
|
||||
>
|
||||
{bricks.map((key, index) => (
|
||||
<li key={index} aria-hidden className="relative">
|
||||
{key === null ? (
|
||||
<span className="block h-7 rounded-[6px] border-2 border-dashed border-muted-foreground/50" />
|
||||
) : (
|
||||
<>
|
||||
<span
|
||||
className={cn(
|
||||
'stud mx-auto mb-[calc(var(--edge-w)*-1)] !h-1.5 !w-3 !rounded-t-[4px] [display:var(--studs)]',
|
||||
key ? collectorDotClass(colorById.get(key)) : 'bg-secondary',
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'block h-7 rounded-[6px] border-[length:var(--edge-w)] border-edge',
|
||||
key ? collectorDotClass(colorById.get(key)) : 'bg-secondary',
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
{showCollectors ? (
|
||||
<ul className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs font-semibold">
|
||||
{collectors.map((entry) => (
|
||||
<li key={entry.id} className="flex items-center gap-1.5">
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'size-3 rounded-[3px] border-[length:var(--edge-w)] border-edge',
|
||||
collectorDotClass(entry.color),
|
||||
)}
|
||||
/>
|
||||
{entry.name}
|
||||
</li>
|
||||
))}
|
||||
<li className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<span
|
||||
aria-hidden
|
||||
className="size-3 rounded-[3px] border-2 border-dashed border-muted-foreground/50"
|
||||
/>
|
||||
fehlt noch
|
||||
</li>
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid grid-cols-2 gap-3">
|
||||
<div className="relative">
|
||||
<Studs studClassName="bg-secondary" />
|
||||
<div className="brick h-full rounded-xl bg-secondary p-4 text-secondary-foreground">
|
||||
<p className="text-2xl font-bold tabular-nums [font-family:var(--font-display)]">
|
||||
{formatNumber(stats.pieceCount)}
|
||||
</p>
|
||||
<p className="text-sm font-semibold">Teile verbaut</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Studs studClassName="bg-owned" />
|
||||
<div className="brick h-full rounded-xl bg-owned p-4 text-owned-foreground">
|
||||
<p className="text-2xl font-bold tabular-nums [font-family:var(--font-display)]">
|
||||
{formatEuro(stats.currentValueCents)}
|
||||
</p>
|
||||
<p className="text-sm font-semibold">
|
||||
{stats.currentValueCents === stats.retailValueCents ? 'Wert nach UVP' : 'Aktueller Wert'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{nextEvent?.eventDate ? (
|
||||
<Link href={`/wunschlisten/${nextEvent.id}`} className="relative block rounded-xl">
|
||||
<Studs count={4} studClassName="bg-primary" />
|
||||
<div className="brick flex items-center gap-4 rounded-xl bg-primary p-4 text-primary-foreground">
|
||||
<div className="flex size-14 shrink-0 flex-col items-center justify-center rounded-lg border-[length:var(--edge-w)] border-edge bg-card text-card-foreground">
|
||||
<span className="text-xl leading-none font-bold [font-family:var(--font-display)]">
|
||||
{formatDate(nextEvent.eventDate).slice(0, 5)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-lg font-semibold [font-family:var(--font-display)]">
|
||||
{nextEvent.title}
|
||||
</p>
|
||||
<p className="text-sm font-semibold">
|
||||
{daysLabel(daysUntil(nextEvent.eventDate, now))} ·{' '}
|
||||
{nextEvent.openCount === 1
|
||||
? '1 Wunsch noch offen'
|
||||
: `${formatNumber(nextEvent.openCount)} Wünsche noch offen`}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRight className="size-5 shrink-0" aria-hidden />
|
||||
</div>
|
||||
</Link>
|
||||
) : null}
|
||||
|
||||
<section className="flex flex-wrap gap-3">
|
||||
<Button asChild variant="outline" size="lg">
|
||||
<Link href="/katalog?besitz=fehlt">
|
||||
<PackageSearch aria-hidden />
|
||||
{missing === 1 ? '1 Set fehlt noch' : `${formatNumber(missing)} Sets fehlen noch`}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
parseForm,
|
||||
type ActionResult,
|
||||
} from '@/lib/action-result';
|
||||
import { defaultCollectorId } from '@/lib/queries/collectors';
|
||||
import { reconcileFulfilledWishlistItems } from '@/lib/queries/wishlists';
|
||||
import { requireUser } from '@/lib/session';
|
||||
import { collectionItemSchema, missingPartSchema } from '@/lib/validation';
|
||||
@@ -32,17 +33,24 @@ function revalidateCollection(setId?: string) {
|
||||
* Quick add from the set detail page: one tap, sensible defaults, details can
|
||||
* be filled in later.
|
||||
*/
|
||||
export async function addSetToCollection(setId: string): Promise<ActionResult<{ id: string }>> {
|
||||
export async function addSetToCollection(
|
||||
setId: string,
|
||||
collectorId?: string,
|
||||
): Promise<ActionResult<{ id: string }>> {
|
||||
await requireUser();
|
||||
|
||||
const exists = db.select({ id: set.id }).from(set).where(eq(set.id, setId)).get();
|
||||
if (!exists) return actionError('Das Set gibt es nicht mehr.');
|
||||
|
||||
// With a single collector nobody is asked - the copy simply belongs to them.
|
||||
const owner = collectorId ?? defaultCollectorId();
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
db.insert(collectionItem)
|
||||
.values({
|
||||
id,
|
||||
setId,
|
||||
collectorId: owner,
|
||||
status: 'owned',
|
||||
completeness: 'unknown',
|
||||
createdAt: now(),
|
||||
@@ -68,6 +76,7 @@ export async function updateCollectionItem(id: string, input: unknown): Promise<
|
||||
|
||||
db.update(collectionItem)
|
||||
.set({
|
||||
collectorId: parsed.data.collectorId ?? existing.collectorId,
|
||||
status: parsed.data.status,
|
||||
completeness: parsed.data.completeness,
|
||||
condition: parsed.data.condition as Condition | null,
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { Boxes, Euro, ImageOff, Layers, PackageSearch } from 'lucide-react';
|
||||
import { Boxes, Euro, Layers, PackageSearch } from 'lucide-react';
|
||||
import { SetPlaceholder } from '@/components/brick/set-placeholder';
|
||||
import { Studs } from '@/components/brick/studs';
|
||||
import { CollectionFilters } from '@/components/sammlung/collection-filters';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { formatDate, formatNumber, pluralize } from '@/lib/format';
|
||||
import { collectorBadgeClass, collectorDotClass } from '@/lib/collector-colors';
|
||||
import { setImageSrc } from '@/lib/images';
|
||||
import { completenessLabel, statusLabel } from '@/lib/labels';
|
||||
import { formatEuro } from '@/lib/money';
|
||||
@@ -15,6 +18,8 @@ import {
|
||||
listCollectionItems,
|
||||
type CollectionFilters as Filters,
|
||||
} from '@/lib/queries/collection';
|
||||
import { activeCollectorId } from '@/lib/collector-choice';
|
||||
import { listCollectors } from '@/lib/queries/collectors';
|
||||
import { listLocations, listManufacturers } from '@/lib/queries/master-data';
|
||||
import { requireUser } from '@/lib/session';
|
||||
import type { CollectionStatus, Completeness } from '@/db/types';
|
||||
@@ -32,23 +37,33 @@ export default async function SammlungPage({ searchParams }: { searchParams: Sea
|
||||
await requireUser();
|
||||
const params = await searchParams;
|
||||
|
||||
const collectors = listCollectors();
|
||||
const collectorId = await activeCollectorId(single(params.sammler));
|
||||
const activeCollector = collectors.find((row) => row.id === collectorId) ?? null;
|
||||
|
||||
const filters: Filters = {
|
||||
q: single(params.q),
|
||||
status: single(params.status) as CollectionStatus | undefined,
|
||||
completeness: single(params.zustand) as Completeness | undefined,
|
||||
locationId: single(params.standort),
|
||||
manufacturerId: single(params.hersteller),
|
||||
collectorId,
|
||||
};
|
||||
|
||||
const items = listCollectionItems(filters);
|
||||
const openParts = countOpenMissingParts(items.map((item) => item.id));
|
||||
const stats = getCollectionStats();
|
||||
const missing = countMissingSets();
|
||||
const stats = getCollectionStats(collectorId);
|
||||
const missing = countMissingSets(collectorId);
|
||||
|
||||
const tiles = [
|
||||
{ icon: Boxes, label: 'Sets', value: formatNumber(stats.setCount) },
|
||||
{ icon: Layers, label: 'Teile gesamt', value: formatNumber(stats.pieceCount) },
|
||||
{ icon: Euro, label: 'Wert nach UVP', value: formatEuro(stats.retailValueCents) },
|
||||
{
|
||||
icon: Euro,
|
||||
// Notierte Preise gehen vor, sonst die UVP.
|
||||
label: stats.currentValueCents === stats.retailValueCents ? 'Wert nach UVP' : 'Aktueller Wert',
|
||||
value: formatEuro(stats.currentValueCents),
|
||||
},
|
||||
{ icon: PackageSearch, label: 'Fehlt noch', value: formatNumber(missing) },
|
||||
] as const;
|
||||
|
||||
@@ -56,7 +71,11 @@ export default async function SammlungPage({ searchParams }: { searchParams: Sea
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Sammlung</h1>
|
||||
<p className="text-sm text-muted-foreground">Alles, was tatsächlich da ist.</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{activeCollector
|
||||
? `Alles, was ${activeCollector.name} tatsächlich hat.`
|
||||
: 'Alles, was tatsächlich da ist.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
@@ -100,23 +119,31 @@ export default async function SammlungPage({ searchParams }: { searchParams: Sea
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-border rounded-lg border border-border">
|
||||
<ul className="space-y-5 pt-2">
|
||||
{items.map((item) => {
|
||||
const image = setImageSrc(item);
|
||||
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
|
||||
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 ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={image} alt="" loading="lazy" className="size-full object-contain" />
|
||||
) : (
|
||||
<div className="flex size-full items-center justify-center text-muted-foreground">
|
||||
<ImageOff className="size-5" aria-hidden />
|
||||
</div>
|
||||
<SetPlaceholder
|
||||
setNumber={item.setNumber}
|
||||
manufacturerName={item.manufacturerName}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -128,12 +155,21 @@ export default async function SammlungPage({ searchParams }: { searchParams: Sea
|
||||
.join(' · ')}
|
||||
</p>
|
||||
<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)}
|
||||
</Badge>
|
||||
{item.completeness !== 'unknown' ? (
|
||||
<Badge variant="secondary">{completenessLabel(item.completeness)}</Badge>
|
||||
) : null}
|
||||
{collectors.length > 1 && item.collectorName ? (
|
||||
<Badge className={collectorBadgeClass(item.collectorColor)}>
|
||||
{item.collectorName}
|
||||
</Badge>
|
||||
) : null}
|
||||
{openParts.get(item.id) ? (
|
||||
<Badge variant="outline">{openParts.get(item.id)} Fehlteile</Badge>
|
||||
) : null}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import {
|
||||
actionData,
|
||||
actionError,
|
||||
actionOk,
|
||||
formToObject,
|
||||
parseForm,
|
||||
type ActionResult,
|
||||
} from '@/lib/action-result';
|
||||
import { env } from '@/env';
|
||||
import {
|
||||
deleteMailSettings,
|
||||
getMailConfig,
|
||||
isMailConfigured,
|
||||
saveMailSettings,
|
||||
} from '@/lib/mail-settings';
|
||||
import { sendMail, verifyMailConnection } from '@/lib/mailer';
|
||||
import { mailSettingsSchema } from '@/lib/validation';
|
||||
import { runReminderJob, type ReminderRun } from '@/lib/reminder-job';
|
||||
import { requireAdmin } from '@/lib/session';
|
||||
|
||||
export async function testMailConnection(): Promise<ActionResult> {
|
||||
await requireAdmin();
|
||||
|
||||
const result = await verifyMailConnection();
|
||||
return result.ok ? { ok: true } : actionError(result.error);
|
||||
}
|
||||
|
||||
export async function sendTestMail(): Promise<ActionResult> {
|
||||
const admin = await requireAdmin();
|
||||
|
||||
if (!isMailConfigured()) {
|
||||
return actionError('Es ist kein SMTP-Server konfiguriert.');
|
||||
}
|
||||
|
||||
const result = await sendMail({
|
||||
to: admin.email,
|
||||
subject: 'Bricktrack: Testnachricht',
|
||||
text: [
|
||||
'Das hat geklappt.',
|
||||
'',
|
||||
'Wenn diese Nachricht ankommt, funktionieren auch die Erinnerungen zu',
|
||||
'Wunschlisten-Terminen.',
|
||||
'',
|
||||
env.BASE_URL,
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
return result.ok ? { ok: true } : actionError(result.error);
|
||||
}
|
||||
|
||||
/** Runs the check immediately instead of waiting for the next hour. */
|
||||
export async function runRemindersNow(): Promise<ActionResult<ReminderRun>> {
|
||||
await requireAdmin();
|
||||
|
||||
if (!isMailConfigured()) {
|
||||
return actionError('Es ist kein SMTP-Server konfiguriert.');
|
||||
}
|
||||
|
||||
const run = await runReminderJob();
|
||||
revalidatePath('/verwaltung/erinnerungen');
|
||||
return actionData(run);
|
||||
}
|
||||
|
||||
/** Stores the SMTP server set up on the reminders page. */
|
||||
export async function saveMailSettingsAction(formData: FormData): Promise<ActionResult> {
|
||||
await requireAdmin();
|
||||
|
||||
if (getMailConfig()?.source === 'env') {
|
||||
return actionError('Der Mailserver kommt aus der .env. Änderungen bitte dort vornehmen.');
|
||||
}
|
||||
|
||||
const parsed = parseForm(mailSettingsSchema, formToObject(formData));
|
||||
if (!parsed.ok) return parsed;
|
||||
|
||||
saveMailSettings(parsed.data);
|
||||
revalidatePath('/verwaltung/erinnerungen');
|
||||
return actionOk();
|
||||
}
|
||||
|
||||
export async function deleteMailSettingsAction(): Promise<ActionResult> {
|
||||
await requireAdmin();
|
||||
deleteMailSettings();
|
||||
revalidatePath('/verwaltung/erinnerungen');
|
||||
return actionOk();
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
import { db } from '@/db';
|
||||
import { wishlist } from '@/db/schema';
|
||||
import { env } from '@/env';
|
||||
import { MailSettingsForm } from '@/components/verwaltung/mail-settings-form';
|
||||
import {
|
||||
ReminderSettings,
|
||||
type ReminderListRow,
|
||||
} from '@/components/verwaltung/reminder-settings';
|
||||
import { getMailSettingsView } from '@/lib/mail-settings';
|
||||
import { daysUntil } from '@/lib/reminders';
|
||||
import { requireAdmin } from '@/lib/session';
|
||||
|
||||
export const metadata: Metadata = { title: 'Erinnerungen' };
|
||||
|
||||
export default async function ErinnerungenPage() {
|
||||
const admin = await requireAdmin();
|
||||
|
||||
const openItems = sql<number>`(
|
||||
select count(*) from wishlist_item wi
|
||||
where wi.wishlist_id = ${wishlist.id}
|
||||
and wi.reserved_at is null
|
||||
and wi.fulfilled_at is null
|
||||
)`.mapWith(Number);
|
||||
|
||||
const now = new Date();
|
||||
const lists: ReminderListRow[] = db
|
||||
.select({
|
||||
id: wishlist.id,
|
||||
title: wishlist.title,
|
||||
eventDate: wishlist.eventDate,
|
||||
lastReminderAt: wishlist.lastReminderAt,
|
||||
openItemCount: openItems,
|
||||
})
|
||||
.from(wishlist)
|
||||
.where(eq(wishlist.ownerUserId, admin.id))
|
||||
.all()
|
||||
.filter((row) => row.eventDate !== null)
|
||||
.map((row) => ({
|
||||
...row,
|
||||
daysLeft: row.eventDate ? daysUntil(row.eventDate, now) : null,
|
||||
}))
|
||||
.sort((a, b) => (a.daysLeft ?? 0) - (b.daysLeft ?? 0));
|
||||
|
||||
const mail = getMailSettingsView();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<ReminderSettings
|
||||
configured={mail.source !== null}
|
||||
daysBefore={env.REMINDER_DAYS_BEFORE}
|
||||
lists={lists}
|
||||
/>
|
||||
<MailSettingsForm view={mail} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +1,15 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { VerwaltungNav } from '@/components/verwaltung/verwaltung-nav';
|
||||
import { VerwaltungHeader } from '@/components/verwaltung/verwaltung-header';
|
||||
import { requireUser } from '@/lib/session';
|
||||
|
||||
export const metadata: Metadata = { title: 'Verwaltung' };
|
||||
|
||||
export default async function VerwaltungLayout({ children }: { children: React.ReactNode }) {
|
||||
const user = await requireUser();
|
||||
await requireUser();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Verwaltung</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Stammdaten, die im Katalog und in der Sammlung ausgewählt werden.
|
||||
</p>
|
||||
</div>
|
||||
<VerwaltungNav isAdmin={user.role === 'admin'} />
|
||||
<VerwaltungHeader />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { asc, eq, sql } from 'drizzle-orm';
|
||||
import { db } from '@/db';
|
||||
import { collectionItem, collector, wishlist } from '@/db/schema';
|
||||
import { actionError, actionOk, parseForm, type ActionResult } from '@/lib/action-result';
|
||||
import { requireUser } from '@/lib/session';
|
||||
import { collectorSchema } from '@/lib/validation';
|
||||
|
||||
function now() {
|
||||
return new Date();
|
||||
}
|
||||
|
||||
function revalidateEverything() {
|
||||
// A collector shows up in the catalog, the collection and the wishlists.
|
||||
revalidatePath('/', 'layout');
|
||||
}
|
||||
|
||||
export async function createCollector(input: unknown): Promise<ActionResult> {
|
||||
await requireUser();
|
||||
|
||||
const parsed = parseForm(collectorSchema, input);
|
||||
if (!parsed.ok) return parsed;
|
||||
|
||||
const [row] = db
|
||||
.select({ next: sql<number>`coalesce(max(${collector.sortOrder}), -1) + 1`.mapWith(Number) })
|
||||
.from(collector)
|
||||
.all();
|
||||
|
||||
db.insert(collector)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
name: parsed.data.name,
|
||||
color: parsed.data.color,
|
||||
birthday: parsed.data.birthday,
|
||||
notes: parsed.data.notes,
|
||||
sortOrder: row?.next ?? 0,
|
||||
createdAt: now(),
|
||||
updatedAt: now(),
|
||||
})
|
||||
.run();
|
||||
|
||||
revalidateEverything();
|
||||
return actionOk();
|
||||
}
|
||||
|
||||
export async function updateCollector(id: string, input: unknown): Promise<ActionResult> {
|
||||
await requireUser();
|
||||
|
||||
const parsed = parseForm(collectorSchema, input);
|
||||
if (!parsed.ok) return parsed;
|
||||
|
||||
db.update(collector)
|
||||
.set({
|
||||
name: parsed.data.name,
|
||||
color: parsed.data.color,
|
||||
birthday: parsed.data.birthday,
|
||||
notes: parsed.data.notes,
|
||||
updatedAt: now(),
|
||||
})
|
||||
.where(eq(collector.id, id))
|
||||
.run();
|
||||
|
||||
revalidateEverything();
|
||||
return actionOk();
|
||||
}
|
||||
|
||||
/**
|
||||
* Deleting a collector would take their whole collection with it, so it is
|
||||
* only allowed while nothing is attached - and never for the last one.
|
||||
*/
|
||||
export async function deleteCollector(id: string): Promise<ActionResult> {
|
||||
await requireUser();
|
||||
|
||||
const remaining = db.select({ id: collector.id }).from(collector).all();
|
||||
if (remaining.length <= 1) {
|
||||
return actionError('Der letzte Sammler kann nicht gelöscht werden.');
|
||||
}
|
||||
|
||||
const owned = db
|
||||
.select({ id: collectionItem.id })
|
||||
.from(collectionItem)
|
||||
.where(eq(collectionItem.collectorId, id))
|
||||
.get();
|
||||
if (owned) {
|
||||
return actionError(
|
||||
'Diesem Sammler sind noch Sets zugeordnet. Ordne sie erst jemand anderem zu, dann lässt er sich löschen.',
|
||||
);
|
||||
}
|
||||
|
||||
const lists = db
|
||||
.select({ id: wishlist.id })
|
||||
.from(wishlist)
|
||||
.where(eq(wishlist.collectorId, id))
|
||||
.get();
|
||||
if (lists) {
|
||||
return actionError(
|
||||
'Zu diesem Sammler gibt es noch Wunschlisten. Ordne sie erst um oder lösch sie.',
|
||||
);
|
||||
}
|
||||
|
||||
db.delete(collector).where(eq(collector.id, id)).run();
|
||||
|
||||
revalidateEverything();
|
||||
return actionOk();
|
||||
}
|
||||
|
||||
/** Moves every owned copy of one collector over to another. */
|
||||
export async function reassignCollectionItems(
|
||||
fromCollectorId: string,
|
||||
toCollectorId: string,
|
||||
): Promise<ActionResult> {
|
||||
await requireUser();
|
||||
|
||||
if (fromCollectorId === toCollectorId) return actionOk();
|
||||
|
||||
const target = db
|
||||
.select({ id: collector.id })
|
||||
.from(collector)
|
||||
.where(eq(collector.id, toCollectorId))
|
||||
.get();
|
||||
if (!target) return actionError('Diesen Sammler gibt es nicht.');
|
||||
|
||||
db.update(collectionItem)
|
||||
.set({ collectorId: toCollectorId, updatedAt: now() })
|
||||
.where(eq(collectionItem.collectorId, fromCollectorId))
|
||||
.run();
|
||||
|
||||
revalidateEverything();
|
||||
return actionOk();
|
||||
}
|
||||
|
||||
/** Reorders the collectors; the first one is the default for new entries. */
|
||||
export async function reorderCollectors(orderedIds: string[]): Promise<ActionResult> {
|
||||
await requireUser();
|
||||
|
||||
db.transaction((tx) => {
|
||||
orderedIds.forEach((id, index) => {
|
||||
tx.update(collector)
|
||||
.set({ sortOrder: index, updatedAt: now() })
|
||||
.where(eq(collector.id, id))
|
||||
.run();
|
||||
});
|
||||
});
|
||||
|
||||
revalidateEverything();
|
||||
return actionOk();
|
||||
}
|
||||
|
||||
/** Every collector, ordered - for the selects in the forms. */
|
||||
export async function listCollectorOptions(): Promise<Array<{ id: string; name: string }>> {
|
||||
await requireUser();
|
||||
|
||||
return db
|
||||
.select({ id: collector.id, name: collector.name })
|
||||
.from(collector)
|
||||
.orderBy(asc(collector.sortOrder), asc(collector.name))
|
||||
.all();
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { CollectorList, type CollectorRowItem } from '@/components/verwaltung/collector-list';
|
||||
import { formatDate, pluralize } from '@/lib/format';
|
||||
import { COLLECTOR_COLOR_LABELS } from '@/lib/collector-colors';
|
||||
import { listCollectors } from '@/lib/queries/collectors';
|
||||
import { requireUser } from '@/lib/session';
|
||||
|
||||
export const metadata: Metadata = { title: 'Sammler' };
|
||||
|
||||
export default async function SammlerPage() {
|
||||
await requireUser();
|
||||
|
||||
const collectors = listCollectors();
|
||||
const rows: CollectorRowItem[] = collectors.map((row) => ({
|
||||
id: row.id,
|
||||
title: row.name,
|
||||
subtitle: [
|
||||
pluralize(row.itemCount, 'Set', 'Sets'),
|
||||
COLLECTOR_COLOR_LABELS[row.color],
|
||||
row.birthday ? `geboren am ${formatDate(row.birthday)}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · '),
|
||||
color: row.color,
|
||||
birthday: row.birthday,
|
||||
notes: row.notes,
|
||||
itemCount: row.itemCount,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="rounded-lg border border-dashed border-border p-4 text-sm text-muted-foreground">
|
||||
Der Katalog ist gemeinsam: Ein Set existiert einmal, unabhängig davon, wer es hat.
|
||||
Sammlung und Wunschlisten gehören dagegen je einem Sammler. Solange es nur einen gibt,
|
||||
taucht die Unterscheidung nirgends in der App auf – sie erscheint erst mit dem zweiten.
|
||||
</p>
|
||||
<CollectorList collectors={rows} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,10 +8,12 @@ import { WishlistHeaderActions } from '@/components/wunschlisten/wishlist-header
|
||||
import { WishlistItems } from '@/components/wunschlisten/wishlist-items';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { collectorBadgeClass } from '@/lib/collector-colors';
|
||||
import { formatDate, pluralize } from '@/lib/format';
|
||||
import { occasionLabel } from '@/lib/labels';
|
||||
import { CATALOG_PAGE_SIZE, listSets, listSetYears } from '@/lib/queries/catalog';
|
||||
import { listManufacturers, listThemes } from '@/lib/queries/master-data';
|
||||
import { listCollectors } from '@/lib/queries/collectors';
|
||||
import { getWishlist, listWishlistItems } from '@/lib/queries/wishlists';
|
||||
import { requireUser } from '@/lib/session';
|
||||
|
||||
@@ -39,7 +41,9 @@ export default async function WunschlisteDetailPage({ params, searchParams }: Pr
|
||||
const list = getWishlist(id);
|
||||
if (!list || list.ownerUserId !== user.id) notFound();
|
||||
|
||||
const items = listWishlistItems(list.id);
|
||||
const collectors = listCollectors();
|
||||
const activeCollector = collectors.find((row) => row.id === list.collectorId) ?? null;
|
||||
const items = listWishlistItems(list.id, list.collectorId);
|
||||
|
||||
// The embedded picker: missing sets that are not on this list yet. It shares
|
||||
// the URL parameters with the catalog, so the filters behave identically.
|
||||
@@ -52,6 +56,7 @@ export default async function WunschlisteDetailPage({ params, searchParams }: Pr
|
||||
year: yearParam ? Number(yearParam) : undefined,
|
||||
ownership: 'fehlt',
|
||||
excludeWishlistId: list.id,
|
||||
collectorId: list.collectorId ?? undefined,
|
||||
page,
|
||||
});
|
||||
|
||||
@@ -63,6 +68,11 @@ export default async function WunschlisteDetailPage({ params, searchParams }: Pr
|
||||
<Badge variant="secondary">{occasionLabel(list.occasion)}</Badge>
|
||||
{list.eventDate ? <Badge variant="outline">{formatDate(list.eventDate)}</Badge> : null}
|
||||
{list.surpriseMode ? <Badge variant="outline">Überraschungsmodus</Badge> : null}
|
||||
{collectors.length > 1 && activeCollector ? (
|
||||
<Badge className={collectorBadgeClass(activeCollector.color)}>
|
||||
für {activeCollector.name}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<h1 className="mt-2 truncate text-2xl font-semibold tracking-tight">{list.title}</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
@@ -70,8 +80,10 @@ export default async function WunschlisteDetailPage({ params, searchParams }: Pr
|
||||
</p>
|
||||
</div>
|
||||
<WishlistHeaderActions
|
||||
collectors={collectors.map((row) => ({ id: row.id, name: row.name }))}
|
||||
wishlist={{
|
||||
id: list.id,
|
||||
collectorId: list.collectorId,
|
||||
title: list.title,
|
||||
occasion: list.occasion,
|
||||
eventDate: list.eventDate,
|
||||
@@ -99,7 +111,9 @@ export default async function WunschlisteDetailPage({ params, searchParams }: Pr
|
||||
<CardHeader>
|
||||
<CardTitle>Aus fehlenden Sets hinzufügen</CardTitle>
|
||||
<CardDescription>
|
||||
Nur Sets, die noch nicht in der Sammlung und noch nicht auf dieser Liste sind. Kachel
|
||||
{activeCollector
|
||||
? `Nur Sets, die ${activeCollector.name} noch nicht hat und die noch nicht auf dieser Liste stehen.`
|
||||
: 'Nur Sets, die noch nicht in der Sammlung und noch nicht auf dieser Liste sind.'}{' '} Kachel
|
||||
lange drücken oder „Auswählen“ tippen, dann mehrere auf einmal übernehmen.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
@@ -124,6 +138,7 @@ export default async function WunschlisteDetailPage({ params, searchParams }: Pr
|
||||
sets={missing.rows}
|
||||
wishlists={[{ id: list.id, title: list.title }]}
|
||||
defaultWishlistId={list.id}
|
||||
collectorId={list.collectorId ?? undefined}
|
||||
emptyState="Es fehlt gerade nichts, was noch nicht auf dieser Liste steht."
|
||||
/>
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type ActionResult,
|
||||
} from '@/lib/action-result';
|
||||
import { ownedCopiesSql } from '@/lib/ownership';
|
||||
import { defaultCollectorId } from '@/lib/queries/collectors';
|
||||
import {
|
||||
findExistingWishlistSetIds,
|
||||
getWishlist,
|
||||
@@ -58,6 +59,7 @@ export async function createWishlist(input: unknown): Promise<ActionResult<{ id:
|
||||
.values({
|
||||
id,
|
||||
ownerUserId: user.id,
|
||||
collectorId: parsed.data.collectorId ?? defaultCollectorId(),
|
||||
title: parsed.data.title,
|
||||
occasion: parsed.data.occasion,
|
||||
eventDate: parsed.data.eventDate,
|
||||
@@ -83,6 +85,7 @@ export async function updateWishlist(id: string, input: unknown): Promise<Action
|
||||
|
||||
db.update(wishlist)
|
||||
.set({
|
||||
collectorId: parsed.data.collectorId,
|
||||
title: parsed.data.title,
|
||||
occasion: parsed.data.occasion,
|
||||
eventDate: parsed.data.eventDate,
|
||||
@@ -153,7 +156,7 @@ export async function addSetsToWishlist(
|
||||
id: set.id,
|
||||
name: set.name,
|
||||
setNumber: set.setNumber,
|
||||
ownedCopies: ownedCopiesSql(set.id).mapWith(Number),
|
||||
ownedCopies: ownedCopiesSql(set.id, list.collectorId).mapWith(Number),
|
||||
})
|
||||
.from(set)
|
||||
.where(inArray(set.id, setIds))
|
||||
|
||||
@@ -4,8 +4,10 @@ import { CalendarDays, Gift, Link2Off } from 'lucide-react';
|
||||
import { NewWishlistButton } from '@/components/wunschlisten/wishlist-form-dialog';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { collectorBadgeClass } from '@/lib/collector-colors';
|
||||
import { formatDate, pluralize } from '@/lib/format';
|
||||
import { occasionLabel } from '@/lib/labels';
|
||||
import { listCollectors } from '@/lib/queries/collectors';
|
||||
import { listWishlists } from '@/lib/queries/wishlists';
|
||||
import { requireUser } from '@/lib/session';
|
||||
|
||||
@@ -14,6 +16,7 @@ export const metadata: Metadata = { title: 'Wunschlisten' };
|
||||
export default async function WunschlistenPage() {
|
||||
const user = await requireUser();
|
||||
const lists = listWishlists(user.id);
|
||||
const collectors = listCollectors();
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
@@ -24,7 +27,9 @@ export default async function WunschlistenPage() {
|
||||
Zum Teilen mit Familie und Freunden – ganz ohne Konto für die anderen.
|
||||
</p>
|
||||
</div>
|
||||
<NewWishlistButton />
|
||||
<NewWishlistButton
|
||||
collectors={collectors.map((row) => ({ id: row.id, name: row.name }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{lists.length === 0 ? (
|
||||
@@ -54,6 +59,11 @@ export default async function WunschlistenPage() {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap items-center gap-2 text-xs">
|
||||
{collectors.length > 1 && list.collectorName ? (
|
||||
<Badge className={collectorBadgeClass(list.collectorColor)}>
|
||||
{list.collectorName}
|
||||
</Badge>
|
||||
) : null}
|
||||
<Badge variant="secondary">{pluralize(list.itemCount, 'Wunsch', 'Wünsche')}</Badge>
|
||||
{list.openCount > 0 ? (
|
||||
<Badge variant="outline">{list.openCount} noch offen</Badge>
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import Link from 'next/link';
|
||||
import { Blocks } from 'lucide-react';
|
||||
import { BrickLogo } from '@/components/brick/logo';
|
||||
|
||||
export default function AuthLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex min-h-dvh flex-col items-center justify-center gap-8 px-4 py-10">
|
||||
<Link href="/" className="flex items-center gap-2 text-xl font-semibold tracking-tight">
|
||||
<Blocks className="size-6 text-primary" aria-hidden />
|
||||
<Link
|
||||
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
|
||||
</Link>
|
||||
<main className="w-full max-w-sm">{children}</main>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/db';
|
||||
import { ensureReminderScheduler } from '@/lib/reminder-job';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
@@ -9,6 +10,10 @@ export const dynamic = 'force-dynamic';
|
||||
* file is readable - a healthy Node process on a broken volume is not healthy.
|
||||
*/
|
||||
export function GET() {
|
||||
// The container polls this every 30s, which makes it the earliest reliable
|
||||
// moment to have the reminder scheduler running. Repeat calls are free.
|
||||
ensureReminderScheduler();
|
||||
|
||||
try {
|
||||
db.get(sql`select 1`);
|
||||
return NextResponse.json({ status: 'ok', time: new Date().toISOString() });
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
@import "./designs.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@@ -10,6 +11,7 @@
|
||||
"Segoe UI Emoji";
|
||||
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
|
||||
"Liberation Mono", monospace;
|
||||
--font-display: var(--font-sans);
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +20,16 @@
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-sans);
|
||||
--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-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
@@ -90,6 +101,21 @@
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 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 {
|
||||
@@ -136,4 +162,9 @@
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
}␍
|
||||
@@ -1,6 +1,14 @@
|
||||
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 { Toaster } from '@/components/ui/sonner';
|
||||
import { DESIGN_COOKIE, DESIGN_INFO, resolveDesign } from '@/lib/design';
|
||||
import './globals.css';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -25,19 +33,30 @@ export const metadata: Metadata = {
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
viewportFit: 'cover',
|
||||
themeColor: [
|
||||
{ media: '(prefers-color-scheme: light)', color: '#ffffff' },
|
||||
{ media: '(prefers-color-scheme: dark)', color: '#0a0a0a' },
|
||||
],
|
||||
};
|
||||
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',
|
||||
initialScale: 1,
|
||||
viewportFit: 'cover',
|
||||
themeColor: DESIGN_INFO[design].themeColor,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||
const design = await currentDesign();
|
||||
|
||||
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||
return (
|
||||
<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">
|
||||
{children}
|
||||
<Toaster position="top-center" richColors closeButton />
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Metadata } from 'next';
|
||||
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 { Badge } from '@/components/ui/badge';
|
||||
import { formatDate, pluralize } from '@/lib/format';
|
||||
@@ -27,7 +29,7 @@ export default async function PublicWishlistPage({ params }: Props) {
|
||||
const list = getWishlistByShareToken(shareToken);
|
||||
if (!list || !list.isPublic) notFound();
|
||||
|
||||
const rows = listWishlistItems(list.id);
|
||||
const rows = listWishlistItems(list.id, list.collectorId);
|
||||
|
||||
// Fulfilled wishes are gone from the public view: the present is already
|
||||
// under the tree, no reason to let someone buy it twice.
|
||||
@@ -54,50 +56,56 @@ export default async function PublicWishlistPage({ params }: Props) {
|
||||
const openCount = items.filter((item) => !item.isReserved).length;
|
||||
|
||||
return (
|
||||
<div className="mx-auto min-h-dvh w-full max-w-2xl px-4 py-8">
|
||||
<header className="space-y-3 border-b border-border pb-6">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Blocks className="size-4 text-primary" aria-hidden />
|
||||
Bricktrack
|
||||
</div>
|
||||
<div className="min-h-dvh">
|
||||
<header className="border-b-[length:max(1px,var(--edge-w))] border-edge bg-primary text-primary-foreground">
|
||||
<div className="mx-auto w-full max-w-2xl space-y-3 px-4 pt-6 pb-10">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<BrickLogo className="h-5 w-6" />
|
||||
Bricktrack
|
||||
</div>
|
||||
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{list.title}</h1>
|
||||
<h1 className="text-3xl font-semibold tracking-tight">{list.title}</h1>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Gift className="size-3" aria-hidden />
|
||||
{occasionLabel(list.occasion)}
|
||||
</Badge>
|
||||
{list.eventDate ? (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<CalendarDays className="size-3" aria-hidden />
|
||||
{formatDate(list.eventDate)}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge className="gap-1 bg-card text-card-foreground">
|
||||
<Gift className="size-3" aria-hidden />
|
||||
{occasionLabel(list.occasion)}
|
||||
</Badge>
|
||||
) : null}
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{pluralize(openCount, 'Wunsch noch offen', 'Wünsche noch offen')}
|
||||
</span>
|
||||
{list.eventDate ? (
|
||||
<Badge className="gap-1 bg-card text-card-foreground">
|
||||
<CalendarDays className="size-3" aria-hidden />
|
||||
{formatDate(list.eventDate)}
|
||||
</Badge>
|
||||
) : null}
|
||||
<Badge variant="secondary">
|
||||
{pluralize(openCount, 'Wunsch noch offen', 'Wünsche noch offen')}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{list.note ? (
|
||||
<p className="rounded-lg bg-muted p-3 text-sm whitespace-pre-line">{list.note}</p>
|
||||
) : null}
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Klick auf „Das schenke ich“, damit niemand dasselbe doppelt kauft. Eine Anmeldung
|
||||
brauchst du dafür nicht.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<main className="py-6">
|
||||
{items.length === 0 ? (
|
||||
<p className="rounded-lg border border-dashed border-border p-8 text-center text-sm text-muted-foreground">
|
||||
Auf dieser Liste steht gerade nichts.
|
||||
</p>
|
||||
) : (
|
||||
<PublicWishlist shareToken={shareToken} items={items} />
|
||||
)}
|
||||
</main>
|
||||
<div className="mx-auto w-full max-w-2xl px-4 pb-8">
|
||||
<div className="relative -mt-5">
|
||||
<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">
|
||||
Klick auf „Das schenke ich“, damit niemand dasselbe doppelt kauft. Eine Anmeldung
|
||||
brauchst du dafür nicht.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main className="py-6">
|
||||
{items.length === 0 ? (
|
||||
<p className="rounded-lg border border-dashed border-border p-8 text-center text-sm text-muted-foreground">
|
||||
Auf dieser Liste steht gerade nichts.
|
||||
</p>
|
||||
) : (
|
||||
<PublicWishlist shareToken={shareToken} items={items} />
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -25,13 +25,18 @@ type Controls = { stop: () => void };
|
||||
* loaded only when the scanner is opened - nothing leaves the device, and the
|
||||
* catalog pages stay light for everyone who never scans.
|
||||
*/
|
||||
export function BarcodeScannerButton() {
|
||||
export function BarcodeScannerDialog({
|
||||
open,
|
||||
onOpenChange: setOpen,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const controlsRef = useRef<Controls | null>(null);
|
||||
const handledRef = useRef(false);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [status, setStatus] = useState<'starting' | 'scanning' | 'checking' | 'error'>('starting');
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
|
||||
@@ -68,7 +73,7 @@ export function BarcodeScannerButton() {
|
||||
router.refresh();
|
||||
});
|
||||
},
|
||||
[router, stop],
|
||||
[router, setOpen, stop],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -134,6 +139,61 @@ export function BarcodeScannerButton() {
|
||||
};
|
||||
}, [open, onDetected, stop]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) stop();
|
||||
setOpen(next);
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Barcode scannen</DialogTitle>
|
||||
<DialogDescription>
|
||||
Halte den Strichcode auf der Packung ins Bild. Kennt der Katalog das Set schon,
|
||||
landest du direkt darauf – sonst im Formular mit ausgefüllter EAN.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{status === 'error' ? (
|
||||
<p className="rounded-lg border border-dashed border-border p-4 text-sm text-muted-foreground">
|
||||
{message}
|
||||
</p>
|
||||
) : (
|
||||
<div className="relative overflow-hidden rounded-lg bg-black">
|
||||
<video
|
||||
ref={videoRef}
|
||||
className="aspect-[3/4] w-full object-cover"
|
||||
muted
|
||||
playsInline
|
||||
aria-label="Kamerabild"
|
||||
/>
|
||||
{/* A guide line makes people hold the box the right way round. */}
|
||||
<div className="pointer-events-none absolute inset-x-6 top-1/2 h-0.5 -translate-y-1/2 bg-red-500/80" />
|
||||
|
||||
{status !== 'scanning' ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center gap-2 bg-black/60 text-sm text-white">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
{status === 'checking' ? 'Im Katalog nachsehen …' : 'Kamera startet …'}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function BarcodeScannerButton() {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
@@ -145,55 +205,7 @@ export function BarcodeScannerButton() {
|
||||
>
|
||||
<ScanLine />
|
||||
</Button>
|
||||
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) stop();
|
||||
setOpen(next);
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Barcode scannen</DialogTitle>
|
||||
<DialogDescription>
|
||||
Halte den Strichcode auf der Packung ins Bild. Kennt der Katalog das Set schon,
|
||||
landest du direkt darauf – sonst im Formular mit ausgefüllter EAN.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{status === 'error' ? (
|
||||
<p className="rounded-lg border border-dashed border-border p-4 text-sm text-muted-foreground">
|
||||
{message}
|
||||
</p>
|
||||
) : (
|
||||
<div className="relative overflow-hidden rounded-lg bg-black">
|
||||
<video
|
||||
ref={videoRef}
|
||||
className="aspect-[3/4] w-full object-cover"
|
||||
muted
|
||||
playsInline
|
||||
aria-label="Kamerabild"
|
||||
/>
|
||||
{/* A guide line makes people hold the box the right way round. */}
|
||||
<div className="pointer-events-none absolute inset-x-6 top-1/2 h-0.5 -translate-y-1/2 bg-red-500/80" />
|
||||
|
||||
{status !== 'scanning' ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center gap-2 bg-black/60 text-sm text-white">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
{status === 'checking' ? 'Im Katalog nachsehen …' : 'Kamera startet …'}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<BarcodeScannerDialog open={open} onOpenChange={setOpen} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,6 +33,8 @@ type Props = {
|
||||
wishlists: WishlistTarget[];
|
||||
/** Preselected target, used by the embedded view on a wishlist page. */
|
||||
defaultWishlistId?: string;
|
||||
/** A list created from a filtered view belongs to that collector. */
|
||||
collectorId?: string;
|
||||
emptyState?: React.ReactNode;
|
||||
};
|
||||
|
||||
@@ -43,7 +45,13 @@ type Props = {
|
||||
* On a desktop the "Auswählen" button does the same job. Sets that are already
|
||||
* in the collection cannot be selected - they do not belong on a wishlist.
|
||||
*/
|
||||
export function CatalogGrid({ sets, wishlists, defaultWishlistId, emptyState }: Props) {
|
||||
export function CatalogGrid({
|
||||
sets,
|
||||
wishlists,
|
||||
defaultWishlistId,
|
||||
collectorId,
|
||||
emptyState,
|
||||
}: Props) {
|
||||
const router = useRouter();
|
||||
const [selecting, setSelecting] = useState(false);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
@@ -102,6 +110,7 @@ export function CatalogGrid({ sets, wishlists, defaultWishlistId, emptyState }:
|
||||
title: newTitle,
|
||||
occasion: 'other',
|
||||
isPublic: 'on',
|
||||
collectorId: collectorId ?? '',
|
||||
});
|
||||
if (!created.ok) {
|
||||
toast.error(created.error);
|
||||
@@ -199,6 +208,7 @@ export function CatalogGrid({ sets, wishlists, defaultWishlistId, emptyState }:
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
data-set-card
|
||||
disabled={owned}
|
||||
aria-pressed={isSelected}
|
||||
onClick={() => toggle(item.id)}
|
||||
@@ -207,7 +217,8 @@ export function CatalogGrid({ sets, wishlists, defaultWishlistId, emptyState }:
|
||||
'relative rounded-xl text-left outline-none transition-opacity',
|
||||
'focus-visible:ring-3 focus-visible:ring-ring/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} />
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState, useTransition } from 'react';
|
||||
import { Loader2, Plus, TrendingDown, TrendingUp, Trash2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { addPricePoint, deletePricePoint } from '@/app/(app)/katalog/price-actions';
|
||||
import { formatDate, toDateInputValue } from '@/lib/format';
|
||||
import { formatEuro } from '@/lib/money';
|
||||
import { priceTrend, type PriceObservation } from '@/lib/prices';
|
||||
import type { PricePointRow } from '@/lib/queries/catalog';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Field } from '@/components/ui/field';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { NativeSelect } from '@/components/ui/native-select';
|
||||
|
||||
const CONDITION_LABELS: Record<string, string> = {
|
||||
new: 'Neu',
|
||||
used: 'Gebraucht',
|
||||
};
|
||||
|
||||
/**
|
||||
* Observed prices for one set.
|
||||
*
|
||||
* The point is not the single current price but the development: what a set
|
||||
* did since it was bought. Everything here is maintained by hand - no price
|
||||
* service is queried, and nothing leaves the server.
|
||||
*/
|
||||
export function PriceSection({
|
||||
setId,
|
||||
prices,
|
||||
retailPriceCents,
|
||||
}: {
|
||||
setId: string;
|
||||
prices: PricePointRow[];
|
||||
retailPriceCents: number | null;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const observations: PriceObservation[] = prices.map((price) => ({
|
||||
priceCents: price.priceCents,
|
||||
condition: price.condition,
|
||||
observedAt: price.observedAt,
|
||||
}));
|
||||
const trend = priceTrend(observations, retailPriceCents);
|
||||
const gained = (trend.changeCents ?? 0) > 0;
|
||||
|
||||
function submit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const form = event.currentTarget;
|
||||
const input = Object.fromEntries(new FormData(form).entries());
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await addPricePoint(setId, input);
|
||||
if (!result.ok) {
|
||||
toast.error(result.error);
|
||||
return;
|
||||
}
|
||||
form.reset();
|
||||
setOpen(false);
|
||||
toast.success('Preis notiert.');
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
function remove(id: string) {
|
||||
startTransition(async () => {
|
||||
const result = await deletePricePoint(id);
|
||||
if (!result.ok) {
|
||||
toast.error(result.error);
|
||||
return;
|
||||
}
|
||||
toast.success('Eintrag gelöscht.');
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<dl className="grid grid-cols-3 gap-3 text-sm">
|
||||
<div>
|
||||
<dt className="text-xs text-muted-foreground">Ursprünglich</dt>
|
||||
<dd className="font-medium">{formatEuro(trend.baseCents)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs text-muted-foreground">Aktuell</dt>
|
||||
<dd className="font-medium">{formatEuro(trend.currentCents)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs text-muted-foreground">Entwicklung</dt>
|
||||
<dd>
|
||||
{trend.changePercent === null ? (
|
||||
<span className="text-muted-foreground">–</span>
|
||||
) : (
|
||||
<span
|
||||
className={`flex items-center gap-1 font-medium ${
|
||||
gained ? 'text-emerald-600' : 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{gained ? (
|
||||
<TrendingUp className="size-4" aria-hidden />
|
||||
) : (
|
||||
<TrendingDown className="size-4" aria-hidden />
|
||||
)}
|
||||
{trend.changePercent > 0 ? '+' : ''}
|
||||
{trend.changePercent.toLocaleString('de-DE')} %
|
||||
</span>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{prices.length > 0 ? (
|
||||
<ul className="divide-y divide-border rounded-lg border border-border">
|
||||
{prices.map((price) => (
|
||||
<li key={price.id} className="flex items-center gap-3 p-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">
|
||||
{formatEuro(price.priceCents)}{' '}
|
||||
<Badge variant="secondary">{CONDITION_LABELS[price.condition]}</Badge>
|
||||
</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{[formatDate(price.observedAt), price.source, price.note]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Preis löschen"
|
||||
onClick={() => remove(price.id)}
|
||||
disabled={pending}
|
||||
>
|
||||
<Trash2 className="text-destructive" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Noch kein Preis notiert. Der Ausgangswert ist die UVP des Sets.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{open ? (
|
||||
<form onSubmit={submit} className="space-y-3 rounded-lg border border-border p-3">
|
||||
<p className="text-sm font-medium">Preis notieren</p>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<Field label="Preis" htmlFor="priceCents" required hint="In Euro.">
|
||||
<Input
|
||||
id="priceCents"
|
||||
name="priceCents"
|
||||
inputMode="decimal"
|
||||
required
|
||||
placeholder="79,99"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Zustand" htmlFor="condition">
|
||||
<NativeSelect id="condition" name="condition" defaultValue="new">
|
||||
<option value="new">Neu</option>
|
||||
<option value="used">Gebraucht</option>
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
<Field label="Gesehen am" htmlFor="observedAt" hint="Leer = heute.">
|
||||
<Input
|
||||
id="observedAt"
|
||||
name="observedAt"
|
||||
type="date"
|
||||
defaultValue={toDateInputValue(new Date())}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Field label="Wo" htmlFor="source">
|
||||
<Input id="source" name="source" maxLength={80} placeholder="Amazon, Flohmarkt …" />
|
||||
</Field>
|
||||
<Field label="Notiz" htmlFor="price-note">
|
||||
<Input id="price-note" name="note" maxLength={200} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending ? <Loader2 className="animate-spin" /> : <Plus />}
|
||||
Speichern
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<Button variant="outline" onClick={() => setOpen(true)}>
|
||||
<Plus />
|
||||
Preis notieren
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import { Input } from '@/components/ui/input';
|
||||
* One-field Lego import: type the number from the box, get a finished catalog
|
||||
* 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 [setNumber, setSetNumber] = useState('');
|
||||
const [pending, startTransition] = useTransition();
|
||||
@@ -80,6 +80,7 @@ export function RebrickableImport({ enabled }: { enabled: boolean }) {
|
||||
onChange={(event) => setSetNumber(event.target.value)}
|
||||
placeholder="60398"
|
||||
inputMode="numeric"
|
||||
autoFocus={autoFocus}
|
||||
maxLength={40}
|
||||
className="font-mono"
|
||||
/>
|
||||
|
||||
@@ -38,32 +38,47 @@ export function SetActions({
|
||||
setName,
|
||||
owned,
|
||||
wishlists,
|
||||
collectors,
|
||||
}: {
|
||||
setId: string;
|
||||
setName: string;
|
||||
owned: boolean;
|
||||
wishlists: Array<{ id: string; title: string }>;
|
||||
/** More than one turns "Zur Sammlung" into a question of "for whom". */
|
||||
collectors: Array<{ id: string; name: string }>;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [wishlistOpen, setWishlistOpen] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [targetId, setTargetId] = useState(wishlists[0]?.id ?? NEW_LIST);
|
||||
const [collectorOpen, setCollectorOpen] = useState(false);
|
||||
const [collectorId, setCollectorId] = useState(collectors[0]?.id ?? '');
|
||||
const [newTitle, setNewTitle] = useState('');
|
||||
const [priority, setPriority] = useState('2');
|
||||
|
||||
function addToCollection() {
|
||||
function addToCollection(forCollectorId?: string) {
|
||||
startTransition(async () => {
|
||||
const result = await addSetToCollection(setId);
|
||||
const result = await addSetToCollection(setId, forCollectorId);
|
||||
if (!result.ok) {
|
||||
toast.error(result.error);
|
||||
return;
|
||||
}
|
||||
toast.success('Zur Sammlung hinzugefügt.');
|
||||
const owner = collectors.find((entry) => entry.id === forCollectorId);
|
||||
toast.success(
|
||||
owner ? `Zur Sammlung von ${owner.name} hinzugefügt.` : 'Zur Sammlung hinzugefügt.',
|
||||
);
|
||||
setCollectorOpen(false);
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
// With a single collector there is nothing to ask.
|
||||
function onAddClick() {
|
||||
if (collectors.length > 1) setCollectorOpen(true);
|
||||
else addToCollection();
|
||||
}
|
||||
|
||||
function addToWishlist() {
|
||||
startTransition(async () => {
|
||||
let wishlistId = targetId;
|
||||
@@ -111,7 +126,7 @@ export function SetActions({
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="lg" onClick={addToCollection} disabled={pending}>
|
||||
<Button size="lg" onClick={onAddClick} disabled={pending}>
|
||||
{pending ? <Loader2 className="animate-spin" /> : <Plus />}
|
||||
Zur Sammlung hinzufügen
|
||||
</Button>
|
||||
@@ -138,6 +153,39 @@ export function SetActions({
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<Dialog open={collectorOpen} onOpenChange={setCollectorOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Wer bekommt das Set?</DialogTitle>
|
||||
<DialogDescription>{setName}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Field label="Sammler" htmlFor="add-collector" required>
|
||||
<NativeSelect
|
||||
id="add-collector"
|
||||
value={collectorId}
|
||||
onChange={(event) => setCollectorId(event.target.value)}
|
||||
>
|
||||
{collectors.map((entry) => (
|
||||
<option key={entry.id} value={entry.id}>
|
||||
{entry.name}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCollectorOpen(false)} disabled={pending}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button onClick={() => addToCollection(collectorId)} disabled={pending}>
|
||||
{pending ? <Loader2 className="animate-spin" /> : <Plus />}
|
||||
Hinzufügen
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={wishlistOpen} onOpenChange={setWishlistOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import Link from 'next/link';
|
||||
import { Check, Gift, ImageOff } from 'lucide-react';
|
||||
import { Check, Gift } from 'lucide-react';
|
||||
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 { formatEuro } from '@/lib/money';
|
||||
import { setImageSrc } from '@/lib/images';
|
||||
@@ -17,56 +19,59 @@ export function SetCardContent({ set }: { set: CatalogRow }) {
|
||||
const owned = set.ownedCopies > 0;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden rounded-xl border border-border bg-card transition-colors">
|
||||
<div className="relative aspect-square bg-muted">
|
||||
{image ? (
|
||||
// Plain img: images are either local files or a URL the user entered,
|
||||
// and the optimizer must never fetch arbitrary remote hosts.
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={image}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="size-full object-contain p-2"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex size-full items-center justify-center text-muted-foreground">
|
||||
<ImageOff className="size-8" aria-hidden />
|
||||
<div className="relative h-full">
|
||||
<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 ? (
|
||||
// Plain img: images are either local files or a URL the user entered,
|
||||
// and the optimizer must never fetch arbitrary remote hosts.
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={image}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="size-full object-contain p-2"
|
||||
/>
|
||||
) : (
|
||||
<SetPlaceholder setNumber={set.setNumber} manufacturerName={set.manufacturerName} />
|
||||
)}
|
||||
|
||||
<div className="absolute top-2 left-2 flex flex-col items-start gap-1">
|
||||
{owned ? (
|
||||
<Badge className="gap-1 bg-owned text-owned-foreground">
|
||||
<Check className="size-3" aria-hidden />
|
||||
{set.ownedCopies > 1 ? `${set.ownedCopies}× vorhanden` : 'Vorhanden'}
|
||||
</Badge>
|
||||
) : null}
|
||||
{set.wishlistCount > 0 ? (
|
||||
<Badge className="gap-1">
|
||||
<Gift className="size-3" aria-hidden />
|
||||
Auf Wunschliste
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="absolute top-2 left-2 flex flex-col items-start gap-1">
|
||||
{owned ? (
|
||||
<Badge className="gap-1 bg-emerald-600 text-white hover:bg-emerald-600">
|
||||
<Check className="size-3" aria-hidden />
|
||||
{set.ownedCopies > 1 ? `${set.ownedCopies}× vorhanden` : 'Vorhanden'}
|
||||
</Badge>
|
||||
) : null}
|
||||
{set.wishlistCount > 0 ? (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Gift className="size-3" aria-hidden />
|
||||
Auf Wunschliste
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-1 p-3">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Badge variant="outline" className="font-mono">
|
||||
{set.setNumber}
|
||||
</Badge>
|
||||
<span className="truncate">{set.manufacturerName}</span>
|
||||
</div>
|
||||
<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">
|
||||
<span className="truncate">{[set.themeName, set.year].filter(Boolean).join(' · ')}</span>
|
||||
{set.retailPriceCents !== null ? (
|
||||
<span className="shrink-0 font-medium text-foreground">
|
||||
{formatEuro(set.retailPriceCents)}
|
||||
<div className="flex flex-1 flex-col gap-1 p-3">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Badge variant="outline" className="font-mono">
|
||||
{set.setNumber}
|
||||
</Badge>
|
||||
<span className="truncate">{set.manufacturerName}</span>
|
||||
</div>
|
||||
<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">
|
||||
<span className="truncate">
|
||||
{[set.themeName, set.year].filter(Boolean).join(' · ')}
|
||||
</span>
|
||||
) : null}
|
||||
{set.retailPriceCents !== null ? (
|
||||
<span className="shrink-0 font-medium text-foreground">
|
||||
{formatEuro(set.retailPriceCents)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -78,7 +83,7 @@ export function SetCard({ set, className }: { set: CatalogRow; className?: strin
|
||||
<Link
|
||||
href={`/katalog/${set.id}`}
|
||||
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,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,13 +1,28 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState, useTransition } from 'react';
|
||||
import { useRef, useState, useTransition } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Loader2, Save } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { createSet, updateSet } from '@/app/(app)/katalog/actions';
|
||||
import {
|
||||
findDuplicateCandidates,
|
||||
type DuplicateCandidate,
|
||||
} from '@/app/(app)/katalog/duplicate-actions';
|
||||
import { centsToInput } from '@/lib/money';
|
||||
import { setImageSrc } from '@/lib/images';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Field } from '@/components/ui/field';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -32,20 +47,29 @@ export type SetFormValues = {
|
||||
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 = {
|
||||
manufacturers: Array<{ id: string; name: string }>;
|
||||
themes: Array<{ id: string; name: string; manufacturerId: string }>;
|
||||
set?: SetFormValues;
|
||||
/** Prefilled EAN coming from the barcode scanner (phase 2). */
|
||||
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 [pending, startTransition] = useTransition();
|
||||
const [manufacturerId, setManufacturerId] = useState(
|
||||
set?.manufacturerId ?? manufacturers[0]?.id ?? '',
|
||||
set?.manufacturerId ?? prefill?.manufacturerId ?? manufacturers[0]?.id ?? '',
|
||||
);
|
||||
const [duplicates, setDuplicates] = useState<DuplicateCandidate[] | null>(null);
|
||||
const pendingForm = useRef<FormData | null>(null);
|
||||
|
||||
const existingImage = set ? setImageSrc(set) : null;
|
||||
const availableThemes = themes.filter((theme) => theme.manufacturerId === manufacturerId);
|
||||
@@ -54,32 +78,70 @@ export function SetForm({ manufacturers, themes, set, defaultEan }: Props) {
|
||||
event.preventDefault();
|
||||
const formData = new FormData(event.currentTarget);
|
||||
|
||||
startTransition(async () => {
|
||||
if (set) {
|
||||
const result = await updateSet(set.id, formData);
|
||||
if (!result.ok) {
|
||||
toast.error(result.error);
|
||||
// Ask before creating something that already exists. Editing skips this:
|
||||
// there the entry is the one we would be warning about.
|
||||
if (!set) {
|
||||
startTransition(async () => {
|
||||
const check = await findDuplicateCandidates({
|
||||
manufacturerId: String(formData.get('manufacturerId') ?? ''),
|
||||
setNumber: String(formData.get('setNumber') ?? ''),
|
||||
name: String(formData.get('name') ?? ''),
|
||||
});
|
||||
|
||||
if (check.ok && check.data.candidates.length > 0) {
|
||||
pendingForm.current = formData;
|
||||
setDuplicates(check.data.candidates);
|
||||
return;
|
||||
}
|
||||
toast.success('Set gespeichert.');
|
||||
router.push(`/katalog/${set.id}`);
|
||||
router.refresh();
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await createSet(formData);
|
||||
if (!result.ok) {
|
||||
toast.error(result.error);
|
||||
return;
|
||||
}
|
||||
toast.success('Set angelegt.');
|
||||
router.push(`/katalog/${result.data.id}`);
|
||||
router.refresh();
|
||||
await save(formData);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
startTransition(() => save(formData));
|
||||
}
|
||||
|
||||
async function save(formData: FormData) {
|
||||
return new Promise<void>((resolve) => {
|
||||
startTransition(async () => {
|
||||
if (set) {
|
||||
const result = await updateSet(set.id, formData);
|
||||
if (!result.ok) {
|
||||
toast.error(result.error);
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
toast.success('Set gespeichert.');
|
||||
router.push(`/katalog/${set.id}`);
|
||||
router.refresh();
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await createSet(formData);
|
||||
if (!result.ok) {
|
||||
toast.error(result.error);
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
toast.success('Set angelegt.');
|
||||
router.push(`/katalog/${result.data.id}`);
|
||||
router.refresh();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const REASON_LABELS: Record<DuplicateCandidate['reason'], string> = {
|
||||
'same-number': 'Gleiche Setnummer beim gleichen Hersteller',
|
||||
'similar-name': 'Sehr ähnlicher Name',
|
||||
'number-elsewhere': 'Gleiche Setnummer bei einem anderen Hersteller',
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="space-y-6">
|
||||
<>
|
||||
<form onSubmit={onSubmit} className="space-y-6">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label="Hersteller" htmlFor="manufacturerId" required>
|
||||
<NativeSelect
|
||||
@@ -112,7 +174,7 @@ export function SetForm({ manufacturers, themes, set, defaultEan }: Props) {
|
||||
<Input
|
||||
id="setNumber"
|
||||
name="setNumber"
|
||||
defaultValue={set?.setNumber ?? ''}
|
||||
defaultValue={set?.setNumber ?? prefill?.setNumber ?? ''}
|
||||
required
|
||||
maxLength={40}
|
||||
placeholder="60398"
|
||||
@@ -124,7 +186,7 @@ export function SetForm({ manufacturers, themes, set, defaultEan }: Props) {
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
defaultValue={set?.name ?? ''}
|
||||
defaultValue={set?.name ?? prefill?.name ?? ''}
|
||||
required
|
||||
maxLength={160}
|
||||
placeholder="Familienhaus und Elektroauto"
|
||||
@@ -137,7 +199,7 @@ export function SetForm({ manufacturers, themes, set, defaultEan }: Props) {
|
||||
name="year"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
defaultValue={set?.year ?? ''}
|
||||
defaultValue={set?.year ?? prefill?.year ?? ''}
|
||||
min={1930}
|
||||
max={new Date().getFullYear() + 5}
|
||||
/>
|
||||
@@ -149,7 +211,7 @@ export function SetForm({ manufacturers, themes, set, defaultEan }: Props) {
|
||||
name="pieceCount"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
defaultValue={set?.pieceCount ?? ''}
|
||||
defaultValue={set?.pieceCount ?? prefill?.pieceCount ?? ''}
|
||||
min={0}
|
||||
/>
|
||||
</Field>
|
||||
@@ -160,7 +222,7 @@ export function SetForm({ manufacturers, themes, set, defaultEan }: Props) {
|
||||
name="minifigCount"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
defaultValue={set?.minifigCount ?? ''}
|
||||
defaultValue={set?.minifigCount ?? prefill?.minifigCount ?? ''}
|
||||
min={0}
|
||||
/>
|
||||
</Field>
|
||||
@@ -180,7 +242,7 @@ export function SetForm({ manufacturers, themes, set, defaultEan }: Props) {
|
||||
id="ean"
|
||||
name="ean"
|
||||
inputMode="numeric"
|
||||
defaultValue={set?.ean ?? defaultEan ?? ''}
|
||||
defaultValue={set?.ean ?? prefill?.ean ?? defaultEan ?? ''}
|
||||
maxLength={20}
|
||||
className="font-mono"
|
||||
/>
|
||||
@@ -201,10 +263,18 @@ export function SetForm({ manufacturers, themes, set, defaultEan }: Props) {
|
||||
name="imageUrl"
|
||||
type="url"
|
||||
inputMode="url"
|
||||
defaultValue={set?.imageUrl ?? ''}
|
||||
defaultValue={set?.imageUrl ?? prefill?.imageUrl ?? ''}
|
||||
placeholder="https://…"
|
||||
/>
|
||||
</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">
|
||||
<Label htmlFor="image">Bild hochladen</Label>
|
||||
@@ -231,7 +301,12 @@ export function SetForm({ manufacturers, themes, set, defaultEan }: Props) {
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<div className="flex gap-3">
|
||||
@@ -243,6 +318,57 @@ export function SetForm({ manufacturers, themes, set, defaultEan }: Props) {
|
||||
Abbrechen
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</form>
|
||||
|
||||
<AlertDialog
|
||||
open={duplicates !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setDuplicates(null);
|
||||
pendingForm.current = null;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Gibt es das vielleicht schon?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Diese Einträge sehen ähnlich aus. Schau kurz nach, bevor der Katalog zwei Mal
|
||||
dasselbe Set enthält.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
<ul className="divide-y divide-border rounded-lg border border-border">
|
||||
{(duplicates ?? []).map((candidate) => (
|
||||
<li key={candidate.id} className="p-3">
|
||||
<Link
|
||||
href={`/katalog/${candidate.id}`}
|
||||
className="text-sm font-medium hover:underline"
|
||||
>
|
||||
{candidate.setNumber} · {candidate.name}
|
||||
</Link>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{candidate.manufacturerName} — {REASON_LABELS[candidate.reason]}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Zurück zum Formular</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => {
|
||||
const formData = pendingForm.current;
|
||||
setDuplicates(null);
|
||||
pendingForm.current = null;
|
||||
if (formData) void save(formData);
|
||||
}}
|
||||
>
|
||||
Trotzdem anlegen
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,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>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +1,54 @@
|
||||
import { Suspense } from 'react';
|
||||
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 { UserMenu } from './user-menu';
|
||||
import { CollectorSwitch, type CollectorOption } from './collector-switch';
|
||||
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 (
|
||||
<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">
|
||||
<Link href="/" className="flex items-center gap-2 font-semibold tracking-tight">
|
||||
<Blocks className="size-5 text-primary" aria-hidden />
|
||||
<Link
|
||||
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>
|
||||
</Link>
|
||||
<div className="flex-1" />
|
||||
<DesktopNav />
|
||||
<UserMenu user={user} />
|
||||
<DesktopNav legoImport={legoImport} />
|
||||
{/* 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>
|
||||
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,55 +2,85 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
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 { 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 = [
|
||||
{ href: '/', label: 'Start', icon: House },
|
||||
{ href: '/katalog', label: 'Katalog', icon: LayoutGrid },
|
||||
{ href: '/sammlung', label: 'Sammlung', icon: Boxes },
|
||||
{ href: '/wunschlisten', label: 'Wünsche', icon: Gift },
|
||||
{ href: '/konto', label: 'Konto', icon: User },
|
||||
] as const;
|
||||
|
||||
export function BottomNav() {
|
||||
function isActive(pathname: string, href: string) {
|
||||
if (href === '/') return pathname === '/';
|
||||
return pathname === href || pathname.startsWith(`${href}/`);
|
||||
}
|
||||
|
||||
function NavLink({ item, pathname }: { item: (typeof items)[number]; pathname: string }) {
|
||||
const active = isActive(pathname, item.href);
|
||||
return (
|
||||
<Link
|
||||
href={item.href}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
className={cn(
|
||||
'flex h-16 flex-col items-center justify-center gap-1 text-xs transition-colors',
|
||||
active ? 'font-bold text-foreground' : 'font-medium text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{/* The active tab sits on a small plate, like a tile on a baseplate. */}
|
||||
<span
|
||||
className={cn(
|
||||
'flex h-7 w-12 items-center justify-center rounded-[calc(var(--radius)*0.6)] border-[length:var(--edge-w)]',
|
||||
active ? 'border-edge bg-secondary text-secondary-foreground' : 'border-transparent',
|
||||
)}
|
||||
>
|
||||
<item.icon className="size-5" aria-hidden />
|
||||
</span>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function BottomNav({ legoImport }: { legoImport: boolean }) {
|
||||
const pathname = usePathname();
|
||||
|
||||
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"
|
||||
className="fixed inset-x-0 bottom-0 z-40 border-t-[length:max(1px,var(--edge-w))] border-border bg-card/95 pb-[env(safe-area-inset-bottom)] backdrop-blur md:hidden"
|
||||
>
|
||||
<ul className="mx-auto flex max-w-lg">
|
||||
{items.map((item) => {
|
||||
const active = pathname === item.href || pathname.startsWith(`${item.href}/`);
|
||||
return (
|
||||
<li key={item.href} className="flex-1">
|
||||
<Link
|
||||
href={item.href}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
className={cn(
|
||||
'flex h-16 flex-col items-center justify-center gap-1 text-xs transition-colors',
|
||||
active ? 'text-primary' : 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<item.icon className="size-5" aria-hidden />
|
||||
{item.label}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
<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>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
export function DesktopNav() {
|
||||
export function DesktopNav({ legoImport }: { legoImport: boolean }) {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<nav aria-label="Hauptnavigation" className="hidden items-center gap-1 md:flex">
|
||||
{items.map((item) => {
|
||||
const active = pathname === item.href || pathname.startsWith(`${item.href}/`);
|
||||
const active = isActive(pathname, item.href);
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
@@ -59,7 +89,7 @@ export function DesktopNav() {
|
||||
className={cn(
|
||||
'flex h-10 items-center gap-2 rounded-lg px-3 text-sm font-medium transition-colors',
|
||||
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',
|
||||
)}
|
||||
>
|
||||
@@ -68,6 +98,9 @@ export function DesktopNav() {
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
<span className="ml-2">
|
||||
<AddMenu legoImport={legoImport} variant="button" />
|
||||
</span>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { LogOut, Settings, UserRound } from 'lucide-react';
|
||||
import { clearOfflineCaches } from '@/components/layout/service-worker';
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import type { SessionUser } from '@/lib/session';
|
||||
|
||||
export function UserMenu({ user }: { user: SessionUser }) {
|
||||
const router = useRouter();
|
||||
|
||||
async function signOut() {
|
||||
await authClient.signOut();
|
||||
// Cached pages must not stay readable on a shared device.
|
||||
await clearOfflineCaches();
|
||||
router.push('/anmelden');
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" aria-label="Konto-Menü">
|
||||
<UserRound />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
<span className="block text-sm font-medium">{user.name}</span>
|
||||
<span className="block truncate text-xs text-muted-foreground">{user.email}</span>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/konto">
|
||||
<UserRound />
|
||||
Konto und Passkeys
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{user.role === 'admin' ? (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/verwaltung/hersteller">
|
||||
<Settings />
|
||||
Verwaltung
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={() => void signOut()}>
|
||||
<LogOut />
|
||||
Abmelden
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { deleteCollectionItem, updateCollectionItem } from '@/app/(app)/sammlung
|
||||
import type { MissingPartRow } from '@/lib/queries/collection';
|
||||
import { MissingPartsDialog } from './missing-parts-dialog';
|
||||
import type { CollectionStatus, Completeness, Condition } from '@/db/types';
|
||||
import { collectorBadgeClass } from '@/lib/collector-colors';
|
||||
import { formatDate, toDateInputValue } from '@/lib/format';
|
||||
import {
|
||||
COMPLETENESS_LABELS,
|
||||
@@ -58,14 +59,19 @@ export type CollectionItemRow = {
|
||||
notes: string | null;
|
||||
setName: string;
|
||||
missingParts: MissingPartRow[];
|
||||
collectorId: string | null;
|
||||
collectorName: string | null;
|
||||
collectorColor: string | null;
|
||||
};
|
||||
|
||||
export function CollectionItemsSection({
|
||||
items,
|
||||
locations,
|
||||
collectors,
|
||||
}: {
|
||||
items: CollectionItemRow[];
|
||||
locations: Array<{ id: string; name: string }>;
|
||||
collectors: Array<{ id: string; name: string }>;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [pending, startTransition] = useTransition();
|
||||
@@ -126,6 +132,11 @@ export function CollectionItemsSection({
|
||||
<li key={item.id} className="flex items-start gap-3 p-3">
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{collectors.length > 1 && item.collectorName ? (
|
||||
<Badge className={collectorBadgeClass(item.collectorColor)}>
|
||||
{item.collectorName}
|
||||
</Badge>
|
||||
) : null}
|
||||
<Badge variant={item.status === 'sold' ? 'outline' : 'default'}>
|
||||
{statusLabel(item.status)}
|
||||
</Badge>
|
||||
@@ -194,6 +205,22 @@ export function CollectionItemsSection({
|
||||
|
||||
<form id="collection-item-form" onSubmit={submit} className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{collectors.length > 1 ? (
|
||||
<Field label="Sammler" htmlFor="collectorId" className="sm:col-span-2">
|
||||
<NativeSelect
|
||||
id="collectorId"
|
||||
name="collectorId"
|
||||
defaultValue={editing?.collectorId ?? collectors[0]?.id ?? 'none'}
|
||||
>
|
||||
{collectors.map((entry) => (
|
||||
<option key={entry.id} value={entry.id}>
|
||||
{entry.name}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
) : null}
|
||||
|
||||
<Field label="Status" htmlFor="status">
|
||||
<NativeSelect id="status" name="status" defaultValue={editing?.status ?? 'owned'}>
|
||||
{Object.entries(STATUS_LABELS).map(([value, label]) => (
|
||||
|
||||