Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
498a5c6226 | ||
|
|
78ae755761 | ||
|
|
62045468ce | ||
|
|
bac948fcb8 | ||
|
|
2d3ddc4dae | ||
|
|
604a8ac113 | ||
|
|
f2304b28e5 | ||
|
|
d009230dc2 | ||
|
|
1b825b44c4 | ||
|
|
e9093d0fad | ||
|
|
e52557b4bf | ||
|
|
0d08df0bff | ||
|
|
91fb66b13b | ||
|
|
87131b8e70 | ||
|
|
c23b754515 | ||
|
|
7265c03548 | ||
|
|
dcd076498a | ||
|
|
39271f8af7 | ||
|
|
45aa8f13cf | ||
|
|
5c5c5213bb | ||
|
|
baa94bf7e5 | ||
|
|
4e3cbb81ce | ||
|
|
52d69e5654 |
@@ -27,6 +27,21 @@ TZ=Europe/Berlin
|
|||||||
# BIND_ADDRESS=127.0.0.1
|
# BIND_ADDRESS=127.0.0.1
|
||||||
# HOST_PORT=3000
|
# HOST_PORT=3000
|
||||||
|
|
||||||
# Optional (ab Phase 2): Rebrickable-API-Key fuer den Lego-Import.
|
# Optional: Rebrickable-API-Key fuer den Lego-Import. Damit reicht auf der
|
||||||
# Ohne Key funktioniert alles manuell weiter.
|
# Seite "Neues Set" die Setnummer, und Name, Jahr, Teile, Minifiguren, Thema
|
||||||
|
# und Bild werden uebernommen. Ohne Key funktioniert alles manuell weiter.
|
||||||
|
# Kostenlos anlegen: https://rebrickable.com/api/ (Konto -> Settings -> API)
|
||||||
# REBRICKABLE_API_KEY=
|
# REBRICKABLE_API_KEY=
|
||||||
|
|
||||||
|
# Optional: Mailversand fuer Erinnerungen an Wunschlisten-Termine. Ohne
|
||||||
|
# SMTP_HOST und SMTP_FROM sind Erinnerungen komplett aus - alles andere
|
||||||
|
# funktioniert unveraendert.
|
||||||
|
# SMTP_HOST=mail.example.de
|
||||||
|
# SMTP_PORT=587
|
||||||
|
# SMTP_SECURE=false
|
||||||
|
# SMTP_USER=bricktrack@example.de
|
||||||
|
# SMTP_PASSWORD=
|
||||||
|
# SMTP_FROM=Bricktrack <bricktrack@example.de>
|
||||||
|
|
||||||
|
# Wie viele Tage vor dem Termin erinnert wird (Standard: 14).
|
||||||
|
# REMINDER_DAYS_BEFORE=14
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ next-env.d.ts
|
|||||||
/data-shots
|
/data-shots
|
||||||
/data-docker
|
/data-docker
|
||||||
/backups
|
/backups
|
||||||
|
/data-test
|
||||||
|
/data-test-export
|
||||||
|
|
||||||
# production
|
# production
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|||||||
@@ -7,6 +7,152 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.4.0] - 2026-10-25
|
||||||
|
|
||||||
|
Ein neues Gesicht: Bricktrack sieht jetzt aus wie das, was es verwaltet.
|
||||||
|
Dazu alle bekannten Sicherheitslücken in den Abhängigkeiten geschlossen.
|
||||||
|
|
||||||
|
### Sicherheit
|
||||||
|
|
||||||
|
- Next.js 15.5.28: schließt unter anderem eine Denial-of-Service-Lücke über
|
||||||
|
Server Actions und Cache-Poisoning bei selbst gehosteten Instanzen. Die
|
||||||
|
kritische Lücke in der Bildoptimierung war hier nicht ausnutzbar
|
||||||
|
(`images.unoptimized`), ist aber ebenfalls behoben.
|
||||||
|
- nodemailer 10.2: mehrere Fehler im Adressparser (Erinnerungs-Mails).
|
||||||
|
- better-auth 1.7.12, sharp 0.35.5 (librsvg), zod 4.6.7 (Speicherverbrauch
|
||||||
|
bei übergroßen Eingaben), drizzle-orm 0.45.4.
|
||||||
|
- Entwicklungswerkzeuge: vitest, vite, postcss, Playwright und die
|
||||||
|
shadcn-CLI aktualisiert, transitive Pakete per `pnpm.overrides` angehoben.
|
||||||
|
Übrig bleibt ein mittlerer Fund in einem alten esbuild, das drizzle-kit
|
||||||
|
intern mitbringt - nur beim Entwickeln, nicht im Image.
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
|
||||||
|
- **Designauswahl** unter Konto → Design: Klemmbaustein (neuer Standard),
|
||||||
|
Warm, Nachtblau (dunkel), Bonbon, Bauklotz und Schlicht (der bisherige
|
||||||
|
Look). Die Wahl gilt pro Gerät und wirkt sofort.
|
||||||
|
- **Platzhalter für Sets ohne Bild**: Farbe des Herstellers, gezeichneter
|
||||||
|
Stein und Setnummer statt eines grauen „Bild fehlt".
|
||||||
|
- **Startseite als Überblick**: Steinreihe für den Fortschritt (bei mehreren
|
||||||
|
Sammlern in deren Farben), Teile, Wert, nächster Wunschlisten-Termin mit
|
||||||
|
Countdown und Abkürzungen.
|
||||||
|
- Neues Logo als Baustein, Schriften lokal gebündelt (kein CDN).
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
|
||||||
|
- Karten, Buttons und Badges tragen im Standard-Design Konturen, eine feste
|
||||||
|
Unterkante und Noppen. In der Sammlung und auf der öffentlichen
|
||||||
|
Wunschliste zeigen die Noppen den Sammler bzw. ob ein Wunsch reserviert ist.
|
||||||
|
- Die öffentliche Wunschliste hat einen farbigen Kopf mit Anlass und Datum.
|
||||||
|
- Screenshots der Anleitung zeigen die feste Navigation nicht mehr mitten
|
||||||
|
im Bild.
|
||||||
|
|
||||||
|
## [0.3.0] - 2026-09-07
|
||||||
|
|
||||||
|
Phase 3: mehrere Kinder, Wertentwicklung, Erinnerungen und ein Schutz vor
|
||||||
|
doppelten Katalogeintraegen.
|
||||||
|
|
||||||
|
### Hinzugefuegt
|
||||||
|
|
||||||
|
**Mehrere Sammler**
|
||||||
|
- Der Katalog bleibt gemeinsam, Sammlung und Wunschlisten gehoeren je einem
|
||||||
|
Sammler. "Hat er das schon?" beantwortet damit die Frage fuer *ihn*: ein Set,
|
||||||
|
das nur die Schwester besitzt, faellt fuer den Bruder unter "fehlt noch".
|
||||||
|
- Umschalter in Katalog und Sammlung, Farbmarkierung je Sammler, Zuordnung im
|
||||||
|
Sammlungseintrag und je Wunschliste.
|
||||||
|
- Solange es nur einen Sammler gibt, ist davon nichts zu sehen - die App
|
||||||
|
bleibt genauso einfach wie vorher.
|
||||||
|
|
||||||
|
**Preisbeobachtung**
|
||||||
|
- Notierte Preise je Set mit Betrag, Zustand, Datum, Quelle und Notiz.
|
||||||
|
- Wertentwicklung gegen die UVP, in der Sammlung als Kachel "Aktueller Wert".
|
||||||
|
|
||||||
|
**Erinnerungen**
|
||||||
|
- E-Mail an den Besitzer, wenn der Termin einer Wunschliste naeher rueckt und
|
||||||
|
noch Wuensche offen sind - standardmaessig 14 Tage vorher, hoechstens einmal
|
||||||
|
pro Woche je Liste.
|
||||||
|
- Verwaltungsseite mit Verbindungstest, Testnachricht und sofortiger Pruefung.
|
||||||
|
- Ohne SMTP-Konfiguration bleibt die Funktion vollstaendig aus.
|
||||||
|
|
||||||
|
**Dublettenpruefung**
|
||||||
|
- Vor dem Anlegen eines Sets warnt die App vor gleicher Setnummer beim
|
||||||
|
gleichen Hersteller, sehr aehnlichem Namen und gleicher Nummer bei einem
|
||||||
|
anderen Hersteller - mit Link zum Verdaechtigen und der Moeglichkeit,
|
||||||
|
trotzdem anzulegen.
|
||||||
|
|
||||||
|
### Geaendert
|
||||||
|
|
||||||
|
- Die Migration legt einen Sammler "Sammlung" an und ordnet ihm alle
|
||||||
|
vorhandenen Sammlungseintraege und Wunschlisten zu. Umbenennen unter
|
||||||
|
Verwaltung -> Sammler.
|
||||||
|
- Der Smoke-Test laeuft gegen das Docker-Image statt gegen den Dev-Server.
|
||||||
|
|
||||||
|
### Nicht umgesetzt
|
||||||
|
|
||||||
|
- Der BrickLink-Preisfuehrer war im Plan als optional vermerkt und wurde
|
||||||
|
bewusst weggelassen: OAuth1 mit einer pro Nutzer registrierten Anwendung ist
|
||||||
|
viel Apparat fuer eine Zahl, die sich in zehn Sekunden eintippen laesst. Das
|
||||||
|
Feld "Wo" nimmt jede Quelle auf, die Datenstruktur bleibt offen.
|
||||||
|
|
||||||
|
## [0.2.0] - 2026-09-07
|
||||||
|
|
||||||
|
Phase 2: die Handgriffe, die den Alltag schneller machen.
|
||||||
|
|
||||||
|
### Hinzugefuegt
|
||||||
|
|
||||||
|
**Lego-Import (Rebrickable)**
|
||||||
|
- Auf "Neues Set" reicht die Setnummer von der Packung: Name, Jahr, Teile,
|
||||||
|
Minifiguren und Thema werden uebernommen, das Bild lokal gespeichert.
|
||||||
|
- Hersteller und Thema werden bei Bedarf angelegt, ein Oberthema inklusive.
|
||||||
|
- Alle Aufrufe laufen serverseitig. Ohne `REBRICKABLE_API_KEY` bleibt der
|
||||||
|
Import sichtbar, aber abgeschaltet - von Hand anlegen geht unveraendert.
|
||||||
|
|
||||||
|
**Barcode-Scanner**
|
||||||
|
- Knopf im Katalog: Strichcode mit der Handykamera scannen. Ist das Set
|
||||||
|
bekannt, oeffnet es sich; sonst das Formular mit ausgefuellter EAN.
|
||||||
|
- Jeder Code wird gegen seine Pruefziffer geprueft; dekodiert wird im
|
||||||
|
Browser, nichts verlaesst das Geraet.
|
||||||
|
|
||||||
|
**Fehlteile**
|
||||||
|
- Fehlende Teile je Sammlungseintrag mit Teilenummer, Farbe, Anzahl und
|
||||||
|
Notiz, abhakbar wenn nachgekauft.
|
||||||
|
- Die Vollstaendigkeit pflegt sich daraus selbst: der erste offene Eintrag
|
||||||
|
setzt "Teile fehlen", der letzte erledigte zurueck auf "vollstaendig".
|
||||||
|
|
||||||
|
**Einladungen**
|
||||||
|
- Der Admin erzeugt Registrierungslinks, die genau einmal funktionieren,
|
||||||
|
optional an eine E-Mail-Adresse gebunden, mit waehlbarer Rolle und
|
||||||
|
Gueltigkeit. In der Datenbank liegt nur der Hash des Tokens.
|
||||||
|
|
||||||
|
**Export und Import**
|
||||||
|
- Alles als ZIP (Daten plus hochgeladene Dateien), nur die Daten als JSON,
|
||||||
|
die Sammlung als CSV mit Semikolon und BOM fuer deutsches Excel.
|
||||||
|
- Import ersetzt die Anwendungsdaten in einer Transaktion, nur fuer
|
||||||
|
Administratoren und nur nach getipptem ERSETZEN.
|
||||||
|
- Nicht im Export: Konten, Passwoerter, Passkeys, Reservierungen und
|
||||||
|
Teilen-Links.
|
||||||
|
|
||||||
|
**PWA**
|
||||||
|
- Manifest, Icons und Service Worker: installierbar auf dem Startbildschirm,
|
||||||
|
mit Verknuepfungen zu "Fehlende Sets" und zur Sammlung.
|
||||||
|
- Einmal geladene Seiten bleiben ohne Verbindung lesbar; Set-Bilder und
|
||||||
|
Build-Dateien kommen aus dem Cache. Beim Abmelden wird der Cache geraeumt.
|
||||||
|
|
||||||
|
### Geaendert
|
||||||
|
|
||||||
|
- `src/env.ts` behandelt leere Werte wie nicht gesetzt: ein `BASE_URL=` in der
|
||||||
|
`.env` fuehrte bisher zum Start-Abbruch statt zum Standardwert.
|
||||||
|
- Unit-Tests koennen jetzt auch Server-Module laden (`server-only` wird in der
|
||||||
|
Testumgebung durch ein leeres Modul ersetzt).
|
||||||
|
|
||||||
|
### Bekannte Einschraenkungen
|
||||||
|
|
||||||
|
- Rebrickable liefert keine Anleitungsdateien. Der Import legt stattdessen
|
||||||
|
einen Link auf die LEGO-Serviceseite zur Setnummer an.
|
||||||
|
- Der Barcode-Scanner braucht wie Passkeys HTTPS.
|
||||||
|
- Beim Import wandern Wunschlisten an den importierenden Admin und sind
|
||||||
|
zunaechst nicht geteilt.
|
||||||
|
|
||||||
## [0.1.0] - 2026-09-07
|
## [0.1.0] - 2026-09-07
|
||||||
|
|
||||||
Erste lauffaehige Fassung: Phase 1 vollstaendig, im Docker-Stack verifiziert.
|
Erste lauffaehige Fassung: Phase 1 vollstaendig, im Docker-Stack verifiziert.
|
||||||
@@ -88,5 +234,8 @@ Erste lauffaehige Fassung: Phase 1 vollstaendig, im Docker-Stack verifiziert.
|
|||||||
- Unter Windows scheitert `pnpm build` an fehlenden Symlink-Rechten. Der
|
- Unter Windows scheitert `pnpm build` an fehlenden Symlink-Rechten. Der
|
||||||
Docker-Build unter Linux ist davon nicht betroffen.
|
Docker-Build unter Linux ist davon nicht betroffen.
|
||||||
|
|
||||||
[Unreleased]: https://git.wilde-server.de/florian.wilde/bricktrack/compare/v0.1.0...HEAD
|
[Unreleased]: https://git.wilde-server.de/florian.wilde/bricktrack/compare/v0.4.0...HEAD
|
||||||
|
[0.4.0]: https://git.wilde-server.de/florian.wilde/bricktrack/releases/tag/v0.4.0
|
||||||
|
[0.3.0]: https://git.wilde-server.de/florian.wilde/bricktrack/releases/tag/v0.3.0
|
||||||
|
[0.2.0]: https://git.wilde-server.de/florian.wilde/bricktrack/releases/tag/v0.2.0
|
||||||
[0.1.0]: https://git.wilde-server.de/florian.wilde/bricktrack/releases/tag/v0.1.0
|
[0.1.0]: https://git.wilde-server.de/florian.wilde/bricktrack/releases/tag/v0.1.0
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ teilbare Wunschlisten.
|
|||||||
| Validierung | zod (serverseitig, immer) | |
|
| Validierung | zod (serverseitig, immer) | |
|
||||||
| Bilder | sharp (nur serverseitig, Thumbnails) | |
|
| Bilder | sharp (nur serverseitig, Thumbnails) | |
|
||||||
| Tests | Vitest (Unit), Playwright (E2E, Screenshots) | |
|
| Tests | Vitest (Unit), Playwright (E2E, Screenshots) | |
|
||||||
|
| PDF | pdfjs-dist, lokal gebuendelt | kein CDN |
|
||||||
|
| Barcode | @zxing/browser, lazy geladen | nur beim Scannen |
|
||||||
|
| Archive | fflate | Export/Import als ZIP |
|
||||||
| Paketmanager | pnpm 10 | |
|
| Paketmanager | pnpm 10 | |
|
||||||
|
|
||||||
Bewusst **kein** Prisma (Standalone-Build im Container), **keine**
|
Bewusst **kein** Prisma (Standalone-Build im Container), **keine**
|
||||||
@@ -43,6 +46,34 @@ Bild-URLs, die der Nutzer selbst hinterlegt hat.
|
|||||||
- Commits: Conventional Commits, Betreff auf Deutsch, Imperativ,
|
- Commits: Conventional Commits, Betreff auf Deutsch, Imperativ,
|
||||||
erste Zeile <= 72 Zeichen.
|
erste Zeile <= 72 Zeichen.
|
||||||
|
|
||||||
|
## Wichtige Module
|
||||||
|
|
||||||
|
| Datei | Zustaendig fuer |
|
||||||
|
|---|---|
|
||||||
|
| `src/lib/ownership.ts` | die eine Definition von "hat er" / "hat er nicht" |
|
||||||
|
| `src/lib/wishlist-logic.ts` | Mehrfachauswahl, Dubletten, Prioritaeten (rein, testbar) |
|
||||||
|
| `src/lib/tokens.ts` | Share-, Storno- und Einladungs-Token |
|
||||||
|
| `src/lib/invites.ts` | Einladungen inklusive der Freigabe fuer die Registrierung |
|
||||||
|
| `src/lib/rebrickable.ts` | Lego-Import, ausschliesslich serverseitig |
|
||||||
|
| `src/lib/set-number.ts`, `src/lib/ean.ts` | Setnummern und Barcodes (rein, testbar) |
|
||||||
|
| `src/lib/uploads.ts` | Datei-Uploads, Magic Bytes, Thumbnails |
|
||||||
|
| `src/lib/similarity.ts` | Namensvergleich fuer die Dublettenpruefung (rein) |
|
||||||
|
| `src/lib/prices.ts` | Wertentwicklung aus notierten Preisen (rein) |
|
||||||
|
| `src/lib/reminders.ts` | Regeln, wann eine Erinnerung faellig ist (rein) |
|
||||||
|
| `src/lib/reminder-job.ts`, `src/lib/mailer.ts` | Versand und Zeitplan |
|
||||||
|
| `src/lib/export.ts` | Export und Import des gesamten Bestands |
|
||||||
|
| `src/lib/money.ts`, `src/lib/format.ts` | Cent-Umrechnung, dd.MM.yyyy |
|
||||||
|
| `src/lib/design.ts`, `src/app/designs.css` | Designs: Namen und Cookie bzw. Tokens je `data-design` |
|
||||||
|
| `src/components/brick/` | Noppen, Logo, Platzhalter fuer Sets ohne Bild |
|
||||||
|
|
||||||
|
### Designs
|
||||||
|
|
||||||
|
Das Design steht als `data-design` am `<html>` (Cookie `bricktrack-design`,
|
||||||
|
pro Geraet, Standard `klemmbaustein`). Komponenten benutzen nur Tokens
|
||||||
|
(`bg-primary`, `border-edge`, `--edge-w`, `--lift`, `bg-tone-1` ...), nie feste
|
||||||
|
Farben - sonst stimmt ein Design nicht mehr. Karten mit Noppen: `<Studs />`
|
||||||
|
neben die Karte in einen `relative`-Wrapper, die Karte selbst mit `brick`.
|
||||||
|
|
||||||
## Befehle
|
## Befehle
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -58,8 +89,22 @@ pnpm db:studio # Drizzle Studio
|
|||||||
pnpm test # Vitest
|
pnpm test # Vitest
|
||||||
pnpm test:e2e # Playwright
|
pnpm test:e2e # Playwright
|
||||||
pnpm screenshots # Screenshots fuer docs/ANLEITUNG.md erzeugen
|
pnpm screenshots # Screenshots fuer docs/ANLEITUNG.md erzeugen
|
||||||
|
|
||||||
|
node scripts/generate-icons.mjs # PWA-Icons aus der SVG-Quelle rendern
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Testkonventionen
|
||||||
|
|
||||||
|
- Unit-Tests liegen in `tests/unit`, laufen unter Node und duerfen auch
|
||||||
|
Server-Module laden: `server-only` ist in `vitest.config.mts` auf einen
|
||||||
|
leeren Stub gemappt.
|
||||||
|
- `tests/setup-env.ts` setzt Platzhalter fuer die Umgebung. `BASE_URL` wird
|
||||||
|
dort **immer** ueberschrieben, weil Vite denselben Namen fuer seinen
|
||||||
|
Public-Base-Path benutzt und ihn auf `/` setzt.
|
||||||
|
- Der Playwright-Smoke-Test nutzt `./data-e2e`, der Screenshot-Lauf
|
||||||
|
`./data-shots` gegen das Produktions-Image. Beide Verzeichnisse werden vor
|
||||||
|
jedem Lauf geleert und liegen nie im Git.
|
||||||
|
|
||||||
## Datenmodell
|
## Datenmodell
|
||||||
|
|
||||||
Kernregel: Ein **`set`** ist ein *Katalogeintrag* - es existiert,
|
Kernregel: Ein **`set`** ist ein *Katalogeintrag* - es existiert,
|
||||||
@@ -83,8 +128,16 @@ Diese Bedingung steht an genau einer Stelle: `src/lib/ownership.ts`.
|
|||||||
|
|
||||||
### Besitz
|
### Besitz
|
||||||
|
|
||||||
|
Mehrere **Sammler** teilen sich den Katalog, nicht den Besitz: `collector`
|
||||||
|
haelt Name und Farbe, `collection_item.collector_id` und
|
||||||
|
`wishlist.collector_id` ordnen zu. Solange nur ein Sammler existiert, blendet
|
||||||
|
die Oberflaeche das Thema vollstaendig aus - Umschalter, Abfragen und Badges
|
||||||
|
erscheinen erst ab dem zweiten.
|
||||||
|
|
||||||
| Tabelle | Zweck | Wichtige Felder |
|
| Tabelle | Zweck | Wichtige Felder |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
| `collector` | ein Kind | `name`, `color`, `birthday`, `sort_order` |
|
||||||
|
| `price_point` | beobachteter Preis je Set | `price_cents`, `condition`, `observed_at`, `source` |
|
||||||
| `collection_item` | ein besessenes Exemplar | `status` = `owned` / `built` / `disassembled` / `lent_out` / `sold`, `completeness`, `condition`, `location_id`, `acquired_*`, `gifted_by` |
|
| `collection_item` | ein besessenes Exemplar | `status` = `owned` / `built` / `disassembled` / `lent_out` / `sold`, `completeness`, `condition`, `location_id`, `acquired_*`, `gifted_by` |
|
||||||
| `location` | Aufbewahrungsort, hierarchisch | `parent_location_id` |
|
| `location` | Aufbewahrungsort, hierarchisch | `parent_location_id` |
|
||||||
| `missing_part` | Fehlteile je Exemplar | `part_number`, `color_name`, `quantity`, `resolved` |
|
| `missing_part` | Fehlteile je Exemplar | `part_number`, `color_name`, `quantity`, `resolved` |
|
||||||
@@ -145,15 +198,23 @@ Session.
|
|||||||
serverseitig.
|
serverseitig.
|
||||||
5. Session-Cookies: `httpOnly`, `secure`, `sameSite=lax`.
|
5. Session-Cookies: `httpOnly`, `secure`, `sameSite=lax`.
|
||||||
Rate-Limiting auf den Login-Endpunkten.
|
Rate-Limiting auf den Login-Endpunkten.
|
||||||
|
6. Der Export enthaelt **keine** Konten, Passwoerter oder Passkeys - und
|
||||||
|
keine Reservierungen oder Teilen-Token. Das Backup ist `./data`, nicht der
|
||||||
|
Export.
|
||||||
|
7. Der Service Worker beantwortet nur GET-Anfragen aus dem Cache und niemals
|
||||||
|
`/api/auth`, `/api/export` oder den Healthcheck. Beim Abmelden wird der
|
||||||
|
Cache geleert.
|
||||||
|
|
||||||
## Phasenplan
|
## Phasenplan
|
||||||
|
|
||||||
- **Phase 1 (MVP)** - Auth mit Passkeys, Stammdaten, Katalog, Sammlung,
|
- **Phase 1 (MVP)** - Auth mit Passkeys, Stammdaten, Katalog, Sammlung,
|
||||||
Wunschlisten inkl. oeffentlicher Ansicht, Docker-Stack.
|
Wunschlisten inkl. oeffentlicher Ansicht, Docker-Stack. *Fertig in 0.1.0.*
|
||||||
- **Phase 2 (Komfort)** - Rebrickable-Import, Barcode-Scanner, Fehlteile,
|
- **Phase 2 (Komfort)** - Rebrickable-Import, Barcode-Scanner, Fehlteile,
|
||||||
Einladungen, Export/Import, PWA.
|
Einladungen, Export/Import, PWA. *Fertig in 0.2.0.*
|
||||||
- **Phase 3 (Nice to have)** - mehrere Sammler, Preisbeobachtung,
|
- **Phase 3 (Nice to have)** - mehrere Sammler, Preisbeobachtung,
|
||||||
E-Mail-Erinnerungen, Dublettenpruefung.
|
E-Mail-Erinnerungen, Dublettenpruefung. *Fertig in 0.3.0.*
|
||||||
|
- **Design und Sicherheit** - Designauswahl (Standard Klemmbaustein),
|
||||||
|
Abhaengigkeiten ohne bekannte Luecken. *Fertig in 0.4.0.*
|
||||||
|
|
||||||
Details und Stand: `docs/ROADMAP.md`. Zeitaufwaende: `docs/DEVLOG.md`.
|
Details und Stand: `docs/ROADMAP.md`. Zeitaufwaende: `docs/DEVLOG.md`.
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ du per Link mit Familie und Freunden teilst. Die reservieren dort ihr Geschenk
|
|||||||
| **Fotos** | Eigene Bilder je Set – direkt aus der Handykamera |
|
| **Fotos** | Eigene Bilder je Set – direkt aus der Handykamera |
|
||||||
| **Wunschlisten** | Mehrfachauswahl aus den fehlenden Sets, Priorität, Reihenfolge, Termin |
|
| **Wunschlisten** | Mehrfachauswahl aus den fehlenden Sets, Priorität, Reihenfolge, Termin |
|
||||||
| **Teilen** | Öffentlicher Link ohne Anmeldung, Reservierung mit Namen, Storno-Link, Überraschungsmodus |
|
| **Teilen** | Öffentlicher Link ohne Anmeldung, Reservierung mit Namen, Storno-Link, Überraschungsmodus |
|
||||||
|
| **Mehrere Kinder** | Gemeinsamer Katalog, getrennte Sammlungen und Wunschlisten – sichtbar erst ab dem zweiten Sammler |
|
||||||
|
| **Preise** | Notierte Preise je Set, Wertentwicklung gegen die UVP |
|
||||||
|
| **Erinnerungen** | E-Mail, wenn ein Termin näher rückt und noch Wünsche offen sind (SMTP optional) |
|
||||||
| **Anmeldung** | E-Mail und Passwort oder Passkey (WebAuthn), mehrere Geräte je Konto |
|
| **Anmeldung** | E-Mail und Passwort oder Passkey (WebAuthn), mehrere Geräte je Konto |
|
||||||
|
|
||||||
Mobil zuerst gebaut: Die App wird im Laden und im Kinderzimmer benutzt, nicht
|
Mobil zuerst gebaut: Die App wird im Laden und im Kinderzimmer benutzt, nicht
|
||||||
@@ -81,7 +84,9 @@ Alle Werte kommen aus `.env` (Vorlage: `.env.example`).
|
|||||||
| `TZ` | nein | `Europe/Berlin` | Zeitzone des Containers. |
|
| `TZ` | nein | `Europe/Berlin` | Zeitzone des Containers. |
|
||||||
| `BIND_ADDRESS` | nein | `127.0.0.1` | Auf welcher Host-Adresse der Container lauscht. `0.0.0.0`, wenn der Proxy auf einem anderen Host läuft. |
|
| `BIND_ADDRESS` | nein | `127.0.0.1` | Auf welcher Host-Adresse der Container lauscht. `0.0.0.0`, wenn der Proxy auf einem anderen Host läuft. |
|
||||||
| `HOST_PORT` | nein | `3000` | Port auf dem Host, falls 3000 belegt ist. |
|
| `HOST_PORT` | nein | `3000` | Port auf dem Host, falls 3000 belegt ist. |
|
||||||
| `REBRICKABLE_API_KEY` | nein | – | Ab Version 0.2.0: Lego-Import. Ohne Key läuft alles manuell. |
|
| `REBRICKABLE_API_KEY` | nein | – | Lego-Import. Ohne Key läuft alles manuell. |
|
||||||
|
| `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`, `SMTP_FROM` | nein | – | Mailversand für Wunschlisten-Erinnerungen. Ohne `SMTP_HOST` und `SMTP_FROM` ist die Funktion aus. |
|
||||||
|
| `REMINDER_DAYS_BEFORE` | nein | `14` | Wie viele Tage vor dem Termin erinnert wird. |
|
||||||
|
|
||||||
### Reverse Proxy
|
### Reverse Proxy
|
||||||
|
|
||||||
@@ -172,10 +177,16 @@ pnpm dev # http://localhost:3000
|
|||||||
| `pnpm build` | Produktionsbuild (standalone) |
|
| `pnpm build` | Produktionsbuild (standalone) |
|
||||||
| `pnpm lint` / `pnpm typecheck` | ESLint / TypeScript – beide müssen sauber sein |
|
| `pnpm lint` / `pnpm typecheck` | ESLint / TypeScript – beide müssen sauber sein |
|
||||||
| `pnpm test` | Unit-Tests (Vitest) |
|
| `pnpm test` | Unit-Tests (Vitest) |
|
||||||
| `pnpm test:e2e` | Playwright-Smoke-Test gegen ein eigenes `./data-e2e` |
|
| `pnpm test:e2e` | Playwright-Smoke-Test gegen das Docker-Image (eigenes `./data-e2e`) |
|
||||||
| `pnpm db:generate` | SQL-Migration aus dem Drizzle-Schema erzeugen |
|
| `pnpm db:generate` | SQL-Migration aus dem Drizzle-Schema erzeugen |
|
||||||
| `pnpm db:migrate` | Migrationen anwenden |
|
| `pnpm db:migrate` | Migrationen anwenden |
|
||||||
| `pnpm db:studio` | Drizzle Studio |
|
| `pnpm db:studio` | Drizzle Studio |
|
||||||
|
| `pnpm screenshots` | Bilder für `docs/ANLEITUNG.md` neu erzeugen |
|
||||||
|
|
||||||
|
`pnpm test:e2e` und `pnpm screenshots` bauen jeweils das Docker-Image und
|
||||||
|
fahren es hoch. Sie brauchen also einen laufenden Docker-Daemon, testen dafür
|
||||||
|
aber genau das Artefakt, das später auch deployt wird – der Dev-Server hat
|
||||||
|
unter der Last eines ganzen Durchlaufs reproduzierbar Chunks verloren.
|
||||||
|
|
||||||
> **Unter Windows** schlägt `pnpm build` mit `EPERM … symlink` fehl: der
|
> **Unter Windows** schlägt `pnpm build` mit `EPERM … symlink` fehl: der
|
||||||
> Standalone-Output legt Symlinks an, wofür Windows den Entwicklermodus oder
|
> Standalone-Output legt Symlinks an, wofür Windows den Entwicklermodus oder
|
||||||
@@ -202,6 +213,10 @@ Ein verkauftes Exemplar zählt also nicht mehr als Besitz – das Set taucht
|
|||||||
wieder unter „fehlt noch" auf und darf zurück auf eine Wunschliste. Diese
|
wieder unter „fehlt noch" auf und darf zurück auf eine Wunschliste. Diese
|
||||||
Regel steht an genau einer Stelle im Code: `src/lib/ownership.ts`.
|
Regel steht an genau einer Stelle im Code: `src/lib/ownership.ts`.
|
||||||
|
|
||||||
|
Gibt es mehrere **Sammler**, bekommt die Frage ein „für wen": Der Katalog ist
|
||||||
|
gemeinsam, der Besitz nicht. Ein Set, das nur das Geschwisterkind hat, fehlt
|
||||||
|
dem anderen weiterhin.
|
||||||
|
|
||||||
Das vollständige Modell inklusive aller Tabellen: [CLAUDE.md](CLAUDE.md#datenmodell).
|
Das vollständige Modell inklusive aller Tabellen: [CLAUDE.md](CLAUDE.md#datenmodell).
|
||||||
|
|
||||||
## Sicherheit
|
## Sicherheit
|
||||||
|
|||||||
@@ -22,7 +22,17 @@ Kinderzimmer, nicht für den Schreibtisch.
|
|||||||
8. [Wunschlisten](#8-wunschlisten)
|
8. [Wunschlisten](#8-wunschlisten)
|
||||||
9. [Teilen – so sehen es Oma und Tante Anne](#9-teilen)
|
9. [Teilen – so sehen es Oma und Tante Anne](#9-teilen)
|
||||||
10. [Passkeys einrichten](#10-passkeys-einrichten)
|
10. [Passkeys einrichten](#10-passkeys-einrichten)
|
||||||
11. [Antworten auf die zwei wichtigen Fragen](#11-die-zwei-fragen)
|
11. [Sets im Schnellverfahren: Import und Scanner](#11-import-und-scanner)
|
||||||
|
12. [Fehlteile](#12-fehlteile)
|
||||||
|
13. [Noch ein Konto für die Familie](#13-einladungen)
|
||||||
|
14. [Daten sichern, umziehen, auswerten](#14-daten)
|
||||||
|
15. [Auf den Startbildschirm legen](#15-startbildschirm)
|
||||||
|
16. [Zwei Kinder, ein Katalog](#16-mehrere-sammler)
|
||||||
|
17. [Was ein Set heute wert ist](#17-preise)
|
||||||
|
18. [Erinnerung vor dem Termin](#18-erinnerungen)
|
||||||
|
19. [Schutz vor doppelten Einträgen](#19-dubletten)
|
||||||
|
20. [Antworten auf die zwei wichtigen Fragen](#20-die-zwei-fragen)
|
||||||
|
21. [Ein anderes Design](#21-design)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -43,17 +53,20 @@ 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 |
|
| **Steinreihe** | Zehn Plätze für den ganzen Katalog. Jeder volle Stein ist ein Zehntel, das schon da ist – bei mehreren Kindern in deren Farbe |
|
||||||
| **Fehlende Sets** | Der direkte Weg zu allem, was noch fehlt |
|
| **Teile und Wert** | Alle verbauten Teile und was die Sammlung heute wert ist |
|
||||||
| **Sammlung** | Was tatsächlich da ist |
|
| **Nächster Termin** | Die nächste Wunschliste mit Datum, wie viele Tage noch bleiben und wie viel offen ist |
|
||||||
| **Wunschlisten** | Zum Teilen mit Familie und Freunden |
|
| **Abkürzungen** | Direkt zu den fehlenden Sets, zu den Wunschlisten oder zu einem neuen Set |
|
||||||
|
|
||||||
Unten am Bildschirmrand liegt die Navigation – auf dem Handy mit dem Daumen
|
Unten am Bildschirmrand liegt die Navigation – auf dem Handy mit dem Daumen
|
||||||
erreichbar.
|
erreichbar. Der aktive Bereich sitzt auf einer kleinen Platte.
|
||||||
|
|
||||||
|
Sets ohne Bild bekommen einen farbigen Platzhalter mit der Setnummer. Jeder
|
||||||
|
Hersteller hat seine eigene Farbe – Lego gelb, Playmobil blau, Cobi grün.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -291,7 +304,238 @@ gib ihnen sprechende Namen.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 11. Die zwei Fragen
|
## 11. Import und Scanner
|
||||||
|
|
||||||
|
Sets von Hand einzutippen ist der langsame Weg. Für Lego gibt es zwei
|
||||||
|
schnellere.
|
||||||
|
|
||||||
|
### Lego-Import über die Setnummer
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Auf **Katalog → Set anlegen** steht oben das Feld für den Import. Setnummer
|
||||||
|
von der Packung eintippen, „Holen" – und Name, Jahr, Teile, Minifiguren und
|
||||||
|
Thema stehen drin. Das Bild wird **lokal gespeichert**, funktioniert also auch
|
||||||
|
weiter, wenn die Quelle es irgendwann austauscht. Fehlen Hersteller oder
|
||||||
|
Thema noch, legt der Import sie an.
|
||||||
|
|
||||||
|
Dafür braucht es einen kostenlosen API-Key von
|
||||||
|
[rebrickable.com/api](https://rebrickable.com/api/), eingetragen als
|
||||||
|
`REBRICKABLE_API_KEY` in der `.env`. Ohne Key steht an derselben Stelle ein
|
||||||
|
Hinweis, und alles andere funktioniert unverändert weiter.
|
||||||
|
|
||||||
|
> Anleitungsdateien liefert Rebrickable nicht. Der Import legt stattdessen
|
||||||
|
> einen Link auf die LEGO-Serviceseite zur Setnummer an – von dort kommst du
|
||||||
|
> zum PDF, das du dann hochladen kannst.
|
||||||
|
|
||||||
|
### Barcode scannen
|
||||||
|
|
||||||
|
Im Katalog sitzt neben „Set anlegen" ein Scanner-Symbol. Strichcode der
|
||||||
|
Packung ins Bild halten:
|
||||||
|
|
||||||
|
- **Set schon im Katalog** → es öffnet sich direkt. Genau das, was man im
|
||||||
|
Laden vor dem Regal wissen will.
|
||||||
|
- **Noch unbekannt** → das Formular „Neues Set" öffnet sich mit
|
||||||
|
ausgefüllter EAN.
|
||||||
|
|
||||||
|
Gelesen wird ausschließlich im Browser, es geht nichts an einen fremden
|
||||||
|
Dienst. Jeder Code wird gegen seine Prüfziffer geprüft – verlesene Ziffern
|
||||||
|
landen so gar nicht erst im Katalog. Die Kamera braucht wie Passkeys HTTPS.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Fehlteile
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Beim Aufbauen fehlt ein Stein. Auf der Set-Detailseite hat jeder
|
||||||
|
Sammlungseintrag ein Puzzle-Symbol: dort trägst du ein, was fehlt – mit
|
||||||
|
Teilenummer und Farbe, wenn du sie kennst, sonst reicht eine Notiz wie
|
||||||
|
„Dachstein vorne links".
|
||||||
|
|
||||||
|
Ist das Teil nachgekauft, hakst du es ab. Die **Vollständigkeit pflegt sich
|
||||||
|
dabei selbst**: Der erste offene Eintrag setzt den Sammlungseintrag auf
|
||||||
|
„Teile fehlen", der letzte erledigte zurück auf „vollständig". Der Filter in
|
||||||
|
der Sammlung stimmt damit, ohne dass du daran denken musst.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Einladungen
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Die Registrierung ist nach dem ersten Konto geschlossen – sonst könnte sich
|
||||||
|
jeder, der die Adresse kennt, ein Konto anlegen. Für den zweiten Elternteil
|
||||||
|
gibt es deshalb **Verwaltung → Einladungen**:
|
||||||
|
|
||||||
|
1. „Einladung erstellen", optional an eine E-Mail-Adresse gebunden, mit Rolle
|
||||||
|
und Gültigkeit.
|
||||||
|
2. Den Link kopieren und weitergeben. Er wird **nur einmal angezeigt** – in
|
||||||
|
der Datenbank liegt nur sein Hash.
|
||||||
|
3. Die eingeladene Person legt darüber ihr Konto an. Danach ist der Link
|
||||||
|
verbraucht.
|
||||||
|
|
||||||
|
„Mitglied" darf die Sammlung pflegen und Wunschlisten anlegen; „Administrator"
|
||||||
|
zusätzlich einladen und Daten exportieren.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Daten
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Unter **Verwaltung → Daten** gibt es drei Downloads:
|
||||||
|
|
||||||
|
| | Wofür |
|
||||||
|
|---|---|
|
||||||
|
| **Alles als ZIP** | Umziehen auf einen anderen Server, zweites Netz |
|
||||||
|
| **Nur Daten (JSON)** | Zum Reinschauen oder Weiterverarbeiten |
|
||||||
|
| **Sammlung als CSV** | Für Excel oder LibreOffice, Semikolon-getrennt |
|
||||||
|
|
||||||
|
**Nicht im Export:** Konten, Passwörter und Passkeys. Ein Export voller
|
||||||
|
Passwort-Hashes im Download-Ordner wäre ein Risiko, kein Backup. Ebenso außen
|
||||||
|
vor: Reservierungen und Teilen-Links – die gehören zu der Instanz, auf der sie
|
||||||
|
entstanden sind.
|
||||||
|
|
||||||
|
> **Das richtige Backup bleibt `./data`.** Ein Verzeichnis, Datenbank und
|
||||||
|
> Dateien darin. Siehe [README](../README.md#backup-und-restore).
|
||||||
|
|
||||||
|
Der **Import** liest ein solches ZIP wieder ein – und **ersetzt dabei alle
|
||||||
|
vorhandenen Daten**. Zusammenführen zweier Sammlungen ist bewusst nicht
|
||||||
|
möglich: Es bräuchte Konfliktregeln für jede Tabelle, und das Ergebnis wäre
|
||||||
|
nicht vorhersagbar. Deshalb muss man zur Bestätigung `ERSETZEN` eintippen.
|
||||||
|
Wunschlisten gehören danach dir und sind zunächst nicht geteilt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Startbildschirm
|
||||||
|
|
||||||
|
Bricktrack lässt sich wie eine App installieren:
|
||||||
|
|
||||||
|
- **iPhone/iPad (Safari):** Teilen-Symbol → „Zum Home-Bildschirm".
|
||||||
|
- **Android (Chrome):** Menü → „App installieren" bzw. „Zum Startbildschirm
|
||||||
|
hinzufügen".
|
||||||
|
|
||||||
|
Danach startet Bricktrack ohne Browserleiste, und langes Drücken auf das Symbol
|
||||||
|
springt direkt zu „Fehlende Sets" oder zur Sammlung.
|
||||||
|
|
||||||
|
**Ohne Verbindung** bleibt lesbar, was du schon einmal geöffnet hast – der
|
||||||
|
Katalog also, wenn du ihn vorher im WLAN durchgeblättert hast. Ändern lässt
|
||||||
|
sich offline nichts: Eine Reservierung, die nur auf deinem Gerät liegt, wäre
|
||||||
|
für alle anderen unsichtbar und damit eine Lüge. Beim Abmelden räumt die App
|
||||||
|
den Zwischenspeicher, damit auf einem geteilten Gerät nichts lesbar bleibt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. Mehrere Sammler
|
||||||
|
|
||||||
|

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

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

|
||||||
|
|
||||||
|
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 SMTP-Zugang in der `.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SMTP_HOST=mail.example.de
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=bricktrack@example.de
|
||||||
|
SMTP_PASSWORD=…
|
||||||
|
SMTP_FROM=Bricktrack <bricktrack@example.de>
|
||||||
|
|
||||||
|
# Optional, Standard ist 14:
|
||||||
|
REMINDER_DAYS_BEFORE=14
|
||||||
|
```
|
||||||
|
|
||||||
|
Unter **Verwaltung → Erinnerungen** kannst du die Verbindung prüfen, dir eine
|
||||||
|
Testnachricht schicken und die Prüfung sofort auslösen, statt auf die nächste
|
||||||
|
Stunde zu warten. Dort steht auch, welche Listen einen Termin haben und wie
|
||||||
|
viel dort noch offen ist.
|
||||||
|
|
||||||
|
Ohne SMTP-Zugang ist die Funktion vollständig aus – alles andere funktioniert
|
||||||
|
unverändert.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 19. Dubletten
|
||||||
|
|
||||||
|
Beim Anlegen eines neuen Sets schaut Bricktrack nach, ob es das schon gibt:
|
||||||
|
|
||||||
|
- **gleiche Setnummer beim gleichen Hersteller** – dann ist es dasselbe Set,
|
||||||
|
- **sehr ähnlicher Name** – meist ein Tippfehler oder eine andere Schreibweise,
|
||||||
|
- **gleiche Nummer bei einem anderen Hersteller** – meist der falsche
|
||||||
|
Hersteller im Formular.
|
||||||
|
|
||||||
|
Passt etwas, erscheint ein Hinweis mit Link zu den Verdächtigen. Von dort
|
||||||
|
kommst du direkt zum vorhandenen Eintrag – oder legst mit „Trotzdem anlegen"
|
||||||
|
weiter an, wenn es wirklich ein anderes Set ist.
|
||||||
|
|
||||||
|
Die Erkennung ist bewusst zurückhaltend eingestellt: Tippfehler schlagen an,
|
||||||
|
zwei echte Sets desselben Themas nicht.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 20. Die zwei Fragen
|
||||||
|
|
||||||
Dafür ist die ganze App gebaut:
|
Dafür ist die ganze App gebaut:
|
||||||
|
|
||||||
@@ -300,10 +544,32 @@ Dafür ist die ganze App gebaut:
|
|||||||
|
|
||||||
**„Welche Sets hat er noch nicht?"**
|
**„Welche Sets hat er noch nicht?"**
|
||||||
→ Katalog, Filter „Fehlt noch". Von dort in zwei Handgriffen auf eine
|
→ Katalog, Filter „Fehlt noch". Von dort in zwei Handgriffen auf eine
|
||||||
Wunschliste und per Link zu Oma.
|
Wunschliste und per Link zu Oma. Bei mehreren Kindern beantwortet der
|
||||||
|
Umschalter oben die Frage für jedes einzeln.
|
||||||
|
|
||||||
Im Laden vor dem Regal: Katalog öffnen, Setnummer von der Packung eintippen.
|
Im Laden vor dem Regal: Scanner öffnen, Strichcode einlesen. Steht ein grüner
|
||||||
Steht ein grüner Haken dran, habt ihr es schon.
|
Haken dran, habt ihr es schon.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 21. Design
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Unter **Konto → 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,36 @@ Commit-Spalte verweist auf den Stand am Ende des Schritts.
|
|||||||
|
|
||||||
**Summe Phase 1: 2:44**
|
**Summe Phase 1: 2:44**
|
||||||
|
|
||||||
|
## Phase 2 – Komfort (v0.2.0)
|
||||||
|
|
||||||
|
### 07.09.2026
|
||||||
|
|
||||||
|
| Von | Bis | Dauer | Schritt | Ergebnis | Commit |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 06:50 | 07:01 | 0:11 | Einladungen | Admin erzeugt einmal gültige Registrierungslinks; Freigabe über AsyncLocalStorage, damit die Rolle nicht aus dem Formular kommen kann. Smoke-Test deckt den ganzen Weg ab | `52d69e5` |
|
||||||
|
| 07:01 | 07:12 | 0:11 | Rebrickable-Import | Setnummer genügt: Metadaten, Thema und lokal gespeichertes Bild; 14 Tests gegen gestubbtes `fetch` inklusive aller Fehlerfälle | `4e3cbb8` |
|
||||||
|
| 07:12 | 07:15 | 0:03 | Barcode-Scanner | EAN mit der Handykamera, Prüfziffer-Kontrolle, zxing wird erst beim Öffnen geladen | `baa94bf` |
|
||||||
|
| 07:15 | 07:18 | 0:03 | Fehlteile | Liste je Sammlungseintrag; Vollständigkeit pflegt sich daraus selbst | `5c5c521` |
|
||||||
|
| 07:18 | 07:23 | 0:05 | Export und Import | ZIP, JSON und CSV; Import ersetzt in einer Transaktion, 10 Tests für den Rundweg | `45aa8f1` |
|
||||||
|
| 07:23 | 07:27 | 0:04 | PWA | Manifest, Icons aus SVG-Quelle, Service Worker mit Offline-Katalog | `39271f8` |
|
||||||
|
| 07:27 | 07:45 | 0:18 | Verifikation und Dokumentation | Smoke-Test, Docker-Build, Changelog 0.2.0, Anleitung und Roadmap | folgt |
|
||||||
|
|
||||||
|
**Summe Phase 2: 0:55**
|
||||||
|
|
||||||
|
## Phase 3 – Nice to have (v0.3.0)
|
||||||
|
|
||||||
|
### 07.09.2026
|
||||||
|
|
||||||
|
| Von | Bis | Dauer | Schritt | Ergebnis | Commit |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 08:35 | 09:00 | 0:25 | Mehrere Sammler | Neue Tabelle, Migration mit Nachziehen der Bestandsdaten, Besitzlogik um eine Dimension erweitert, Umschalter in Katalog und Sammlung, Zuordnung in Sammlungseintrag und Wunschliste | `c23b754` |
|
||||||
|
| 09:00 | 09:13 | 0:13 | Dublettenprüfung | Ähnlichkeitsvergleich mit konservativer Schwelle, Warndialog mit Links, E2E-Test | `87131b8` |
|
||||||
|
| 09:13 | 09:17 | 0:04 | Preisbeobachtung | Preisreihe je Set, Wertentwicklung gegen die UVP, Kachel „Aktueller Wert" | `91fb66b` |
|
||||||
|
| 09:17 | 09:22 | 0:05 | Erinnerungen | SMTP optional, stündliche Prüfung, Sperrfrist je Liste, Verwaltungsseite mit Testfunktionen | `0d08df0` |
|
||||||
|
| 09:22 | 09:50 | 0:28 | Verifikation und Dokumentation | Docker-Build, Smoke-Test, Screenshots, Changelog 0.3.0, Anleitung | folgt |
|
||||||
|
|
||||||
|
**Summe Phase 3: 1:15**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Notizen und Entscheidungen
|
## Notizen und Entscheidungen
|
||||||
@@ -86,6 +116,39 @@ Commit-Spalte verweist auf den Stand am Ende des Schritts.
|
|||||||
Produktions-Image im Container – verlässlich und zugleich näher an dem, was
|
Produktions-Image im Container – verlässlich und zugleich näher an dem, was
|
||||||
Nutzer wirklich sehen.
|
Nutzer wirklich sehen.
|
||||||
|
|
||||||
|
### Phase 2
|
||||||
|
|
||||||
|
- **`server-only` in Tests**: Das Paket wirft beim Import ausserhalb einer
|
||||||
|
Server-Komponente, wodurch sich Servermodule nicht testen liessen. In
|
||||||
|
`vitest.config.mts` ist es jetzt auf einen leeren Stub gemappt – der Import
|
||||||
|
ist eine Build-Zeit-Absicherung, kein Verhalten.
|
||||||
|
- **Namenskonflikt `BASE_URL`**: Vite benutzt genau diesen Namen für seinen
|
||||||
|
Public-Base-Path und setzt ihn auf `/`. Die Tests überschreiben ihn deshalb
|
||||||
|
ausdrücklich. Dabei ist aufgefallen, dass `src/env.ts` bei einem leeren Wert
|
||||||
|
abbrach statt auf den Standard zurückzufallen – behoben, denn ein
|
||||||
|
`BASE_URL=` in der `.env` ist ein realistischer Tippfehler.
|
||||||
|
- **Rebrickable und Anleitungen**: Die API v3 stellt keine Anleitungsdateien
|
||||||
|
bereit. Statt das zu verschleiern, legt der Import einen externen Link auf
|
||||||
|
die LEGO-Serviceseite zur Setnummer an.
|
||||||
|
- **Import statt Zusammenführen**: Zwei Sammlungen zu mischen bräuchte
|
||||||
|
Konfliktregeln für jede Tabelle und wäre im Ergebnis nicht vorhersagbar.
|
||||||
|
Der Import ersetzt deshalb – dafür mit getipptem `ERSETZEN` als Bremse.
|
||||||
|
|
||||||
|
### Phase 3
|
||||||
|
|
||||||
|
- **Migration mit Bestandsdaten**: Die generierte SQL-Datei wurde von Hand um
|
||||||
|
drei Anweisungen ergänzt, die einen Sammler anlegen und alles Vorhandene
|
||||||
|
zuordnen. Ohne das wären nach dem Update alle Sammlungseinträge ohne
|
||||||
|
Besitzer gewesen.
|
||||||
|
- **Instrumentation und native Module**: Der Zeitplan für Erinnerungen liess
|
||||||
|
sich nicht aus Next' `instrumentation.ts` starten – die Datei wird auch für
|
||||||
|
die Edge-Runtime übersetzt, und das native SQLite-Modul bricht dort den
|
||||||
|
Build. Der Zeitplan startet jetzt aus dem Healthcheck heraus, den der
|
||||||
|
Container ohnehin alle 30 Sekunden aufruft.
|
||||||
|
- **BrickLink bewusst weggelassen**: OAuth1 mit einer pro Nutzer registrierten
|
||||||
|
Anwendung ist viel Apparat für eine Zahl, die man in zehn Sekunden eintippt.
|
||||||
|
Im Plan war der Punkt als optional vermerkt.
|
||||||
|
|
||||||
### Beim Testen gefunden und behoben
|
### Beim Testen gefunden und behoben
|
||||||
|
|
||||||
- Eine spontan aus dem Katalog angelegte Wunschliste war **nicht teilbar**:
|
- Eine spontan aus dem Katalog angelegte Wunschliste war **nicht teilbar**:
|
||||||
@@ -96,3 +159,8 @@ Commit-Spalte verweist auf den Stand am Ende des Schritts.
|
|||||||
`required` des Eingabefeldes.
|
`required` des Eingabefeldes.
|
||||||
- In der Mehrfachauswahl verdeckte die Aktionsleiste die letzte Kachelreihe;
|
- In der Mehrfachauswahl verdeckte die Aktionsleiste die letzte Kachelreihe;
|
||||||
das Raster bekommt im Auswahlmodus zusätzliches Polster nach unten.
|
das Raster bekommt im Auswahlmodus zusätzliches Polster nach unten.
|
||||||
|
- Die Sperrfrist der Erinnerungen rechnete mit umgedrehtem Vorzeichen und
|
||||||
|
hätte jede Erinnerung dauerhaft unterdrückt. Der Test dafür war zuerst da.
|
||||||
|
- Der Screenshot-Lauf wählte plötzlich die falschen Kacheln aus: Der neue
|
||||||
|
Sammler-Umschalter benutzt ebenfalls `aria-pressed`. Die Auswahlkacheln
|
||||||
|
tragen jetzt ein eigenes `data-set-card`.
|
||||||
|
|||||||
@@ -34,24 +34,47 @@ Laeuft vollstaendig im Docker-Stack.
|
|||||||
- [x] Dokumentation: README, QUICKSTART, bebilderte Anleitung,
|
- [x] Dokumentation: README, QUICKSTART, bebilderte Anleitung,
|
||||||
update.sh, Nginx-Proxy-Manager-Konfiguration
|
update.sh, Nginx-Proxy-Manager-Konfiguration
|
||||||
|
|
||||||
## Phase 2 - Komfort (Ziel: v0.2.0)
|
## Phase 2 - Komfort (v0.2.0, abgeschlossen am 07.09.2026)
|
||||||
|
|
||||||
- [ ] Rebrickable-Import: Setnummer eingeben, Metadaten und Bild
|
- [x] Rebrickable-Import: Setnummer eingeben, Metadaten und Bild
|
||||||
uebernehmen, Bild lokal cachen. Ohne API-Key bleibt alles manuell.
|
uebernehmen, Bild lokal cachen. Ohne API-Key bleibt alles manuell.
|
||||||
- [ ] Barcode-Scanner (EAN) per Handykamera, Suche nach EAN, bei
|
- [x] Barcode-Scanner (EAN) per Handykamera, Suche nach EAN, bei
|
||||||
Unbekanntem direkt ins Formular "Neues Set"
|
Unbekanntem direkt ins Formular "Neues Set"
|
||||||
- [ ] Fehlteile je Sammlungseintrag pflegen
|
- [x] Fehlteile je Sammlungseintrag pflegen
|
||||||
- [ ] Einladungslinks durch den Admin
|
- [x] Einladungslinks durch den Admin
|
||||||
- [ ] Export/Import: gesamte Datenbank als JSON, Dateien als ZIP,
|
- [x] Export/Import: gesamte Datenbank als JSON, Dateien als ZIP,
|
||||||
Sammlung zusaetzlich als CSV
|
Sammlung zusaetzlich als CSV
|
||||||
- [ ] PWA: Manifest, Service Worker, Katalog offline lesbar
|
- [x] PWA: Manifest, Service Worker, Katalog offline lesbar
|
||||||
|
|
||||||
## Phase 3 - Nice to have (Ziel: v0.3.0)
|
## Phase 3 - Nice to have (v0.3.0, abgeschlossen am 07.09.2026)
|
||||||
|
|
||||||
- [ ] Mehrere Sammler mit eigener Sammlung und eigenen Wunschlisten,
|
- [x] Mehrere Sammler mit eigener Sammlung und eigenen Wunschlisten,
|
||||||
gemeinsamer Katalog
|
gemeinsamer Katalog
|
||||||
- [ ] Preisbeobachtung und Wertentwicklung (manuell gepflegt,
|
- [x] Preisbeobachtung und Wertentwicklung (manuell gepflegt).
|
||||||
optional BrickLink-Preisfuehrer)
|
Der BrickLink-Preisfuehrer war als optional vermerkt und wurde
|
||||||
- [ ] E-Mail-Erinnerung (SMTP), wenn ein Wunschlisten-Termin naeher
|
bewusst weggelassen: er verlangt OAuth1 mit einer pro Nutzer
|
||||||
|
registrierten Anwendung - viel Apparat fuer eine Zahl, die sich in
|
||||||
|
zehn Sekunden eintippen laesst. Die Datenstruktur ist offen dafuer.
|
||||||
|
- [x] E-Mail-Erinnerung (SMTP), wenn ein Wunschlisten-Termin naeher
|
||||||
rueckt und Eintraege unreserviert sind
|
rueckt und Eintraege unreserviert sind
|
||||||
- [ ] Dublettenpruefung beim Anlegen von Sets
|
- [x] Dublettenpruefung beim Anlegen von Sets
|
||||||
|
|
||||||
|
## Design und Sicherheit (v0.4.0, abgeschlossen am 25.10.2026)
|
||||||
|
|
||||||
|
- [x] Abhängigkeiten mit bekannten Sicherheitslücken aktualisiert
|
||||||
|
|
||||||
|
- [x] Designauswahl pro Gerät, sechs Designs, Standard „Klemmbaustein"
|
||||||
|
- [x] Platzhalter für Sets ohne Bild, neues Logo, Schriften lokal
|
||||||
|
- [x] Startseite als Überblick mit Steinreihe und nächstem Termin
|
||||||
|
- [ ] Filter im Katalog als Reiter statt Auswahlfelder
|
||||||
|
- [ ] Scan-Button mittig in der unteren Navigation
|
||||||
|
|
||||||
|
## Danach
|
||||||
|
|
||||||
|
Phase 1 bis 3 sind umgesetzt. Was noch offen ist, steht hier - ohne Termin
|
||||||
|
und ohne Anspruch auf Vollstaendigkeit:
|
||||||
|
|
||||||
|
- [ ] BrickLink-Preisfuehrer anbinden, falls der Aufwand sich doch lohnt
|
||||||
|
- [ ] Mehrere Fotos je Sammlungseintrag (bisher nur je Set)
|
||||||
|
- [ ] Anleitungen im PDF-Viewer durchsuchen
|
||||||
|
- [ ] Statistik ueber die Zeit: was kam wann dazu
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 162 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 285 KiB After Width: | Height: | Size: 435 KiB |
|
Before Width: | Height: | Size: 202 KiB After Width: | Height: | Size: 322 KiB |
|
Before Width: | Height: | Size: 212 KiB After Width: | Height: | Size: 361 KiB |
|
Before Width: | Height: | Size: 252 KiB After Width: | Height: | Size: 446 KiB |
|
Before Width: | Height: | Size: 183 KiB After Width: | Height: | Size: 329 KiB |
|
Before Width: | Height: | Size: 184 KiB After Width: | Height: | Size: 322 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 292 KiB After Width: | Height: | Size: 475 KiB |
|
Before Width: | Height: | Size: 155 KiB After Width: | Height: | Size: 234 KiB |
|
Before Width: | Height: | Size: 244 KiB After Width: | Height: | Size: 597 KiB |
|
Before Width: | Height: | Size: 187 KiB After Width: | Height: | Size: 248 KiB |
|
Before Width: | Height: | Size: 294 KiB After Width: | Height: | Size: 476 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 283 KiB |
|
After Width: | Height: | Size: 299 KiB |
|
After Width: | Height: | Size: 205 KiB |
|
After Width: | Height: | Size: 281 KiB |
|
After Width: | Height: | Size: 297 KiB |
|
After Width: | Height: | Size: 142 KiB |
|
After Width: | Height: | Size: 368 KiB |
|
After Width: | Height: | Size: 336 KiB |
|
After Width: | Height: | Size: 222 KiB |
|
After Width: | Height: | Size: 136 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;
|
||||||
@@ -8,6 +8,27 @@
|
|||||||
"when": 1788726503038,
|
"when": 1788726503038,
|
||||||
"tag": "0000_rapid_gargoyle",
|
"tag": "0000_rapid_gargoyle",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1788763704015,
|
||||||
|
"tag": "0001_lumpy_bill_hollister",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1788765229376,
|
||||||
|
"tag": "0002_aspiring_whiplash",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 3,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1788765478965,
|
||||||
|
"tag": "0003_powerful_sally_floyd",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import { execFileSync } from 'node:child_process';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Migrates the smoke-test database. The directory itself is wiped earlier by
|
|
||||||
* scripts/reset-e2e.mjs, because Playwright starts its web server before this
|
|
||||||
* hook runs and the open database file would block the delete on Windows.
|
|
||||||
*/
|
|
||||||
export default function globalSetup() {
|
|
||||||
execFileSync('node', ['node_modules/tsx/dist/cli.mjs', 'scripts/migrate.ts'], {
|
|
||||||
stdio: 'inherit',
|
|
||||||
env: { ...process.env, DATA_DIR: './data-e2e' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -19,6 +19,9 @@ function shotPath(name: string) {
|
|||||||
async function shoot(page: Page, name: string) {
|
async function shoot(page: Page, name: string) {
|
||||||
// Let fonts and images settle so the picture is not half-rendered.
|
// Let fonts and images settle so the picture is not half-rendered.
|
||||||
await page.waitForLoadState('networkidle');
|
await page.waitForLoadState('networkidle');
|
||||||
|
// A fixed bottom bar would be painted where the first viewport ends, in the
|
||||||
|
// middle of a full-page picture. In flow it lands at the end, where it belongs.
|
||||||
|
await page.addStyleTag({ content: '.fixed.bottom-0 { position: static !important; }' });
|
||||||
await page.screenshot({ path: shotPath(name), fullPage: true });
|
await page.screenshot({ path: shotPath(name), fullPage: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,7 +69,8 @@ test('Screenshots fuer die Anleitung', async ({ page, browser }) => {
|
|||||||
|
|
||||||
await page.getByRole('button', { name: 'Auswählen' }).click();
|
await page.getByRole('button', { name: 'Auswählen' }).click();
|
||||||
await expect(page.getByText('Tippe die Sets an')).toBeVisible();
|
await expect(page.getByText('Tippe die Sets an')).toBeVisible();
|
||||||
const cards = page.locator('button[aria-pressed]');
|
// data-set-card, not aria-pressed: the collector switcher uses that too.
|
||||||
|
const cards = page.locator('button[data-set-card]');
|
||||||
await cards.nth(0).click();
|
await cards.nth(0).click();
|
||||||
await cards.nth(1).click();
|
await cards.nth(1).click();
|
||||||
await expect(page.getByText('2 ausgewählt')).toBeVisible();
|
await expect(page.getByText('2 ausgewählt')).toBeVisible();
|
||||||
@@ -116,6 +120,8 @@ test('Screenshots fuer die Anleitung', async ({ page, browser }) => {
|
|||||||
await visitor.getByRole('button', { name: 'Reservieren' }).click();
|
await visitor.getByRole('button', { name: 'Reservieren' }).click();
|
||||||
await expect(visitor.getByText('Reservierung gespeichert')).toBeVisible();
|
await expect(visitor.getByText('Reservierung gespeichert')).toBeVisible();
|
||||||
await visitor.getByRole('button', { name: 'Alles klar' }).click();
|
await visitor.getByRole('button', { name: 'Alles klar' }).click();
|
||||||
|
// Otherwise the picture catches the dialog halfway through fading out.
|
||||||
|
await expect(visitor.getByRole('dialog')).toBeHidden();
|
||||||
await shoot(visitor, '14-reserviert');
|
await shoot(visitor, '14-reserviert');
|
||||||
await visitorContext.close();
|
await visitorContext.close();
|
||||||
|
|
||||||
@@ -127,4 +133,78 @@ test('Screenshots fuer die Anleitung', async ({ page, browser }) => {
|
|||||||
await visit(page, '/konto');
|
await visit(page, '/konto');
|
||||||
await expect(page.getByText('Passkeys', { exact: true })).toBeVisible();
|
await expect(page.getByText('Passkeys', { exact: true })).toBeVisible();
|
||||||
await shoot(page, '16-konto-passkeys');
|
await shoot(page, '16-konto-passkeys');
|
||||||
|
|
||||||
|
// The same start page in the dark design, then back to the default.
|
||||||
|
await page.getByText('Nachtblau').click();
|
||||||
|
await expect(page.locator('html')).toHaveAttribute('data-design', 'nacht');
|
||||||
|
// The look switches at once; the cookie follows with the server action.
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
await visit(page, '/');
|
||||||
|
await expect(page.locator('html')).toHaveAttribute('data-design', 'nacht');
|
||||||
|
await shoot(page, '25-design-nachtblau');
|
||||||
|
await visit(page, '/konto');
|
||||||
|
await page.getByText('Klemmbaustein').click();
|
||||||
|
await expect(page.locator('html')).toHaveAttribute('data-design', 'klemmbaustein');
|
||||||
|
|
||||||
|
// --- 12. Lego import ------------------------------------------------------
|
||||||
|
// The screenshot container runs with a placeholder API key so the manual
|
||||||
|
// shows the form, not the "not configured" hint. Nothing is submitted.
|
||||||
|
await visit(page, '/katalog/neu');
|
||||||
|
await expect(page.getByText('Lego-Import')).toBeVisible();
|
||||||
|
await shoot(page, '17-lego-import');
|
||||||
|
|
||||||
|
// --- 13. missing parts ----------------------------------------------------
|
||||||
|
await visit(page, '/katalog?q=60398');
|
||||||
|
await page.getByText('Familienhaus und Elektroauto').first().click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Familienhaus und Elektroauto' })).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: 'Fehlteile' }).first().click();
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
await page.getByLabel('Teilenummer').fill('3001');
|
||||||
|
await page.getByLabel('Farbe').fill('Rot');
|
||||||
|
await page.getByRole('button', { name: 'Hinzufügen' }).click();
|
||||||
|
await expect(page.getByText('3001')).toBeVisible();
|
||||||
|
await page.screenshot({ path: shotPath('18-fehlteile') });
|
||||||
|
await page.getByRole('button', { name: 'Fertig' }).click();
|
||||||
|
|
||||||
|
// --- 14. invitations ------------------------------------------------------
|
||||||
|
await visit(page, '/verwaltung/einladungen');
|
||||||
|
await expect(page.getByRole('button', { name: 'Einladung erstellen' })).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: 'Einladung erstellen' }).click();
|
||||||
|
await expect(page.getByRole('dialog')).toBeVisible();
|
||||||
|
await page.screenshot({ path: shotPath('19-einladungen') });
|
||||||
|
await page.getByRole('button', { name: 'Abbrechen' }).click();
|
||||||
|
|
||||||
|
// --- 15. export and import ------------------------------------------------
|
||||||
|
await visit(page, '/verwaltung/daten');
|
||||||
|
await expect(page.getByText('Alles als ZIP')).toBeVisible();
|
||||||
|
await shoot(page, '20-daten');
|
||||||
|
|
||||||
|
// --- 16. collectors -------------------------------------------------------
|
||||||
|
await visit(page, '/verwaltung/sammler');
|
||||||
|
await expect(page.getByText('Der Katalog ist gemeinsam')).toBeVisible();
|
||||||
|
await shoot(page, '21-sammler');
|
||||||
|
|
||||||
|
// The switcher only exists because the seed created two collectors.
|
||||||
|
await visit(page, '/katalog?besitz=fehlt');
|
||||||
|
await expect(page.getByRole('button', { name: /Max/ })).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: /Max/ }).click();
|
||||||
|
await expect(page.getByText('Sets, die Max noch nicht hat')).toBeVisible();
|
||||||
|
await shoot(page, '22-sammler-filter');
|
||||||
|
|
||||||
|
// --- 17. prices -----------------------------------------------------------
|
||||||
|
await visit(page, '/katalog?q=60398');
|
||||||
|
await page.getByText('Familienhaus und Elektroauto').first().click();
|
||||||
|
await expect(page.getByText('Preis und Wertentwicklung')).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: 'Preis notieren' }).click();
|
||||||
|
// Role, not label: the label text carries the required marker.
|
||||||
|
await page.getByRole('textbox', { name: 'Preis', exact: true }).fill('79,99');
|
||||||
|
await page.getByLabel('Wo').fill('Amazon');
|
||||||
|
await page.getByRole('button', { name: 'Speichern' }).click();
|
||||||
|
await expect(page.getByText('Amazon')).toBeVisible();
|
||||||
|
await shoot(page, '23-preise');
|
||||||
|
|
||||||
|
// --- 18. reminders --------------------------------------------------------
|
||||||
|
await visit(page, '/verwaltung/erinnerungen');
|
||||||
|
await expect(page.getByText('Erinnerungen').first()).toBeVisible();
|
||||||
|
await shoot(page, '24-erinnerungen');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -81,6 +81,77 @@ test('Registrieren, Set anlegen, Wunschliste teilen und reservieren', async ({ p
|
|||||||
await visitorContext.close();
|
await visitorContext.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Dublettenpruefung warnt vor einem fast gleichen Set', async ({ page }) => {
|
||||||
|
await page.goto('/anmelden');
|
||||||
|
await page.getByLabel('E-Mail').fill('test@bricktrack.local');
|
||||||
|
await page.getByLabel('Passwort').fill('smoketest-passwort');
|
||||||
|
await page.getByRole('button', { name: 'Anmelden', exact: true }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Hallo Florian' })).toBeVisible();
|
||||||
|
|
||||||
|
// Dasselbe Set noch einmal, mit Tippfehler im Namen und anderer Nummer.
|
||||||
|
await page.goto('/katalog/neu');
|
||||||
|
await page.getByLabel('Setnummer').fill('60399');
|
||||||
|
await page
|
||||||
|
.getByRole('textbox', { name: 'Name', exact: true })
|
||||||
|
.fill('Familienhaus und Elektroauo');
|
||||||
|
await page.getByRole('button', { name: 'Set anlegen' }).click();
|
||||||
|
|
||||||
|
await expect(page.getByText('Gibt es das vielleicht schon?')).toBeVisible();
|
||||||
|
await expect(page.getByText('Sehr ähnlicher Name')).toBeVisible();
|
||||||
|
|
||||||
|
// Zurueck ins Formular: nichts wurde angelegt.
|
||||||
|
await page.getByRole('button', { name: 'Zurück zum Formular' }).click();
|
||||||
|
await page.goto('/katalog?q=60399');
|
||||||
|
await expect(page.getByText('0 Sets')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Einladung: zweiter Elternteil bekommt ein eigenes Konto', async ({ page, browser }) => {
|
||||||
|
// The admin from the first test is still the only account, so log in.
|
||||||
|
await page.goto('/anmelden');
|
||||||
|
await page.getByLabel('E-Mail').fill('test@bricktrack.local');
|
||||||
|
await page.getByLabel('Passwort').fill('smoketest-passwort');
|
||||||
|
await page.getByRole('button', { name: 'Anmelden', exact: true }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Hallo Florian' })).toBeVisible();
|
||||||
|
|
||||||
|
// --- the admin issues an invitation --------------------------------------
|
||||||
|
await page.goto('/verwaltung/einladungen');
|
||||||
|
await page.getByRole('button', { name: 'Einladung erstellen' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Erstellen' }).click();
|
||||||
|
|
||||||
|
const inviteUrl = await page
|
||||||
|
.getByRole('textbox', { name: 'Einladungslink' })
|
||||||
|
.inputValue();
|
||||||
|
expect(inviteUrl).toContain('/einladung/');
|
||||||
|
await page.getByRole('button', { name: 'Fertig' }).click();
|
||||||
|
await expect(page.getByText('Offen').first()).toBeVisible();
|
||||||
|
|
||||||
|
// --- the invited person registers ----------------------------------------
|
||||||
|
const guestContext = await browser.newContext();
|
||||||
|
const guest = await guestContext.newPage();
|
||||||
|
await guest.goto(inviteUrl);
|
||||||
|
await expect(guest.getByRole('button', { name: 'Konto anlegen' })).toBeVisible();
|
||||||
|
|
||||||
|
await guest.getByLabel('Name').fill('Mama');
|
||||||
|
await guest.getByLabel('E-Mail').fill('mama@bricktrack.local');
|
||||||
|
await guest.getByLabel('Passwort').fill('zweites-passwort');
|
||||||
|
await guest.getByRole('button', { name: 'Konto anlegen' }).click();
|
||||||
|
|
||||||
|
await expect(guest.getByRole('heading', { name: 'Hallo Mama' })).toBeVisible();
|
||||||
|
|
||||||
|
// A member is not an admin: no invitation management for them.
|
||||||
|
await guest.goto('/verwaltung/einladungen');
|
||||||
|
await expect(guest).toHaveURL(/\/$|\/anmelden/);
|
||||||
|
|
||||||
|
// --- the link is single use ----------------------------------------------
|
||||||
|
const secondTry = await browser.newContext();
|
||||||
|
const secondPage = await secondTry.newPage();
|
||||||
|
await secondPage.goto(inviteUrl);
|
||||||
|
await expect(secondPage.getByText('Einladung ungültig')).toBeVisible();
|
||||||
|
|
||||||
|
await guestContext.close();
|
||||||
|
await secondTry.close();
|
||||||
|
});
|
||||||
|
|
||||||
test('Ohne Anmeldung fuehrt jeder geschuetzte Pfad zur Anmeldung', async ({ page }) => {
|
test('Ohne Anmeldung fuehrt jeder geschuetzte Pfad zur Anmeldung', async ({ page }) => {
|
||||||
await page.goto('/sammlung');
|
await page.goto('/sammlung');
|
||||||
await expect(page).toHaveURL(/\/anmelden|\/registrieren/);
|
await expect(page).toHaveURL(/\/anmelden|\/registrieren/);
|
||||||
|
|||||||
@@ -11,6 +11,17 @@ const compat = new FlatCompat({
|
|||||||
|
|
||||||
const eslintConfig = [
|
const eslintConfig = [
|
||||||
...compat.extends("next/core-web-vitals", "next/typescript"),
|
...compat.extends("next/core-web-vitals", "next/typescript"),
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
// Destructuring a key out in order to drop it is how we omit fields
|
||||||
|
// (share tokens, reservations) from an export - the binding is
|
||||||
|
// deliberately unused.
|
||||||
|
"@typescript-eslint/no-unused-vars": [
|
||||||
|
"warn",
|
||||||
|
{ ignoreRestSiblings: true, argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
ignores: [
|
ignores: [
|
||||||
"node_modules/**",
|
"node_modules/**",
|
||||||
|
|||||||
@@ -1,73 +1,99 @@
|
|||||||
{
|
{
|
||||||
"name": "bricktrack",
|
"name": "bricktrack",
|
||||||
"version": "0.1.0",
|
"version": "0.4.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Self-hosted Verwaltung fuer Spielzeug-Set-Sammlungen (Lego, Playmobil, Cobi, ...)",
|
"description": "Self-hosted Verwaltung fuer Spielzeug-Set-Sammlungen (Lego, Playmobil, Cobi, ...)",
|
||||||
"packageManager": "pnpm@10.34.5",
|
"packageManager": "pnpm@10.34.5",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22"
|
"node": ">=22"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "tsx scripts/migrate.ts",
|
"db:migrate": "tsx scripts/migrate.ts",
|
||||||
"db:seed": "tsx scripts/seed.ts",
|
"db:seed": "tsx scripts/seed.ts",
|
||||||
"db:studio": "drizzle-kit studio",
|
"db:studio": "drizzle-kit studio",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:e2e": "node scripts/reset-e2e.mjs && playwright test",
|
"test:e2e": "node scripts/e2e-prepare.mjs && playwright test",
|
||||||
"screenshots": "node scripts/screenshots-prepare.mjs && playwright test --config=playwright.screenshots.config.ts"
|
"screenshots": "node scripts/screenshots-prepare.mjs && playwright test --config=playwright.screenshots.config.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@better-auth/passkey": "^1.7.3",
|
"@better-auth/passkey": "^1.7.12",
|
||||||
"better-auth": "^1.7.3",
|
"@fontsource-variable/archivo": "^5.3.0",
|
||||||
"better-sqlite3": "^13.0.3",
|
"@fontsource-variable/bricolage-grotesque": "^5.3.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"@fontsource-variable/fredoka": "^5.3.0",
|
||||||
"cn": "^0.2.6",
|
"@fontsource-variable/inter": "^5.3.0",
|
||||||
"date-fns": "^4.4.0",
|
"@fontsource-variable/nunito": "^5.3.0",
|
||||||
"drizzle-orm": "^0.45.2",
|
"@fontsource-variable/outfit": "^5.3.0",
|
||||||
"lucide-react": "^1.41.0",
|
"@zxing/browser": "^0.2.1",
|
||||||
"next": "15.5.25",
|
"@zxing/library": "^0.23.0",
|
||||||
"next-themes": "^0.4.6",
|
"better-auth": "^1.7.12",
|
||||||
"pdfjs-dist": "^6.3.289",
|
"better-sqlite3": "^13.0.3",
|
||||||
"radix-ui": "^1.6.7",
|
"class-variance-authority": "^0.7.1",
|
||||||
"react": "19.1.0",
|
"cn": "^0.2.6",
|
||||||
"react-dom": "19.1.0",
|
"date-fns": "^4.4.0",
|
||||||
"server-only": "^0.0.1",
|
"drizzle-orm": "^0.45.4",
|
||||||
"sharp": "^0.35.4",
|
"fflate": "^0.8.3",
|
||||||
"sonner": "^2.0.8",
|
"lucide-react": "^1.41.0",
|
||||||
"tw-animate-css": "^1.4.0",
|
"next": "15.5.28",
|
||||||
"zod": "^4.5.4"
|
"next-themes": "^0.4.6",
|
||||||
},
|
"nodemailer": "^10.2.1",
|
||||||
"devDependencies": {
|
"pdfjs-dist": "^6.3.289",
|
||||||
"@eslint/eslintrc": "^3",
|
"radix-ui": "^1.6.7",
|
||||||
"@playwright/test": "^1.63.0",
|
"react": "19.1.0",
|
||||||
"@tailwindcss/postcss": "^4",
|
"react-dom": "19.1.0",
|
||||||
"@types/better-sqlite3": "^9.6.0",
|
"server-only": "^0.0.1",
|
||||||
"@types/node": "^20",
|
"sharp": "^0.35.5",
|
||||||
"@types/react": "^19",
|
"sonner": "^2.0.8",
|
||||||
"@types/react-dom": "^19",
|
"tw-animate-css": "^1.4.0",
|
||||||
"dotenv": "^17.4.2",
|
"zod": "^4.6.7"
|
||||||
"drizzle-kit": "^0.31.10",
|
},
|
||||||
"esbuild": "^0.25.12",
|
"devDependencies": {
|
||||||
"eslint": "^9",
|
"@eslint/eslintrc": "^3",
|
||||||
"eslint-config-next": "15.5.25",
|
"@playwright/test": "^1.64.1",
|
||||||
"shadcn": "^4.21.0",
|
"@tailwindcss/postcss": "^4",
|
||||||
"tailwindcss": "^4",
|
"@types/better-sqlite3": "^9.6.0",
|
||||||
"tsx": "^4.23.13",
|
"@types/node": "^20",
|
||||||
"typescript": "^5",
|
"@types/nodemailer": "^8.0.1",
|
||||||
"vitest": "^5.0.0"
|
"@types/react": "^19",
|
||||||
},
|
"@types/react-dom": "^19",
|
||||||
"pnpm": {
|
"dotenv": "^17.4.2",
|
||||||
"onlyBuiltDependencies": [
|
"drizzle-kit": "^0.31.11",
|
||||||
"better-sqlite3",
|
"esbuild": "^0.25.12",
|
||||||
"esbuild",
|
"eslint": "^9",
|
||||||
"sharp",
|
"eslint-config-next": "15.5.28",
|
||||||
"unrs-resolver"
|
"shadcn": "^4.22.1",
|
||||||
]
|
"tailwindcss": "^4",
|
||||||
}
|
"tsx": "^4.23.15",
|
||||||
}
|
"typescript": "^5",
|
||||||
|
"vite": "^8.3.5",
|
||||||
|
"vitest": "^5.0.5"
|
||||||
|
},
|
||||||
|
"pnpm": {
|
||||||
|
"onlyBuiltDependencies": [
|
||||||
|
"better-sqlite3",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,55 +1,58 @@
|
|||||||
import { defineConfig, devices } from '@playwright/test';
|
import path from 'node:path';
|
||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
/**
|
|
||||||
* Smoke test setup.
|
/**
|
||||||
*
|
* Smoke test setup.
|
||||||
* Runs against its own data directory (./data-e2e) so a test never touches the
|
*
|
||||||
* real database. The global setup wipes and migrates it before each run.
|
* Runs against the production container with its own data directory
|
||||||
*/
|
* (./data-e2e), so a test never touches the real database and never depends on
|
||||||
const PORT = 3210;
|
* the dev server compiling fast enough.
|
||||||
const BASE_URL = `http://localhost:${PORT}`;
|
*/
|
||||||
|
const PORT = 3210;
|
||||||
export default defineConfig({
|
const BASE_URL = `http://localhost:${PORT}`;
|
||||||
testDir: './e2e',
|
|
||||||
// The screenshot run has its own config, its own server and its own data
|
// Docker wants forward slashes, also on Windows.
|
||||||
// directory - it must never join the smoke run.
|
const dataDir = path.resolve(process.cwd(), 'data-e2e').replace(/\\/g, '/');
|
||||||
testMatch: 'smoke.spec.ts',
|
|
||||||
globalSetup: './e2e/global-setup.ts',
|
export default defineConfig({
|
||||||
fullyParallel: false,
|
testDir: './e2e',
|
||||||
workers: 1,
|
// The screenshot run has its own config, its own server and its own data
|
||||||
retries: 0,
|
// directory - it must never join the smoke run.
|
||||||
// Generous: the dev server compiles each route on first visit.
|
testMatch: 'smoke.spec.ts',
|
||||||
timeout: 240_000,
|
fullyParallel: false,
|
||||||
// A first navigation to a route the dev server has not compiled yet can
|
workers: 1,
|
||||||
// take well over ten seconds on a cold cache.
|
retries: 0,
|
||||||
expect: { timeout: 30_000 },
|
timeout: 90_000,
|
||||||
reporter: process.env.CI ? 'github' : 'list',
|
expect: { timeout: 15_000 },
|
||||||
use: {
|
reporter: process.env.CI ? 'github' : 'list',
|
||||||
baseURL: BASE_URL,
|
use: {
|
||||||
trace: 'retain-on-failure',
|
baseURL: BASE_URL,
|
||||||
locale: 'de-DE',
|
trace: 'retain-on-failure',
|
||||||
timezoneId: 'Europe/Berlin',
|
locale: 'de-DE',
|
||||||
},
|
timezoneId: 'Europe/Berlin',
|
||||||
projects: [
|
},
|
||||||
{
|
projects: [
|
||||||
name: 'mobile',
|
{
|
||||||
// The app is built for phones first, so the smoke test uses one.
|
name: 'mobile',
|
||||||
use: { ...devices['Pixel 7'] },
|
// The app is built for phones first, so the smoke test uses one.
|
||||||
},
|
use: { ...devices['Pixel 7'] },
|
||||||
],
|
},
|
||||||
webServer: {
|
],
|
||||||
command: `pnpm exec next dev -p ${PORT}`,
|
webServer: {
|
||||||
url: `${BASE_URL}/api/health`,
|
command: [
|
||||||
reuseExistingServer: false,
|
'docker run --rm --name bricktrack-e2e',
|
||||||
timeout: 180_000,
|
`-p ${PORT}:3000`,
|
||||||
stdout: 'pipe',
|
`-e BASE_URL=${BASE_URL}`,
|
||||||
stderr: 'pipe',
|
'-e BETTER_AUTH_SECRET=playwright-smoke-test-secret-0123456789abcdef',
|
||||||
env: {
|
'-e TZ=Europe/Berlin',
|
||||||
NODE_ENV: 'development',
|
'-e MAX_UPLOAD_MB=25',
|
||||||
DATA_DIR: './data-e2e',
|
`-v "${dataDir}:/data"`,
|
||||||
BASE_URL,
|
'bricktrack:e2e',
|
||||||
BETTER_AUTH_SECRET: 'playwright-smoke-test-secret-0123456789abcdef',
|
].join(' '),
|
||||||
MAX_UPLOAD_MB: '25',
|
url: `${BASE_URL}/api/health`,
|
||||||
},
|
reuseExistingServer: false,
|
||||||
},
|
timeout: 180_000,
|
||||||
});
|
stdout: 'pipe',
|
||||||
|
stderr: 'pipe',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ export default defineConfig({
|
|||||||
`-e BASE_URL=${BASE_URL}`,
|
`-e BASE_URL=${BASE_URL}`,
|
||||||
'-e BETTER_AUTH_SECRET=screenshot-run-secret-0123456789abcdefgh',
|
'-e BETTER_AUTH_SECRET=screenshot-run-secret-0123456789abcdefgh',
|
||||||
'-e TZ=Europe/Berlin',
|
'-e TZ=Europe/Berlin',
|
||||||
|
// A placeholder key so the manual shows the import form rather than the
|
||||||
|
// "not configured" hint. The screenshot run never submits it.
|
||||||
|
'-e REBRICKABLE_API_KEY=screenshot-placeholder',
|
||||||
`-v "${dataDir}:/data"`,
|
`-v "${dataDir}:/data"`,
|
||||||
'bricktrack:shots',
|
'bricktrack:shots',
|
||||||
].join(' '),
|
].join(' '),
|
||||||
|
|||||||
|
After Width: | Height: | Size: 332 B |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"name": "Bricktrack",
|
||||||
|
"short_name": "Bricktrack",
|
||||||
|
"description": "Sammlung und Wunschlisten für Spielzeug-Sets",
|
||||||
|
"lang": "de",
|
||||||
|
"dir": "ltr",
|
||||||
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"theme_color": "#0a0a0a",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-maskable-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"shortcuts": [
|
||||||
|
{
|
||||||
|
"name": "Fehlende Sets",
|
||||||
|
"short_name": "Fehlt noch",
|
||||||
|
"url": "/katalog?besitz=fehlt"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Sammlung",
|
||||||
|
"short_name": "Sammlung",
|
||||||
|
"url": "/sammlung"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
/**
|
||||||
|
* Bricktrack service worker.
|
||||||
|
*
|
||||||
|
* Goal: the catalog stays readable in the cellar, in the shop, and wherever
|
||||||
|
* else the phone loses its connection. Anything that changes data still needs
|
||||||
|
* the network - a wish reserved offline would be a lie.
|
||||||
|
*
|
||||||
|
* Caches are versioned; bumping VERSION drops the old ones on activation.
|
||||||
|
*/
|
||||||
|
const VERSION = 'v1';
|
||||||
|
const PAGE_CACHE = `bricktrack-pages-${VERSION}`;
|
||||||
|
const ASSET_CACHE = `bricktrack-assets-${VERSION}`;
|
||||||
|
const IMAGE_CACHE = `bricktrack-images-${VERSION}`;
|
||||||
|
const OFFLINE_URL = '/offline';
|
||||||
|
|
||||||
|
self.addEventListener('install', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches
|
||||||
|
.open(PAGE_CACHE)
|
||||||
|
.then((cache) => cache.addAll([OFFLINE_URL, '/manifest.webmanifest']))
|
||||||
|
.then(() => self.skipWaiting()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
const keep = new Set([PAGE_CACHE, ASSET_CACHE, IMAGE_CACHE]);
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
caches
|
||||||
|
.keys()
|
||||||
|
.then((keys) => Promise.all(keys.filter((key) => !keep.has(key)).map((key) => caches.delete(key))))
|
||||||
|
.then(() => self.clients.claim()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signing out must not leave readable pages behind on a shared device, so the
|
||||||
|
* app tells the worker to drop everything it holds.
|
||||||
|
*/
|
||||||
|
self.addEventListener('message', (event) => {
|
||||||
|
if (event.data === 'bricktrack:clear-cache') {
|
||||||
|
event.waitUntil(caches.keys().then((keys) => Promise.all(keys.map((key) => caches.delete(key)))));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Long-lived, content-addressed build output. */
|
||||||
|
function isStaticAsset(url) {
|
||||||
|
return url.pathname.startsWith('/_next/static/') || url.pathname.startsWith('/icons/');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set pictures; their file names contain a UUID and never change. */
|
||||||
|
function isCacheableImage(url) {
|
||||||
|
return url.pathname.startsWith('/api/files/images/');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything that must never be answered from a cache. */
|
||||||
|
function isNeverCached(url) {
|
||||||
|
return (
|
||||||
|
url.pathname.startsWith('/api/auth/') ||
|
||||||
|
url.pathname.startsWith('/api/export/') ||
|
||||||
|
url.pathname.startsWith('/api/health')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cacheFirst(request, cacheName) {
|
||||||
|
const cache = await caches.open(cacheName);
|
||||||
|
const hit = await cache.match(request);
|
||||||
|
if (hit) return hit;
|
||||||
|
|
||||||
|
const response = await fetch(request);
|
||||||
|
if (response.ok) cache.put(request, response.clone());
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Network first, cache as a fallback: the catalog is always current when there
|
||||||
|
* is a connection, and still readable when there is not.
|
||||||
|
*/
|
||||||
|
async function networkFirst(request, cacheName) {
|
||||||
|
const cache = await caches.open(cacheName);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(request);
|
||||||
|
if (response.ok) cache.put(request, response.clone());
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
const hit = await cache.match(request);
|
||||||
|
if (hit) return hit;
|
||||||
|
|
||||||
|
if (request.mode === 'navigate') {
|
||||||
|
const offline = await cache.match(OFFLINE_URL);
|
||||||
|
if (offline) return offline;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.addEventListener('fetch', (event) => {
|
||||||
|
const { request } = event;
|
||||||
|
|
||||||
|
// Only plain GETs are safe to serve from a cache.
|
||||||
|
if (request.method !== 'GET') return;
|
||||||
|
|
||||||
|
const url = new URL(request.url);
|
||||||
|
if (url.origin !== self.location.origin) return;
|
||||||
|
if (isNeverCached(url)) return;
|
||||||
|
|
||||||
|
if (isStaticAsset(url)) {
|
||||||
|
event.respondWith(cacheFirst(request, ASSET_CACHE));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCacheableImage(url)) {
|
||||||
|
event.respondWith(cacheFirst(request, IMAGE_CACHE));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Page loads and the RSC payloads behind client-side navigation.
|
||||||
|
if (request.mode === 'navigate' || url.searchParams.has('_rsc')) {
|
||||||
|
event.respondWith(networkFirst(request, PAGE_CACHE));
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* Prepares the smoke run.
|
||||||
|
*
|
||||||
|
* The tests run against the production image, not the dev server: the dev
|
||||||
|
* server compiles routes on demand and, under the load of a full run, would
|
||||||
|
* occasionally drop a chunk mid-navigation. Testing the artefact that people
|
||||||
|
* actually deploy is also simply more useful.
|
||||||
|
*/
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const dataDir = path.resolve(process.cwd(), 'data-e2e');
|
||||||
|
|
||||||
|
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||||
|
fs.mkdirSync(path.join(dataDir, 'db'), { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(dataDir, 'files'), { recursive: true });
|
||||||
|
console.log(`[e2e] ${dataDir} zurueckgesetzt.`);
|
||||||
|
|
||||||
|
// A container left behind by an aborted run would hold the port.
|
||||||
|
try {
|
||||||
|
execFileSync('docker', ['rm', '-f', 'bricktrack-e2e'], { stdio: 'ignore' });
|
||||||
|
} catch {
|
||||||
|
// Nothing to remove - the normal case.
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[e2e] Image wird gebaut (bricktrack:e2e) …');
|
||||||
|
execFileSync('docker', ['build', '-t', 'bricktrack:e2e', '.'], { stdio: 'inherit' });
|
||||||
|
console.log('[e2e] Image ist bereit.');
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* Renders the app icons from one SVG source.
|
||||||
|
*
|
||||||
|
* Run with `node scripts/generate-icons.mjs` after changing the mark; the
|
||||||
|
* results are committed so neither the build nor the container needs to draw
|
||||||
|
* anything at runtime.
|
||||||
|
*/
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
|
||||||
|
const OUT_DIR = path.resolve(process.cwd(), 'public/icons');
|
||||||
|
|
||||||
|
// Four studs on a rounded plate - readable down to 32px, which a detailed
|
||||||
|
// brick drawing would not be.
|
||||||
|
const mark = (background, foreground) => `
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
|
<rect width="512" height="512" rx="112" fill="${background}"/>
|
||||||
|
<g fill="${foreground}">
|
||||||
|
<rect x="112" y="112" width="128" height="128" rx="28"/>
|
||||||
|
<rect x="272" y="112" width="128" height="128" rx="28"/>
|
||||||
|
<rect x="112" y="272" width="128" height="128" rx="28"/>
|
||||||
|
<rect x="272" y="272" width="128" height="128" rx="28"/>
|
||||||
|
</g>
|
||||||
|
</svg>`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A maskable icon has to survive being cropped to a circle, so the mark sits
|
||||||
|
* on a full-bleed background with generous padding.
|
||||||
|
*/
|
||||||
|
const maskable = (background, foreground) => `
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
|
<rect width="512" height="512" fill="${background}"/>
|
||||||
|
<g fill="${foreground}">
|
||||||
|
<rect x="146" y="146" width="100" height="100" rx="22"/>
|
||||||
|
<rect x="266" y="146" width="100" height="100" rx="22"/>
|
||||||
|
<rect x="146" y="266" width="100" height="100" rx="22"/>
|
||||||
|
<rect x="266" y="266" width="100" height="100" rx="22"/>
|
||||||
|
</g>
|
||||||
|
</svg>`;
|
||||||
|
|
||||||
|
const DARK = '#0a0a0a';
|
||||||
|
const LIGHT = '#fafafa';
|
||||||
|
|
||||||
|
const targets = [
|
||||||
|
{ file: 'icon-192.png', size: 192, svg: mark(DARK, LIGHT) },
|
||||||
|
{ file: 'icon-512.png', size: 512, svg: mark(DARK, LIGHT) },
|
||||||
|
{ file: 'icon-maskable-512.png', size: 512, svg: maskable(DARK, LIGHT) },
|
||||||
|
{ file: 'apple-touch-icon.png', size: 180, svg: mark(DARK, LIGHT) },
|
||||||
|
];
|
||||||
|
|
||||||
|
fs.mkdirSync(OUT_DIR, { recursive: true });
|
||||||
|
|
||||||
|
for (const target of targets) {
|
||||||
|
await sharp(Buffer.from(target.svg))
|
||||||
|
.resize(target.size, target.size)
|
||||||
|
.png({ compressionLevel: 9 })
|
||||||
|
.toFile(path.join(OUT_DIR, target.file));
|
||||||
|
console.log(`[icons] ${target.file} (${target.size}px)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The favicon keeps the same mark so the browser tab matches the home screen.
|
||||||
|
await sharp(Buffer.from(mark(DARK, LIGHT)))
|
||||||
|
.resize(32, 32)
|
||||||
|
.png({ compressionLevel: 9 })
|
||||||
|
.toFile(path.resolve(process.cwd(), 'public/favicon.png'));
|
||||||
|
console.log('[icons] favicon.png (32px)');
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
/**
|
|
||||||
* Wipes the Playwright data directory before the smoke run.
|
|
||||||
*
|
|
||||||
* Runs before Playwright starts, because Playwright boots its web server
|
|
||||||
* before global setup - on Windows the open SQLite file would then block the
|
|
||||||
* delete.
|
|
||||||
*/
|
|
||||||
import fs from 'node:fs';
|
|
||||||
import path from 'node:path';
|
|
||||||
|
|
||||||
const dataDir = path.resolve(process.cwd(), 'data-e2e');
|
|
||||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
|
||||||
console.log(`[e2e] ${dataDir} zurueckgesetzt.`);
|
|
||||||
@@ -16,6 +16,13 @@ fs.mkdirSync(path.join(dataDir, 'db'), { recursive: true });
|
|||||||
fs.mkdirSync(path.join(dataDir, 'files'), { recursive: true });
|
fs.mkdirSync(path.join(dataDir, 'files'), { recursive: true });
|
||||||
console.log(`[screenshots] ${dataDir} zurueckgesetzt.`);
|
console.log(`[screenshots] ${dataDir} zurueckgesetzt.`);
|
||||||
|
|
||||||
|
// A container left behind by an aborted run would hold the port.
|
||||||
|
try {
|
||||||
|
execFileSync('docker', ['rm', '-f', 'bricktrack-shots'], { stdio: 'ignore' });
|
||||||
|
} catch {
|
||||||
|
// Nothing to remove - the normal case.
|
||||||
|
}
|
||||||
|
|
||||||
console.log('[screenshots] Image wird gebaut (bricktrack:shots) …');
|
console.log('[screenshots] Image wird gebaut (bricktrack:shots) …');
|
||||||
execFileSync('docker', ['build', '-t', 'bricktrack:shots', '.'], { stdio: 'inherit' });
|
execFileSync('docker', ['build', '-t', 'bricktrack:shots', '.'], { stdio: 'inherit' });
|
||||||
console.log('[screenshots] Image ist bereit.');
|
console.log('[screenshots] Image ist bereit.');
|
||||||
|
|||||||
@@ -33,11 +33,12 @@ type SeedSet = {
|
|||||||
minifigCount: number | null;
|
minifigCount: number | null;
|
||||||
retailPriceCents: number;
|
retailPriceCents: number;
|
||||||
ageRange: string;
|
ageRange: string;
|
||||||
/** Copies owned - 0 means the set is only in the catalog. */
|
/** Copies owned - absent means the set is only in the catalog. */
|
||||||
owned?: {
|
owned?: {
|
||||||
status: 'owned' | 'built' | 'disassembled' | 'lent_out' | 'sold';
|
status: 'owned' | 'built' | 'disassembled' | 'lent_out' | 'sold';
|
||||||
completeness: 'complete' | 'missing_parts' | 'unknown';
|
completeness: 'complete' | 'missing_parts' | 'unknown';
|
||||||
location: string;
|
location: string;
|
||||||
|
collector: string;
|
||||||
giftedBy?: string;
|
giftedBy?: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -59,6 +60,13 @@ const THEMES: Array<{ manufacturer: string; name: string }> = [
|
|||||||
|
|
||||||
const LOCATIONS = ['Regal Kinderzimmer', 'Kiste Keller 3', 'Vitrine Wohnzimmer'];
|
const LOCATIONS = ['Regal Kinderzimmer', 'Kiste Keller 3', 'Vitrine Wohnzimmer'];
|
||||||
|
|
||||||
|
// Two collectors, so the shared catalog and the separate collections are
|
||||||
|
// visible right away. With only one, the whole concept stays hidden in the UI.
|
||||||
|
const COLLECTORS = [
|
||||||
|
{ name: 'Max', color: 'blue' as const },
|
||||||
|
{ name: 'Mia', color: 'rose' as const },
|
||||||
|
];
|
||||||
|
|
||||||
const SETS: SeedSet[] = [
|
const SETS: SeedSet[] = [
|
||||||
{
|
{
|
||||||
manufacturer: 'Lego',
|
manufacturer: 'Lego',
|
||||||
@@ -70,7 +78,12 @@ const SETS: SeedSet[] = [
|
|||||||
minifigCount: 4,
|
minifigCount: 4,
|
||||||
retailPriceCents: 4999,
|
retailPriceCents: 4999,
|
||||||
ageRange: '6-12',
|
ageRange: '6-12',
|
||||||
owned: { status: 'built', completeness: 'complete', location: 'Regal Kinderzimmer' },
|
owned: {
|
||||||
|
status: 'built',
|
||||||
|
completeness: 'complete',
|
||||||
|
location: 'Regal Kinderzimmer',
|
||||||
|
collector: 'Max',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
manufacturer: 'Lego',
|
manufacturer: 'Lego',
|
||||||
@@ -86,6 +99,7 @@ const SETS: SeedSet[] = [
|
|||||||
status: 'owned',
|
status: 'owned',
|
||||||
completeness: 'missing_parts',
|
completeness: 'missing_parts',
|
||||||
location: 'Kiste Keller 3',
|
location: 'Kiste Keller 3',
|
||||||
|
collector: 'Max',
|
||||||
giftedBy: 'Oma Helga',
|
giftedBy: 'Oma Helga',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -99,7 +113,12 @@ const SETS: SeedSet[] = [
|
|||||||
minifigCount: null,
|
minifigCount: null,
|
||||||
retailPriceCents: 999,
|
retailPriceCents: 999,
|
||||||
ageRange: '7-14',
|
ageRange: '7-14',
|
||||||
owned: { status: 'disassembled', completeness: 'unknown', location: 'Kiste Keller 3' },
|
owned: {
|
||||||
|
status: 'disassembled',
|
||||||
|
completeness: 'unknown',
|
||||||
|
location: 'Kiste Keller 3',
|
||||||
|
collector: 'Mia',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
manufacturer: 'Lego',
|
manufacturer: 'Lego',
|
||||||
@@ -144,7 +163,12 @@ const SETS: SeedSet[] = [
|
|||||||
minifigCount: 5,
|
minifigCount: 5,
|
||||||
retailPriceCents: 12999,
|
retailPriceCents: 12999,
|
||||||
ageRange: '4-10',
|
ageRange: '4-10',
|
||||||
owned: { status: 'built', completeness: 'complete', location: 'Regal Kinderzimmer' },
|
owned: {
|
||||||
|
status: 'built',
|
||||||
|
completeness: 'complete',
|
||||||
|
location: 'Regal Kinderzimmer',
|
||||||
|
collector: 'Mia',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
manufacturer: 'Playmobil',
|
manufacturer: 'Playmobil',
|
||||||
@@ -241,6 +265,59 @@ function seedThemes(manufacturers: Map<string, string>) {
|
|||||||
return byKey;
|
return byKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function seedCollectors() {
|
||||||
|
const byName = new Map<string, string>();
|
||||||
|
|
||||||
|
COLLECTORS.forEach((entry, index) => {
|
||||||
|
const all = db
|
||||||
|
.select({ id: schema.collector.id, name: schema.collector.name })
|
||||||
|
.from(schema.collector)
|
||||||
|
.all();
|
||||||
|
|
||||||
|
const existing = all.find((row) => row.name === entry.name);
|
||||||
|
if (existing) {
|
||||||
|
byName.set(entry.name, existing.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A fresh install already has the collector the migration created. Rather
|
||||||
|
// than adding a third one next to it, the seed takes it over.
|
||||||
|
const untouchedDefault = all.find(
|
||||||
|
(row) =>
|
||||||
|
row.name === 'Sammlung' &&
|
||||||
|
!db
|
||||||
|
.select({ id: schema.collectionItem.id })
|
||||||
|
.from(schema.collectionItem)
|
||||||
|
.where(eq(schema.collectionItem.collectorId, row.id))
|
||||||
|
.get(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (untouchedDefault) {
|
||||||
|
db.update(schema.collector)
|
||||||
|
.set({ name: entry.name, color: entry.color, sortOrder: index, updatedAt: now })
|
||||||
|
.where(eq(schema.collector.id, untouchedDefault.id))
|
||||||
|
.run();
|
||||||
|
byName.set(entry.name, untouchedDefault.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newId = id();
|
||||||
|
db.insert(schema.collector)
|
||||||
|
.values({
|
||||||
|
id: newId,
|
||||||
|
name: entry.name,
|
||||||
|
color: entry.color,
|
||||||
|
sortOrder: index,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
byName.set(entry.name, newId);
|
||||||
|
});
|
||||||
|
|
||||||
|
return byName;
|
||||||
|
}
|
||||||
|
|
||||||
function seedLocations() {
|
function seedLocations() {
|
||||||
const byName = new Map<string, string>();
|
const byName = new Map<string, string>();
|
||||||
|
|
||||||
@@ -270,6 +347,7 @@ function seedSets(
|
|||||||
manufacturers: Map<string, string>,
|
manufacturers: Map<string, string>,
|
||||||
themes: Map<string, string>,
|
themes: Map<string, string>,
|
||||||
locations: Map<string, string>,
|
locations: Map<string, string>,
|
||||||
|
collectors: Map<string, string>,
|
||||||
) {
|
) {
|
||||||
const setIds = new Map<string, string>();
|
const setIds = new Map<string, string>();
|
||||||
|
|
||||||
@@ -317,6 +395,7 @@ function seedSets(
|
|||||||
completeness: entry.owned.completeness,
|
completeness: entry.owned.completeness,
|
||||||
condition: 'good',
|
condition: 'good',
|
||||||
locationId: locations.get(entry.owned.location) ?? null,
|
locationId: locations.get(entry.owned.location) ?? null,
|
||||||
|
collectorId: collectors.get(entry.owned.collector) ?? null,
|
||||||
acquiredAt: new Date(entry.year, 11, 24),
|
acquiredAt: new Date(entry.year, 11, 24),
|
||||||
acquiredPriceCents: entry.retailPriceCents,
|
acquiredPriceCents: entry.retailPriceCents,
|
||||||
giftedBy: entry.owned.giftedBy ?? null,
|
giftedBy: entry.owned.giftedBy ?? null,
|
||||||
@@ -330,7 +409,7 @@ function seedSets(
|
|||||||
return setIds;
|
return setIds;
|
||||||
}
|
}
|
||||||
|
|
||||||
function seedWishlist(setIds: Map<string, string>) {
|
function seedWishlist(setIds: Map<string, string>, collectors: Map<string, string>) {
|
||||||
const owner = db.select({ id: schema.user.id }).from(schema.user).get();
|
const owner = db.select({ id: schema.user.id }).from(schema.user).get();
|
||||||
if (!owner) {
|
if (!owner) {
|
||||||
console.log('[seed] Kein Nutzer vorhanden - die Beispiel-Wunschliste wird uebersprungen.');
|
console.log('[seed] Kein Nutzer vorhanden - die Beispiel-Wunschliste wird uebersprungen.');
|
||||||
@@ -353,6 +432,7 @@ function seedWishlist(setIds: Map<string, string>) {
|
|||||||
.values({
|
.values({
|
||||||
id: wishlistId,
|
id: wishlistId,
|
||||||
ownerUserId: owner.id,
|
ownerUserId: owner.id,
|
||||||
|
collectorId: collectors.get('Max') ?? null,
|
||||||
title: 'Weihnachten 2026',
|
title: 'Weihnachten 2026',
|
||||||
occasion: 'christmas',
|
occasion: 'christmas',
|
||||||
eventDate: new Date(2026, 11, 24),
|
eventDate: new Date(2026, 11, 24),
|
||||||
@@ -389,10 +469,12 @@ console.log('[seed] Beispieldaten werden eingespielt …');
|
|||||||
const manufacturers = seedManufacturers();
|
const manufacturers = seedManufacturers();
|
||||||
const themes = seedThemes(manufacturers);
|
const themes = seedThemes(manufacturers);
|
||||||
const locations = seedLocations();
|
const locations = seedLocations();
|
||||||
const setIds = seedSets(manufacturers, themes, locations);
|
const collectors = seedCollectors();
|
||||||
seedWishlist(setIds);
|
const setIds = seedSets(manufacturers, themes, locations, collectors);
|
||||||
|
seedWishlist(setIds, collectors);
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`[seed] Fertig: ${manufacturers.size} Hersteller, ${themes.size} Themen, ${setIds.size} Sets.`,
|
`[seed] Fertig: ${collectors.size} Sammler, ${manufacturers.size} Hersteller, ` +
|
||||||
|
`${themes.size} Themen, ${setIds.size} Sets.`,
|
||||||
);
|
);
|
||||||
sqlite.close();
|
sqlite.close();
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
import { Check, ExternalLink, Gift, ImageOff, Pencil } from 'lucide-react';
|
import { Check, ExternalLink, Gift, Pencil } from 'lucide-react';
|
||||||
|
import { SetPlaceholder } from '@/components/brick/set-placeholder';
|
||||||
|
import { Studs } from '@/components/brick/studs';
|
||||||
import { InstructionsSection } from '@/components/katalog/instructions-section';
|
import { InstructionsSection } from '@/components/katalog/instructions-section';
|
||||||
import { PhotosSection } from '@/components/katalog/photos-section';
|
import { PhotosSection } from '@/components/katalog/photos-section';
|
||||||
|
import { PriceSection } from '@/components/katalog/price-section';
|
||||||
import { SetActions } from '@/components/katalog/set-actions';
|
import { SetActions } from '@/components/katalog/set-actions';
|
||||||
import { CollectionItemsSection } from '@/components/sammlung/collection-items-section';
|
import { CollectionItemsSection } from '@/components/sammlung/collection-items-section';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@@ -17,24 +20,37 @@ import {
|
|||||||
listCollectionItemsForSet,
|
listCollectionItemsForSet,
|
||||||
listInstructionsForSet,
|
listInstructionsForSet,
|
||||||
listPhotosForSet,
|
listPhotosForSet,
|
||||||
|
listPricePoints,
|
||||||
} from '@/lib/queries/catalog';
|
} from '@/lib/queries/catalog';
|
||||||
|
import { listMissingParts } from '@/lib/queries/collection';
|
||||||
|
import { listCollectors, resolveCollectorId } from '@/lib/queries/collectors';
|
||||||
import { listLocations } from '@/lib/queries/master-data';
|
import { listLocations } from '@/lib/queries/master-data';
|
||||||
import { listWishlistsForSet, listWishlistTargets } from '@/lib/queries/wishlists';
|
import { listWishlistsForSet, listWishlistTargets } from '@/lib/queries/wishlists';
|
||||||
import { requireUser } from '@/lib/session';
|
import { requireUser } from '@/lib/session';
|
||||||
|
|
||||||
type Props = { params: Promise<{ id: string }> };
|
type Props = {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||||
|
};
|
||||||
|
|
||||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
export async function generateMetadata({ params }: Pick<Props, 'params'>): Promise<Metadata> {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const set = getSetById(id);
|
const set = getSetById(id);
|
||||||
return { title: set ? `${set.setNumber} · ${set.name}` : 'Set' };
|
return { title: set ? `${set.setNumber} · ${set.name}` : 'Set' };
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function SetDetailPage({ params }: Props) {
|
export default async function SetDetailPage({ params, searchParams }: Props) {
|
||||||
await requireUser();
|
await requireUser();
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
const query = await searchParams;
|
||||||
|
|
||||||
const set = getSetById(id);
|
// Keeps the "vorhanden / fehlt" badge in step with the collector the user
|
||||||
|
// was looking at in the catalog.
|
||||||
|
const rawCollector = Array.isArray(query.sammler) ? query.sammler[0] : query.sammler;
|
||||||
|
const collectorId = resolveCollectorId(rawCollector);
|
||||||
|
const collectors = listCollectors();
|
||||||
|
|
||||||
|
const set = getSetById(id, collectorId);
|
||||||
if (!set) notFound();
|
if (!set) notFound();
|
||||||
|
|
||||||
const image = setImageSrc(set);
|
const image = setImageSrc(set);
|
||||||
@@ -42,9 +58,16 @@ export default async function SetDetailPage({ params }: Props) {
|
|||||||
const locations = listLocations().map((row) => ({ id: row.id, name: row.name }));
|
const locations = listLocations().map((row) => ({ id: row.id, name: row.name }));
|
||||||
const locationNames = new Map(locations.map((row) => [row.id, row.name]));
|
const locationNames = new Map(locations.map((row) => [row.id, row.name]));
|
||||||
|
|
||||||
|
const collectorsById = new Map(collectors.map((row) => [row.id, row]));
|
||||||
const collectionItems = listCollectionItemsForSet(set.id).map((item) => ({
|
const collectionItems = listCollectionItemsForSet(set.id).map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
locationName: item.locationId ? (locationNames.get(item.locationId) ?? null) : null,
|
locationName: item.locationId ? (locationNames.get(item.locationId) ?? null) : null,
|
||||||
|
setName: `${set.setNumber} · ${set.name}`,
|
||||||
|
missingParts: listMissingParts(item.id),
|
||||||
|
collectorName: item.collectorId ? (collectorsById.get(item.collectorId)?.name ?? null) : null,
|
||||||
|
collectorColor: item.collectorId
|
||||||
|
? (collectorsById.get(item.collectorId)?.color ?? null)
|
||||||
|
: null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const facts: Array<[string, string]> = [
|
const facts: Array<[string, string]> = [
|
||||||
@@ -93,15 +116,21 @@ export default async function SetDetailPage({ params }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-6 md:grid-cols-[minmax(0,320px)_1fr]">
|
<div className="grid gap-6 md:grid-cols-[minmax(0,320px)_1fr]">
|
||||||
<div className="overflow-hidden rounded-xl border border-border bg-muted">
|
<div className="relative self-start">
|
||||||
{image ? (
|
<Studs count={4} />
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
<div className="brick overflow-hidden rounded-xl bg-muted">
|
||||||
<img src={image} alt="" className="aspect-square w-full object-contain p-3" />
|
{image ? (
|
||||||
) : (
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
<div className="flex aspect-square items-center justify-center text-muted-foreground">
|
<img src={image} alt="" className="aspect-square w-full object-contain p-3" />
|
||||||
<ImageOff className="size-10" aria-hidden />
|
) : (
|
||||||
</div>
|
<SetPlaceholder
|
||||||
)}
|
setNumber={set.setNumber}
|
||||||
|
manufacturerName={set.manufacturerName}
|
||||||
|
size="lg"
|
||||||
|
className="aspect-square"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -110,6 +139,7 @@ export default async function SetDetailPage({ params }: Props) {
|
|||||||
setName={`${set.setNumber} · ${set.name}`}
|
setName={`${set.setNumber} · ${set.name}`}
|
||||||
owned={owned}
|
owned={owned}
|
||||||
wishlists={listWishlistTargets()}
|
wishlists={listWishlistTargets()}
|
||||||
|
collectors={collectors.map((row) => ({ id: row.id, name: row.name }))}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<dl className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm sm:grid-cols-3">
|
<dl className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm sm:grid-cols-3">
|
||||||
@@ -141,7 +171,24 @@ export default async function SetDetailPage({ params }: Props) {
|
|||||||
<CardTitle>In der Sammlung</CardTitle>
|
<CardTitle>In der Sammlung</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<CollectionItemsSection items={collectionItems} locations={locations} />
|
<CollectionItemsSection
|
||||||
|
items={collectionItems}
|
||||||
|
locations={locations}
|
||||||
|
collectors={collectors.map((row) => ({ id: row.id, name: row.name }))}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Preis und Wertentwicklung</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<PriceSection
|
||||||
|
setId={set.id}
|
||||||
|
prices={listPricePoints(set.id)}
|
||||||
|
retailPriceCents={set.retailPriceCents}
|
||||||
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -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,237 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { revalidatePath } from 'next/cache';
|
||||||
|
import { and, eq } from 'drizzle-orm';
|
||||||
|
import { db } from '@/db';
|
||||||
|
import { instruction, manufacturer, set, theme } from '@/db/schema';
|
||||||
|
import { actionData, actionError, type ActionResult } from '@/lib/action-result';
|
||||||
|
import {
|
||||||
|
fetchImageBytes,
|
||||||
|
fetchMinifigCount,
|
||||||
|
fetchSet,
|
||||||
|
fetchTheme,
|
||||||
|
isRebrickableEnabled,
|
||||||
|
legoInstructionsUrl,
|
||||||
|
RebrickableError,
|
||||||
|
} from '@/lib/rebrickable';
|
||||||
|
import { requireUser } from '@/lib/session';
|
||||||
|
import { uniqueSlug } from '@/lib/slug';
|
||||||
|
import { storeImageBytes, UploadError } from '@/lib/uploads';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import of a Lego set from Rebrickable.
|
||||||
|
*
|
||||||
|
* Runs entirely on the server, creates the manufacturer and the theme if they
|
||||||
|
* are missing, and stores the picture locally so the catalog keeps working
|
||||||
|
* when Rebrickable changes it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const LEGO_SLUG = 'lego';
|
||||||
|
|
||||||
|
function now() {
|
||||||
|
return new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the Lego manufacturer, creating it on first import. */
|
||||||
|
function ensureLegoManufacturer(): string {
|
||||||
|
const existing = db
|
||||||
|
.select({ id: manufacturer.id })
|
||||||
|
.from(manufacturer)
|
||||||
|
.where(eq(manufacturer.slug, LEGO_SLUG))
|
||||||
|
.get();
|
||||||
|
if (existing) return existing.id;
|
||||||
|
|
||||||
|
const taken = db.select({ slug: manufacturer.slug }).from(manufacturer).all();
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
|
||||||
|
db.insert(manufacturer)
|
||||||
|
.values({
|
||||||
|
id,
|
||||||
|
name: 'Lego',
|
||||||
|
slug: uniqueSlug('Lego', taken.map((row) => row.slug)),
|
||||||
|
website: 'https://www.lego.com/de-de',
|
||||||
|
createdAt: now(),
|
||||||
|
updatedAt: now(),
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps a Rebrickable theme onto a local one, creating it (and its parent) when
|
||||||
|
* needed. Rebrickable nests themes deeply; one level of parent is enough to
|
||||||
|
* keep "Technic > Cars" readable without importing the whole tree.
|
||||||
|
*/
|
||||||
|
async function ensureTheme(manufacturerId: string, themeId: number | null): Promise<string | null> {
|
||||||
|
if (themeId === null) return null;
|
||||||
|
|
||||||
|
const remote = await fetchTheme(themeId);
|
||||||
|
if (!remote) return null;
|
||||||
|
|
||||||
|
const existing = db
|
||||||
|
.select({ id: theme.id })
|
||||||
|
.from(theme)
|
||||||
|
.where(and(eq(theme.manufacturerId, manufacturerId), eq(theme.name, remote.name)))
|
||||||
|
.get();
|
||||||
|
if (existing) return existing.id;
|
||||||
|
|
||||||
|
let parentThemeId: string | null = null;
|
||||||
|
if (remote.parentId !== null) {
|
||||||
|
const parent = await fetchTheme(remote.parentId);
|
||||||
|
if (parent) {
|
||||||
|
const existingParent = db
|
||||||
|
.select({ id: theme.id })
|
||||||
|
.from(theme)
|
||||||
|
.where(and(eq(theme.manufacturerId, manufacturerId), eq(theme.name, parent.name)))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (existingParent) {
|
||||||
|
parentThemeId = existingParent.id;
|
||||||
|
} else {
|
||||||
|
parentThemeId = crypto.randomUUID();
|
||||||
|
db.insert(theme)
|
||||||
|
.values({
|
||||||
|
id: parentThemeId,
|
||||||
|
manufacturerId,
|
||||||
|
name: parent.name,
|
||||||
|
createdAt: now(),
|
||||||
|
updatedAt: now(),
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
db.insert(theme)
|
||||||
|
.values({
|
||||||
|
id,
|
||||||
|
manufacturerId,
|
||||||
|
name: remote.name,
|
||||||
|
parentThemeId,
|
||||||
|
createdAt: now(),
|
||||||
|
updatedAt: now(),
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ImportResult = {
|
||||||
|
setId: string;
|
||||||
|
setNumber: string;
|
||||||
|
name: string;
|
||||||
|
imported: {
|
||||||
|
image: boolean;
|
||||||
|
theme: boolean;
|
||||||
|
minifigs: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function importSetFromRebrickable(
|
||||||
|
rawSetNumber: unknown,
|
||||||
|
): Promise<ActionResult<ImportResult>> {
|
||||||
|
await requireUser();
|
||||||
|
|
||||||
|
if (!isRebrickableEnabled()) {
|
||||||
|
return actionError(
|
||||||
|
'Der Import ist nicht eingerichtet. Trag REBRICKABLE_API_KEY in der .env ein und starte den Container neu.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const setNumber = typeof rawSetNumber === 'string' ? rawSetNumber.trim() : '';
|
||||||
|
if (!setNumber) return actionError('Bitte gib eine Setnummer ein.');
|
||||||
|
if (setNumber.length > 40) return actionError('Das sieht nicht nach einer Setnummer aus.');
|
||||||
|
|
||||||
|
let remote;
|
||||||
|
try {
|
||||||
|
remote = await fetchSet(setNumber);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof RebrickableError) return actionError(error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!remote) {
|
||||||
|
return actionError(
|
||||||
|
`Rebrickable kennt die Setnummer ${setNumber} nicht. Du kannst das Set von Hand anlegen.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const manufacturerId = ensureLegoManufacturer();
|
||||||
|
|
||||||
|
const duplicate = db
|
||||||
|
.select({ id: set.id })
|
||||||
|
.from(set)
|
||||||
|
.where(and(eq(set.manufacturerId, manufacturerId), eq(set.setNumber, remote.setNumber)))
|
||||||
|
.get();
|
||||||
|
if (duplicate) {
|
||||||
|
return actionError(`${remote.setNumber} ist schon im Katalog: ${remote.name}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const themeId = await ensureTheme(manufacturerId, remote.themeId);
|
||||||
|
const minifigCount = await fetchMinifigCount(remote.setNumber);
|
||||||
|
|
||||||
|
const setId = crypto.randomUUID();
|
||||||
|
|
||||||
|
// Cache the picture locally; a failed download must not fail the import.
|
||||||
|
let imagePath: string | null = null;
|
||||||
|
if (remote.imageUrl) {
|
||||||
|
const bytes = await fetchImageBytes(remote.imageUrl);
|
||||||
|
if (bytes) {
|
||||||
|
try {
|
||||||
|
imagePath = (await storeImageBytes(bytes, 'set-image', setId)).filePath;
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof UploadError)) throw error;
|
||||||
|
console.error('[import] Bild konnte nicht gespeichert werden', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
db.insert(set)
|
||||||
|
.values({
|
||||||
|
id: setId,
|
||||||
|
manufacturerId,
|
||||||
|
themeId,
|
||||||
|
setNumber: remote.setNumber,
|
||||||
|
name: remote.name,
|
||||||
|
year: remote.year,
|
||||||
|
pieceCount: remote.pieceCount,
|
||||||
|
minifigCount,
|
||||||
|
imagePath,
|
||||||
|
// Keep the source URL as a fallback if the local file is ever lost.
|
||||||
|
imageUrl: imagePath ? null : remote.imageUrl,
|
||||||
|
externalIds: { rebrickable: `${remote.setNumber}-1` },
|
||||||
|
createdAt: now(),
|
||||||
|
updatedAt: now(),
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
|
||||||
|
// Rebrickable does not serve instruction files, so this points at Lego's
|
||||||
|
// own service page for the set number.
|
||||||
|
db.insert(instruction)
|
||||||
|
.values({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
setId,
|
||||||
|
kind: 'external_url',
|
||||||
|
url: legoInstructionsUrl(remote.setNumber),
|
||||||
|
label: 'Bauanleitung bei LEGO',
|
||||||
|
sortOrder: 0,
|
||||||
|
createdAt: now(),
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
|
||||||
|
revalidatePath('/katalog');
|
||||||
|
revalidatePath('/verwaltung/themen');
|
||||||
|
|
||||||
|
return actionData({
|
||||||
|
setId,
|
||||||
|
setNumber: remote.setNumber,
|
||||||
|
name: remote.name,
|
||||||
|
imported: {
|
||||||
|
image: imagePath !== null,
|
||||||
|
theme: themeId !== null,
|
||||||
|
minifigs: minifigCount !== null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { RebrickableImport } from '@/components/katalog/rebrickable-import';
|
||||||
import { SetForm } from '@/components/katalog/set-form';
|
import { SetForm } from '@/components/katalog/set-form';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { listManufacturers, listThemes } from '@/lib/queries/master-data';
|
import { listManufacturers, listThemes } from '@/lib/queries/master-data';
|
||||||
|
import { isRebrickableEnabled } from '@/lib/rebrickable';
|
||||||
import { requireUser } from '@/lib/session';
|
import { requireUser } from '@/lib/session';
|
||||||
|
|
||||||
export const metadata: Metadata = { title: 'Neues Set' };
|
export const metadata: Metadata = { title: 'Neues Set' };
|
||||||
@@ -21,12 +23,19 @@ export default async function NeuesSetPage({ searchParams }: { searchParams: Sea
|
|||||||
manufacturerId: row.manufacturerId,
|
manufacturerId: row.manufacturerId,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const importEnabled = isRebrickableEnabled();
|
||||||
|
|
||||||
if (manufacturers.length === 0) {
|
if (manufacturers.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-6">
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Neues Set</h1>
|
<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">
|
<p className="rounded-lg border border-dashed border-border p-6 text-sm text-muted-foreground">
|
||||||
Jedes Set gehört zu einem Hersteller. Leg zuerst einen an – Lego, Playmobil, Cobi oder
|
Für ein Set von Hand brauchst du zuerst einen Hersteller – Lego, Playmobil, Cobi oder
|
||||||
was auch immer im Kinderzimmer steht.
|
was auch immer im Kinderzimmer steht.
|
||||||
</p>
|
</p>
|
||||||
<Button asChild size="lg">
|
<Button asChild size="lg">
|
||||||
@@ -45,6 +54,14 @@ export default async function NeuesSetPage({ searchParams }: { searchParams: Sea
|
|||||||
Sammlung hinzufügen“ fest.
|
Sammlung hinzufügen“ fest.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<RebrickableImport enabled={importEnabled} />
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||||
|
<span className="h-px flex-1 bg-border" />
|
||||||
|
oder von Hand
|
||||||
|
<span className="h-px flex-1 bg-border" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<SetForm manufacturers={manufacturers} themes={themes} defaultEan={ean} />
|
<SetForm manufacturers={manufacturers} themes={themes} defaultEan={ean} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Plus } from 'lucide-react';
|
import { Plus } from 'lucide-react';
|
||||||
|
import { CollectorFilter } from '@/components/layout/collector-filter';
|
||||||
|
import { BarcodeScannerButton } from '@/components/katalog/barcode-scanner';
|
||||||
import { CatalogFilters } from '@/components/katalog/catalog-filters';
|
import { CatalogFilters } from '@/components/katalog/catalog-filters';
|
||||||
import { CatalogGrid } from '@/components/katalog/catalog-grid';
|
import { CatalogGrid } from '@/components/katalog/catalog-grid';
|
||||||
import { Pagination } from '@/components/katalog/pagination';
|
import { Pagination } from '@/components/katalog/pagination';
|
||||||
@@ -8,6 +10,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { pluralize } from '@/lib/format';
|
import { pluralize } from '@/lib/format';
|
||||||
import { OWNERSHIP_FILTER_LABELS, parseOwnershipFilter } from '@/lib/ownership';
|
import { OWNERSHIP_FILTER_LABELS, parseOwnershipFilter } from '@/lib/ownership';
|
||||||
import { CATALOG_PAGE_SIZE, listSets, listSetYears } from '@/lib/queries/catalog';
|
import { CATALOG_PAGE_SIZE, listSets, listSetYears } from '@/lib/queries/catalog';
|
||||||
|
import { listCollectors, resolveCollectorId } from '@/lib/queries/collectors';
|
||||||
import { listManufacturers, listThemes } from '@/lib/queries/master-data';
|
import { listManufacturers, listThemes } from '@/lib/queries/master-data';
|
||||||
import { listWishlistTargets } from '@/lib/queries/wishlists';
|
import { listWishlistTargets } from '@/lib/queries/wishlists';
|
||||||
import { requireUser } from '@/lib/session';
|
import { requireUser } from '@/lib/session';
|
||||||
@@ -26,6 +29,9 @@ export default async function KatalogPage({ searchParams }: { searchParams: Sear
|
|||||||
const params = await searchParams;
|
const params = await searchParams;
|
||||||
|
|
||||||
const ownership = parseOwnershipFilter(single(params.besitz));
|
const ownership = parseOwnershipFilter(single(params.besitz));
|
||||||
|
const collectors = listCollectors();
|
||||||
|
const collectorId = resolveCollectorId(single(params.sammler));
|
||||||
|
const activeCollector = collectors.find((row) => row.id === collectorId) ?? null;
|
||||||
const yearParam = single(params.jahr);
|
const yearParam = single(params.jahr);
|
||||||
const page = Number(single(params.seite) ?? '1') || 1;
|
const page = Number(single(params.seite) ?? '1') || 1;
|
||||||
|
|
||||||
@@ -35,6 +41,7 @@ export default async function KatalogPage({ searchParams }: { searchParams: Sear
|
|||||||
themeId: single(params.thema),
|
themeId: single(params.thema),
|
||||||
year: yearParam ? Number(yearParam) : undefined,
|
year: yearParam ? Number(yearParam) : undefined,
|
||||||
ownership,
|
ownership,
|
||||||
|
collectorId,
|
||||||
page,
|
page,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -56,18 +63,28 @@ export default async function KatalogPage({ searchParams }: { searchParams: Sear
|
|||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{ownership === 'fehlt'
|
{ownership === 'fehlt'
|
||||||
? 'Sets, die noch nicht in der Sammlung sind.'
|
? activeCollector
|
||||||
|
? `Sets, die ${activeCollector.name} noch nicht hat.`
|
||||||
|
: 'Sets, die noch nicht in der Sammlung sind.'
|
||||||
: 'Alle bekannten Sets – unabhängig davon, ob sie schon da sind.'}
|
: 'Alle bekannten Sets – unabhängig davon, ob sie schon da sind.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button asChild size="lg" className="shrink-0">
|
<div className="flex shrink-0 gap-2">
|
||||||
<Link href="/katalog/neu">
|
<BarcodeScannerButton />
|
||||||
<Plus />
|
<Button asChild size="lg">
|
||||||
<span className="hidden sm:inline">Set anlegen</span>
|
<Link href="/katalog/neu">
|
||||||
</Link>
|
<Plus />
|
||||||
</Button>
|
<span className="hidden sm:inline">Set anlegen</span>
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<CollectorFilter
|
||||||
|
collectors={collectors.map((row) => ({ id: row.id, name: row.name, color: row.color }))}
|
||||||
|
allLabel="Alle zusammen"
|
||||||
|
/>
|
||||||
|
|
||||||
<CatalogFilters options={{ manufacturers, themes, years: listSetYears() }} />
|
<CatalogFilters options={{ manufacturers, themes, years: listSetYears() }} />
|
||||||
|
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
@@ -75,7 +92,7 @@ export default async function KatalogPage({ searchParams }: { searchParams: Sear
|
|||||||
{ownership !== 'alle' ? ` · ${OWNERSHIP_FILTER_LABELS[ownership]}` : ''}
|
{ownership !== 'alle' ? ` · ${OWNERSHIP_FILTER_LABELS[ownership]}` : ''}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<CatalogGrid sets={rows} wishlists={wishlists} />
|
<CatalogGrid sets={rows} wishlists={wishlists} collectorId={collectorId} />
|
||||||
|
|
||||||
<Pagination page={page} total={total} pageSize={CATALOG_PAGE_SIZE} />
|
<Pagination page={page} total={total} pageSize={CATALOG_PAGE_SIZE} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { actionData, actionError, type ActionResult } from '@/lib/action-result';
|
||||||
|
import { isValidEan } from '@/lib/ean';
|
||||||
|
import { findSetByEan } from '@/lib/queries/catalog';
|
||||||
|
import { requireUser } from '@/lib/session';
|
||||||
|
|
||||||
|
export type ScanLookup =
|
||||||
|
| { found: true; setId: string; setNumber: string; name: string }
|
||||||
|
| { found: false; ean: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a scanned barcode.
|
||||||
|
*
|
||||||
|
* Either the set is already in the catalog - then we jump straight to it - or
|
||||||
|
* it is not, and the caller opens the "new set" form with the number filled in.
|
||||||
|
*/
|
||||||
|
export async function lookupScannedEan(rawEan: unknown): Promise<ActionResult<ScanLookup>> {
|
||||||
|
await requireUser();
|
||||||
|
|
||||||
|
const ean = typeof rawEan === 'string' ? rawEan.replace(/\D/g, '') : '';
|
||||||
|
if (!isValidEan(ean)) {
|
||||||
|
return actionError('Der Strichcode wurde nicht sauber erkannt. Bitte noch einmal scannen.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = findSetByEan(ean);
|
||||||
|
if (match) {
|
||||||
|
return actionData({
|
||||||
|
found: true as const,
|
||||||
|
setId: match.id,
|
||||||
|
setNumber: match.setNumber,
|
||||||
|
name: match.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return actionData({ found: false as const, ean });
|
||||||
|
}
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { revalidatePath } from 'next/cache';
|
import { revalidatePath } from 'next/cache';
|
||||||
import { headers } from 'next/headers';
|
import { cookies, headers } from 'next/headers';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import { env } from '@/env';
|
||||||
import { auth } from '@/lib/auth';
|
import { auth } from '@/lib/auth';
|
||||||
|
import { DESIGN_COOKIE, DESIGNS } from '@/lib/design';
|
||||||
|
import { requireUser } from '@/lib/session';
|
||||||
|
|
||||||
const idSchema = z.object({ id: z.string().min(1) });
|
const idSchema = z.object({ id: z.string().min(1) });
|
||||||
const renameSchema = idSchema.extend({ name: z.string().trim().min(1).max(60) });
|
const renameSchema = idSchema.extend({ name: z.string().trim().min(1).max(60) });
|
||||||
|
const designSchema = z.object({ design: z.enum(DESIGNS) });
|
||||||
|
|
||||||
export type ActionResult = { ok: true } | { ok: false; error: string };
|
export type ActionResult = { ok: true } | { ok: false; error: string };
|
||||||
|
|
||||||
@@ -43,3 +47,26 @@ export async function renamePasskeyAction(input: unknown): Promise<ActionResult>
|
|||||||
revalidatePath('/konto');
|
revalidatePath('/konto');
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The design is a per-device choice: a cookie, not a column on the user. That
|
||||||
|
* way it also covers the login page and lets two children's tablets look
|
||||||
|
* different.
|
||||||
|
*/
|
||||||
|
export async function setDesignAction(input: unknown): Promise<ActionResult> {
|
||||||
|
await requireUser();
|
||||||
|
|
||||||
|
const parsed = designSchema.safeParse(input);
|
||||||
|
if (!parsed.success) return { ok: false, error: 'Unbekanntes Design.' };
|
||||||
|
|
||||||
|
(await cookies()).set(DESIGN_COOKIE, parsed.data.design, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: env.isProduction,
|
||||||
|
sameSite: 'lax',
|
||||||
|
path: '/',
|
||||||
|
maxAge: 60 * 60 * 24 * 365,
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath('/', 'layout');
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import { headers } from 'next/headers';
|
import { cookies, headers } from 'next/headers';
|
||||||
import { PasskeyManager, type PasskeyRow } from '@/components/auth/passkey-manager';
|
import { PasskeyManager, type PasskeyRow } from '@/components/auth/passkey-manager';
|
||||||
|
import { DesignPicker } from '@/components/konto/design-picker';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { auth } from '@/lib/auth';
|
import { auth } from '@/lib/auth';
|
||||||
|
import { DESIGN_COOKIE, resolveDesign } from '@/lib/design';
|
||||||
import { requireUser } from '@/lib/session';
|
import { requireUser } from '@/lib/session';
|
||||||
|
|
||||||
export const metadata: Metadata = { title: 'Konto' };
|
export const metadata: Metadata = { title: 'Konto' };
|
||||||
@@ -17,6 +19,7 @@ export default async function AccountPage() {
|
|||||||
name: passkey.name ?? null,
|
name: passkey.name ?? null,
|
||||||
createdAt: passkey.createdAt ? new Date(passkey.createdAt) : null,
|
createdAt: passkey.createdAt ? new Date(passkey.createdAt) : null,
|
||||||
}));
|
}));
|
||||||
|
const design = resolveDesign((await cookies()).get(DESIGN_COOKIE)?.value);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -45,6 +48,18 @@ export default async function AccountPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Design</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Gilt für dieses Gerät. Öffentliche Wunschlisten sehen Gäste im Standard-Design.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<DesignPicker current={design} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Passkeys</CardTitle>
|
<CardTitle>Passkeys</CardTitle>
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import { AppHeader } from '@/components/layout/app-header';
|
import { AppHeader } from '@/components/layout/app-header';
|
||||||
import { BottomNav } from '@/components/layout/bottom-nav';
|
import { BottomNav } from '@/components/layout/bottom-nav';
|
||||||
|
import { ensureReminderScheduler } from '@/lib/reminder-job';
|
||||||
import { requireUser } from '@/lib/session';
|
import { requireUser } from '@/lib/session';
|
||||||
|
|
||||||
export default async function AppLayout({ children }: { children: React.ReactNode }) {
|
export default async function AppLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
// Second entry point besides the healthcheck, in case someone turns that
|
||||||
|
// off. The call is idempotent.
|
||||||
|
ensureReminderScheduler();
|
||||||
|
|
||||||
const user = await requireUser();
|
const user = await requireUser();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,58 +1,186 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Boxes, Gift, LayoutGrid, PackageSearch } from 'lucide-react';
|
import { ChevronRight, Gift, PackageSearch, Plus } from 'lucide-react';
|
||||||
import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { cn } from 'cn';
|
||||||
|
import { Studs } from '@/components/brick/studs';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { brickRow } from '@/lib/brick-row';
|
||||||
|
import { collectorDotClass } from '@/lib/collector-colors';
|
||||||
|
import { formatDate, formatNumber, pluralize } from '@/lib/format';
|
||||||
|
import { formatEuro } from '@/lib/money';
|
||||||
|
import { countMissingSets, getCollectionStats } from '@/lib/queries/collection';
|
||||||
|
import { countItemsPerCollector, listCollectors } from '@/lib/queries/collectors';
|
||||||
|
import { listWishlists } from '@/lib/queries/wishlists';
|
||||||
|
import { daysUntil } from '@/lib/reminders';
|
||||||
import { requireUser } from '@/lib/session';
|
import { requireUser } from '@/lib/session';
|
||||||
|
|
||||||
const shortcuts = [
|
function daysLabel(days: number): string {
|
||||||
{
|
if (days === 0) return 'heute';
|
||||||
href: '/katalog',
|
if (days === 1) return 'morgen';
|
||||||
icon: LayoutGrid,
|
return `in ${days} Tagen`;
|
||||||
title: 'Katalog',
|
}
|
||||||
description: 'Alle bekannten Sets durchsuchen und neue anlegen.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/katalog?besitz=fehlt',
|
|
||||||
icon: PackageSearch,
|
|
||||||
title: 'Fehlende Sets',
|
|
||||||
description: 'Was noch nicht in der Sammlung ist – direkt auf die Wunschliste.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/sammlung',
|
|
||||||
icon: Boxes,
|
|
||||||
title: 'Sammlung',
|
|
||||||
description: 'Alles, was tatsächlich da ist – mit Standort und Zustand.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/wunschlisten',
|
|
||||||
icon: Gift,
|
|
||||||
title: 'Wunschlisten',
|
|
||||||
description: 'Listen anlegen und per Link mit Familie und Freunden teilen.',
|
|
||||||
},
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export default async function DashboardPage() {
|
export default async function DashboardPage() {
|
||||||
const user = await requireUser();
|
const user = await requireUser();
|
||||||
|
|
||||||
return (
|
const stats = getCollectionStats();
|
||||||
<div className="space-y-6">
|
const missing = countMissingSets();
|
||||||
<div>
|
const totalSets = stats.setCount + missing;
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Hallo {user.name}</h1>
|
|
||||||
<p className="text-sm text-muted-foreground">Was möchtest du tun?</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
const collectors = listCollectors();
|
||||||
{shortcuts.map((shortcut) => (
|
const perCollector = countItemsPerCollector();
|
||||||
<Link key={shortcut.href} href={shortcut.href} className="group">
|
const showCollectors = collectors.length > 1;
|
||||||
<Card className="h-full transition-colors group-hover:border-primary/40">
|
const bricks = brickRow(
|
||||||
<CardHeader>
|
stats.setCount,
|
||||||
<shortcut.icon className="size-6 text-primary" aria-hidden />
|
totalSets,
|
||||||
<CardTitle>{shortcut.title}</CardTitle>
|
showCollectors
|
||||||
<CardDescription>{shortcut.description}</CardDescription>
|
? collectors.map((entry) => ({ key: entry.id, count: perCollector.get(entry.id) ?? 0 }))
|
||||||
</CardHeader>
|
: [],
|
||||||
</Card>
|
);
|
||||||
|
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="mx-auto max-w-2xl space-y-7">
|
||||||
|
<h1 className="text-3xl font-semibold tracking-tight">Hallo {user.name}!</h1>
|
||||||
|
|
||||||
|
<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">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>
|
</Link>
|
||||||
))}
|
</Button>
|
||||||
</div>
|
<Button asChild variant="outline" size="lg">
|
||||||
|
<Link href="/wunschlisten">
|
||||||
|
<Gift aria-hidden />
|
||||||
|
Wunschlisten
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button asChild size="lg">
|
||||||
|
<Link href="/katalog/neu">
|
||||||
|
<Plus aria-hidden />
|
||||||
|
Set anlegen
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { revalidatePath } from 'next/cache';
|
import { revalidatePath } from 'next/cache';
|
||||||
import { eq } from 'drizzle-orm';
|
import { and, eq } from 'drizzle-orm';
|
||||||
import { db } from '@/db';
|
import { db } from '@/db';
|
||||||
import { collectionItem, set } from '@/db/schema';
|
import { collectionItem, missingPart, set } from '@/db/schema';
|
||||||
import {
|
import {
|
||||||
actionData,
|
actionData,
|
||||||
actionError,
|
actionError,
|
||||||
@@ -11,9 +11,10 @@ import {
|
|||||||
parseForm,
|
parseForm,
|
||||||
type ActionResult,
|
type ActionResult,
|
||||||
} from '@/lib/action-result';
|
} from '@/lib/action-result';
|
||||||
|
import { defaultCollectorId } from '@/lib/queries/collectors';
|
||||||
import { reconcileFulfilledWishlistItems } from '@/lib/queries/wishlists';
|
import { reconcileFulfilledWishlistItems } from '@/lib/queries/wishlists';
|
||||||
import { requireUser } from '@/lib/session';
|
import { requireUser } from '@/lib/session';
|
||||||
import { collectionItemSchema } from '@/lib/validation';
|
import { collectionItemSchema, missingPartSchema } from '@/lib/validation';
|
||||||
import type { Condition } from '@/db/types';
|
import type { Condition } from '@/db/types';
|
||||||
|
|
||||||
function now() {
|
function now() {
|
||||||
@@ -32,17 +33,24 @@ function revalidateCollection(setId?: string) {
|
|||||||
* Quick add from the set detail page: one tap, sensible defaults, details can
|
* Quick add from the set detail page: one tap, sensible defaults, details can
|
||||||
* be filled in later.
|
* be filled in later.
|
||||||
*/
|
*/
|
||||||
export async function addSetToCollection(setId: string): Promise<ActionResult<{ id: string }>> {
|
export async function addSetToCollection(
|
||||||
|
setId: string,
|
||||||
|
collectorId?: string,
|
||||||
|
): Promise<ActionResult<{ id: string }>> {
|
||||||
await requireUser();
|
await requireUser();
|
||||||
|
|
||||||
const exists = db.select({ id: set.id }).from(set).where(eq(set.id, setId)).get();
|
const exists = db.select({ id: set.id }).from(set).where(eq(set.id, setId)).get();
|
||||||
if (!exists) return actionError('Das Set gibt es nicht mehr.');
|
if (!exists) return actionError('Das Set gibt es nicht mehr.');
|
||||||
|
|
||||||
|
// With a single collector nobody is asked - the copy simply belongs to them.
|
||||||
|
const owner = collectorId ?? defaultCollectorId();
|
||||||
|
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
db.insert(collectionItem)
|
db.insert(collectionItem)
|
||||||
.values({
|
.values({
|
||||||
id,
|
id,
|
||||||
setId,
|
setId,
|
||||||
|
collectorId: owner,
|
||||||
status: 'owned',
|
status: 'owned',
|
||||||
completeness: 'unknown',
|
completeness: 'unknown',
|
||||||
createdAt: now(),
|
createdAt: now(),
|
||||||
@@ -68,6 +76,7 @@ export async function updateCollectionItem(id: string, input: unknown): Promise<
|
|||||||
|
|
||||||
db.update(collectionItem)
|
db.update(collectionItem)
|
||||||
.set({
|
.set({
|
||||||
|
collectorId: parsed.data.collectorId ?? existing.collectorId,
|
||||||
status: parsed.data.status,
|
status: parsed.data.status,
|
||||||
completeness: parsed.data.completeness,
|
completeness: parsed.data.completeness,
|
||||||
condition: parsed.data.condition as Condition | null,
|
condition: parsed.data.condition as Condition | null,
|
||||||
@@ -103,3 +112,123 @@ export async function deleteCollectionItem(id: string): Promise<ActionResult> {
|
|||||||
revalidateCollection(existing.setId);
|
revalidateCollection(existing.setId);
|
||||||
return actionOk();
|
return actionOk();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Missing parts
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps `completeness` honest without ever fighting the user.
|
||||||
|
*
|
||||||
|
* The moment a part is reported missing the item is not complete any more, and
|
||||||
|
* once the last open entry is resolved it is complete again. A deliberate
|
||||||
|
* "unknown" is left alone in the second direction only.
|
||||||
|
*/
|
||||||
|
function syncCompleteness(collectionItemId: string): void {
|
||||||
|
const open = db
|
||||||
|
.select({ id: missingPart.id })
|
||||||
|
.from(missingPart)
|
||||||
|
.where(and(eq(missingPart.collectionItemId, collectionItemId), eq(missingPart.resolved, false)))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
const item = db
|
||||||
|
.select({ completeness: collectionItem.completeness })
|
||||||
|
.from(collectionItem)
|
||||||
|
.where(eq(collectionItem.id, collectionItemId))
|
||||||
|
.get();
|
||||||
|
if (!item) return;
|
||||||
|
|
||||||
|
if (open.length > 0 && item.completeness !== 'missing_parts') {
|
||||||
|
db.update(collectionItem)
|
||||||
|
.set({ completeness: 'missing_parts', updatedAt: now() })
|
||||||
|
.where(eq(collectionItem.id, collectionItemId))
|
||||||
|
.run();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (open.length === 0 && item.completeness === 'missing_parts') {
|
||||||
|
db.update(collectionItem)
|
||||||
|
.set({ completeness: 'complete', updatedAt: now() })
|
||||||
|
.where(eq(collectionItem.id, collectionItemId))
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadItemSetId(collectionItemId: string): string | null {
|
||||||
|
const row = db
|
||||||
|
.select({ setId: collectionItem.setId })
|
||||||
|
.from(collectionItem)
|
||||||
|
.where(eq(collectionItem.id, collectionItemId))
|
||||||
|
.get();
|
||||||
|
return row?.setId ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addMissingPart(
|
||||||
|
collectionItemId: string,
|
||||||
|
input: unknown,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
await requireUser();
|
||||||
|
|
||||||
|
const parsed = parseForm(missingPartSchema, input);
|
||||||
|
if (!parsed.ok) return parsed;
|
||||||
|
|
||||||
|
const setId = loadItemSetId(collectionItemId);
|
||||||
|
if (!setId) return actionError('Diesen Sammlungseintrag gibt es nicht mehr.');
|
||||||
|
|
||||||
|
db.insert(missingPart)
|
||||||
|
.values({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
collectionItemId,
|
||||||
|
partNumber: parsed.data.partNumber,
|
||||||
|
colorName: parsed.data.colorName,
|
||||||
|
quantity: parsed.data.quantity,
|
||||||
|
resolved: false,
|
||||||
|
notes: parsed.data.notes,
|
||||||
|
createdAt: now(),
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
|
||||||
|
syncCompleteness(collectionItemId);
|
||||||
|
revalidateCollection(setId);
|
||||||
|
return actionOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setMissingPartResolved(
|
||||||
|
missingPartId: string,
|
||||||
|
resolved: boolean,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
await requireUser();
|
||||||
|
|
||||||
|
const row = db
|
||||||
|
.select({ collectionItemId: missingPart.collectionItemId })
|
||||||
|
.from(missingPart)
|
||||||
|
.where(eq(missingPart.id, missingPartId))
|
||||||
|
.get();
|
||||||
|
if (!row) return actionOk();
|
||||||
|
|
||||||
|
db.update(missingPart)
|
||||||
|
.set({ resolved })
|
||||||
|
.where(eq(missingPart.id, missingPartId))
|
||||||
|
.run();
|
||||||
|
|
||||||
|
syncCompleteness(row.collectionItemId);
|
||||||
|
revalidateCollection(loadItemSetId(row.collectionItemId) ?? undefined);
|
||||||
|
return actionOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteMissingPart(missingPartId: string): Promise<ActionResult> {
|
||||||
|
await requireUser();
|
||||||
|
|
||||||
|
const row = db
|
||||||
|
.select({ collectionItemId: missingPart.collectionItemId })
|
||||||
|
.from(missingPart)
|
||||||
|
.where(eq(missingPart.id, missingPartId))
|
||||||
|
.get();
|
||||||
|
if (!row) return actionOk();
|
||||||
|
|
||||||
|
db.delete(missingPart).where(eq(missingPart.id, missingPartId)).run();
|
||||||
|
|
||||||
|
syncCompleteness(row.collectionItemId);
|
||||||
|
revalidateCollection(loadItemSetId(row.collectionItemId) ?? undefined);
|
||||||
|
return actionOk();
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,19 +1,25 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Boxes, Euro, ImageOff, Layers, PackageSearch } from 'lucide-react';
|
import { Boxes, Euro, Layers, PackageSearch } from 'lucide-react';
|
||||||
|
import { SetPlaceholder } from '@/components/brick/set-placeholder';
|
||||||
|
import { Studs } from '@/components/brick/studs';
|
||||||
|
import { CollectorFilter } from '@/components/layout/collector-filter';
|
||||||
import { CollectionFilters } from '@/components/sammlung/collection-filters';
|
import { CollectionFilters } from '@/components/sammlung/collection-filters';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { formatDate, formatNumber, pluralize } from '@/lib/format';
|
import { formatDate, formatNumber, pluralize } from '@/lib/format';
|
||||||
|
import { collectorBadgeClass, collectorDotClass } from '@/lib/collector-colors';
|
||||||
import { setImageSrc } from '@/lib/images';
|
import { setImageSrc } from '@/lib/images';
|
||||||
import { completenessLabel, statusLabel } from '@/lib/labels';
|
import { completenessLabel, statusLabel } from '@/lib/labels';
|
||||||
import { formatEuro } from '@/lib/money';
|
import { formatEuro } from '@/lib/money';
|
||||||
import {
|
import {
|
||||||
countMissingSets,
|
countMissingSets,
|
||||||
|
countOpenMissingParts,
|
||||||
getCollectionStats,
|
getCollectionStats,
|
||||||
listCollectionItems,
|
listCollectionItems,
|
||||||
type CollectionFilters as Filters,
|
type CollectionFilters as Filters,
|
||||||
} from '@/lib/queries/collection';
|
} from '@/lib/queries/collection';
|
||||||
|
import { listCollectors, resolveCollectorId } from '@/lib/queries/collectors';
|
||||||
import { listLocations, listManufacturers } from '@/lib/queries/master-data';
|
import { listLocations, listManufacturers } from '@/lib/queries/master-data';
|
||||||
import { requireUser } from '@/lib/session';
|
import { requireUser } from '@/lib/session';
|
||||||
import type { CollectionStatus, Completeness } from '@/db/types';
|
import type { CollectionStatus, Completeness } from '@/db/types';
|
||||||
@@ -31,22 +37,33 @@ export default async function SammlungPage({ searchParams }: { searchParams: Sea
|
|||||||
await requireUser();
|
await requireUser();
|
||||||
const params = await searchParams;
|
const params = await searchParams;
|
||||||
|
|
||||||
|
const collectors = listCollectors();
|
||||||
|
const collectorId = resolveCollectorId(single(params.sammler));
|
||||||
|
const activeCollector = collectors.find((row) => row.id === collectorId) ?? null;
|
||||||
|
|
||||||
const filters: Filters = {
|
const filters: Filters = {
|
||||||
q: single(params.q),
|
q: single(params.q),
|
||||||
status: single(params.status) as CollectionStatus | undefined,
|
status: single(params.status) as CollectionStatus | undefined,
|
||||||
completeness: single(params.zustand) as Completeness | undefined,
|
completeness: single(params.zustand) as Completeness | undefined,
|
||||||
locationId: single(params.standort),
|
locationId: single(params.standort),
|
||||||
manufacturerId: single(params.hersteller),
|
manufacturerId: single(params.hersteller),
|
||||||
|
collectorId,
|
||||||
};
|
};
|
||||||
|
|
||||||
const items = listCollectionItems(filters);
|
const items = listCollectionItems(filters);
|
||||||
const stats = getCollectionStats();
|
const openParts = countOpenMissingParts(items.map((item) => item.id));
|
||||||
const missing = countMissingSets();
|
const stats = getCollectionStats(collectorId);
|
||||||
|
const missing = countMissingSets(collectorId);
|
||||||
|
|
||||||
const tiles = [
|
const tiles = [
|
||||||
{ icon: Boxes, label: 'Sets', value: formatNumber(stats.setCount) },
|
{ icon: Boxes, label: 'Sets', value: formatNumber(stats.setCount) },
|
||||||
{ icon: Layers, label: 'Teile gesamt', value: formatNumber(stats.pieceCount) },
|
{ icon: Layers, label: 'Teile gesamt', value: formatNumber(stats.pieceCount) },
|
||||||
{ icon: Euro, label: 'Wert nach UVP', value: formatEuro(stats.retailValueCents) },
|
{
|
||||||
|
icon: Euro,
|
||||||
|
// Notierte Preise gehen vor, sonst die UVP.
|
||||||
|
label: stats.currentValueCents === stats.retailValueCents ? 'Wert nach UVP' : 'Aktueller Wert',
|
||||||
|
value: formatEuro(stats.currentValueCents),
|
||||||
|
},
|
||||||
{ icon: PackageSearch, label: 'Fehlt noch', value: formatNumber(missing) },
|
{ icon: PackageSearch, label: 'Fehlt noch', value: formatNumber(missing) },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@@ -54,9 +71,18 @@ export default async function SammlungPage({ searchParams }: { searchParams: Sea
|
|||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Sammlung</h1>
|
<h1 className="text-2xl font-semibold tracking-tight">Sammlung</h1>
|
||||||
<p className="text-sm text-muted-foreground">Alles, was tatsächlich da ist.</p>
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{activeCollector
|
||||||
|
? `Alles, was ${activeCollector.name} tatsächlich hat.`
|
||||||
|
: 'Alles, was tatsächlich da ist.'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<CollectorFilter
|
||||||
|
collectors={collectors.map((row) => ({ id: row.id, name: row.name, color: row.color }))}
|
||||||
|
allLabel="Alle zusammen"
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
{tiles.map((tile) => (
|
{tiles.map((tile) => (
|
||||||
<Card key={tile.label}>
|
<Card key={tile.label}>
|
||||||
@@ -98,23 +124,31 @@ export default async function SammlungPage({ searchParams }: { searchParams: Sea
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ul className="divide-y divide-border rounded-lg border border-border">
|
<ul className="space-y-5 pt-2">
|
||||||
{items.map((item) => {
|
{items.map((item) => {
|
||||||
const image = setImageSrc(item);
|
const image = setImageSrc(item);
|
||||||
return (
|
return (
|
||||||
<li key={item.id}>
|
<li key={item.id} className="relative">
|
||||||
|
{/* The studs carry the collector's colour once there is more than one. */}
|
||||||
|
<Studs
|
||||||
|
studClassName={
|
||||||
|
collectors.length > 1 ? collectorDotClass(item.collectorColor) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Link
|
<Link
|
||||||
href={`/katalog/${item.setId}`}
|
href={`/katalog/${item.setId}`}
|
||||||
className="flex items-center gap-3 p-3 transition-colors hover:bg-muted/50"
|
className="brick flex items-center gap-3 rounded-xl bg-card p-3 transition-colors hover:bg-muted/50"
|
||||||
>
|
>
|
||||||
<div className="size-14 shrink-0 overflow-hidden rounded-lg border border-border bg-muted">
|
<div className="size-16 shrink-0 overflow-hidden rounded-lg border-[length:max(1px,var(--edge-w))] border-edge bg-muted">
|
||||||
{image ? (
|
{image ? (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
<img src={image} alt="" loading="lazy" className="size-full object-contain" />
|
<img src={image} alt="" loading="lazy" className="size-full object-contain" />
|
||||||
) : (
|
) : (
|
||||||
<div className="flex size-full items-center justify-center text-muted-foreground">
|
<SetPlaceholder
|
||||||
<ImageOff className="size-5" aria-hidden />
|
setNumber={item.setNumber}
|
||||||
</div>
|
manufacturerName={item.manufacturerName}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -126,12 +160,24 @@ export default async function SammlungPage({ searchParams }: { searchParams: Sea
|
|||||||
.join(' · ')}
|
.join(' · ')}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-1 flex flex-wrap items-center gap-1">
|
<div className="mt-1 flex flex-wrap items-center gap-1">
|
||||||
<Badge variant={item.status === 'sold' ? 'outline' : 'default'}>
|
{/* Green like "Vorhanden" in the catalog; red stays for actions. */}
|
||||||
|
<Badge
|
||||||
|
variant={item.status === 'sold' ? 'outline' : 'default'}
|
||||||
|
className={item.status === 'sold' ? undefined : 'bg-owned text-owned-foreground'}
|
||||||
|
>
|
||||||
{statusLabel(item.status)}
|
{statusLabel(item.status)}
|
||||||
</Badge>
|
</Badge>
|
||||||
{item.completeness !== 'unknown' ? (
|
{item.completeness !== 'unknown' ? (
|
||||||
<Badge variant="secondary">{completenessLabel(item.completeness)}</Badge>
|
<Badge variant="secondary">{completenessLabel(item.completeness)}</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
|
{collectors.length > 1 && item.collectorName ? (
|
||||||
|
<Badge className={collectorBadgeClass(item.collectorColor)}>
|
||||||
|
{item.collectorName}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
{openParts.get(item.id) ? (
|
||||||
|
<Badge variant="outline">{openParts.get(item.id)} Fehlteile</Badge>
|
||||||
|
) : null}
|
||||||
{item.acquiredAt ? (
|
{item.acquiredAt ? (
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
seit {formatDate(item.acquiredAt)}
|
seit {formatDate(item.acquiredAt)}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { revalidatePath } from 'next/cache';
|
||||||
|
import { actionData, actionError, type ActionResult } from '@/lib/action-result';
|
||||||
|
import { importArchive, ImportError, type ImportSummary } from '@/lib/export';
|
||||||
|
import { requireAdmin } from '@/lib/session';
|
||||||
|
|
||||||
|
const MAX_ARCHIVE_BYTES = 512 * 1024 * 1024;
|
||||||
|
|
||||||
|
/** Typed by hand before anything is deleted. */
|
||||||
|
const CONFIRMATION = 'ERSETZEN';
|
||||||
|
|
||||||
|
export async function importArchiveAction(
|
||||||
|
formData: FormData,
|
||||||
|
): Promise<ActionResult<ImportSummary>> {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
|
||||||
|
if (formData.get('confirmation') !== CONFIRMATION) {
|
||||||
|
return actionError(`Bitte tipp ${CONFIRMATION} ein, um den Austausch zu bestätigen.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = formData.get('archive');
|
||||||
|
if (!(file instanceof File) || file.size === 0) {
|
||||||
|
return actionError('Bitte wähle ein Bricktrack-Archiv (.zip) aus.');
|
||||||
|
}
|
||||||
|
if (file.size > MAX_ARCHIVE_BYTES) {
|
||||||
|
return actionError('Das Archiv ist größer als 512 MB und wird nicht eingelesen.');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const summary = importArchive(
|
||||||
|
new Uint8Array(await file.arrayBuffer()),
|
||||||
|
admin.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
revalidatePath('/', 'layout');
|
||||||
|
return actionData(summary);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ImportError) return actionError(error.message);
|
||||||
|
console.error('[import] Archiv konnte nicht eingelesen werden', error);
|
||||||
|
return actionError(
|
||||||
|
'Das Archiv konnte nicht eingelesen werden. Die vorhandenen Daten wurden nicht verändert.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
import { DataTransfer } from '@/components/verwaltung/data-transfer';
|
||||||
|
import { requireAdmin } from '@/lib/session';
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: 'Daten' };
|
||||||
|
|
||||||
|
export default async function DatenPage() {
|
||||||
|
await requireAdmin();
|
||||||
|
return <DataTransfer />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { revalidatePath } from 'next/cache';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { actionData, actionOk, parseForm, type ActionResult } from '@/lib/action-result';
|
||||||
|
import { createInvite, deleteInvite, INVITE_VALID_DAYS } from '@/lib/invites';
|
||||||
|
import { requireAdmin } from '@/lib/session';
|
||||||
|
import { optionalText } from '@/lib/validation';
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
// Optional: when set, the invitation only works for that address.
|
||||||
|
email: optionalText(160),
|
||||||
|
role: z.enum(['admin', 'member']).default('member'),
|
||||||
|
validDays: z.coerce.number().int().min(1).max(90).default(INVITE_VALID_DAYS),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function createInviteAction(
|
||||||
|
input: unknown,
|
||||||
|
): Promise<ActionResult<{ token: string }>> {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
|
||||||
|
const parsed = parseForm(schema, input);
|
||||||
|
if (!parsed.ok) return parsed;
|
||||||
|
|
||||||
|
const created = createInvite({
|
||||||
|
createdByUserId: admin.id,
|
||||||
|
email: parsed.data.email,
|
||||||
|
role: parsed.data.role,
|
||||||
|
validDays: parsed.data.validDays,
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath('/verwaltung/einladungen');
|
||||||
|
// The plaintext token is returned once and never stored - only its hash is.
|
||||||
|
return actionData({ token: created.token });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteInviteAction(id: string): Promise<ActionResult> {
|
||||||
|
await requireAdmin();
|
||||||
|
|
||||||
|
deleteInvite(id);
|
||||||
|
|
||||||
|
revalidatePath('/verwaltung/einladungen');
|
||||||
|
return actionOk();
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
import { InviteList, type InviteListRow } from '@/components/verwaltung/invite-list';
|
||||||
|
import { inviteState, listInvites } from '@/lib/invites';
|
||||||
|
import { requireAdmin } from '@/lib/session';
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: 'Einladungen' };
|
||||||
|
|
||||||
|
export default async function EinladungenPage() {
|
||||||
|
await requireAdmin();
|
||||||
|
|
||||||
|
const invites: InviteListRow[] = listInvites().map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
email: row.email,
|
||||||
|
role: row.role,
|
||||||
|
expiresAt: row.expiresAt,
|
||||||
|
usedAt: row.usedAt,
|
||||||
|
usedByName: row.usedByName,
|
||||||
|
state: inviteState(row),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return <InviteList invites={invites} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { revalidatePath } from 'next/cache';
|
||||||
|
import { actionData, actionError, type ActionResult } from '@/lib/action-result';
|
||||||
|
import { env } from '@/env';
|
||||||
|
import { sendMail, verifyMailConnection } from '@/lib/mailer';
|
||||||
|
import { runReminderJob, type ReminderRun } from '@/lib/reminder-job';
|
||||||
|
import { requireAdmin } from '@/lib/session';
|
||||||
|
|
||||||
|
export async function testMailConnection(): Promise<ActionResult> {
|
||||||
|
await requireAdmin();
|
||||||
|
|
||||||
|
const result = await verifyMailConnection();
|
||||||
|
return result.ok ? { ok: true } : actionError(result.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendTestMail(): Promise<ActionResult> {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
|
||||||
|
if (!env.isMailConfigured) {
|
||||||
|
return actionError('Es ist kein SMTP-Server konfiguriert.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await sendMail({
|
||||||
|
to: admin.email,
|
||||||
|
subject: 'Bricktrack: Testnachricht',
|
||||||
|
text: [
|
||||||
|
'Das hat geklappt.',
|
||||||
|
'',
|
||||||
|
'Wenn diese Nachricht ankommt, funktionieren auch die Erinnerungen zu',
|
||||||
|
'Wunschlisten-Terminen.',
|
||||||
|
'',
|
||||||
|
env.BASE_URL,
|
||||||
|
].join('\n'),
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.ok ? { ok: true } : actionError(result.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs the check immediately instead of waiting for the next hour. */
|
||||||
|
export async function runRemindersNow(): Promise<ActionResult<ReminderRun>> {
|
||||||
|
await requireAdmin();
|
||||||
|
|
||||||
|
if (!env.isMailConfigured) {
|
||||||
|
return actionError('Es ist kein SMTP-Server konfiguriert.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const run = await runReminderJob();
|
||||||
|
revalidatePath('/verwaltung/erinnerungen');
|
||||||
|
return actionData(run);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
import { eq, sql } from 'drizzle-orm';
|
||||||
|
import { db } from '@/db';
|
||||||
|
import { wishlist } from '@/db/schema';
|
||||||
|
import { env } from '@/env';
|
||||||
|
import {
|
||||||
|
ReminderSettings,
|
||||||
|
type ReminderListRow,
|
||||||
|
} from '@/components/verwaltung/reminder-settings';
|
||||||
|
import { daysUntil } from '@/lib/reminders';
|
||||||
|
import { requireAdmin } from '@/lib/session';
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: 'Erinnerungen' };
|
||||||
|
|
||||||
|
export default async function ErinnerungenPage() {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
|
||||||
|
const openItems = sql<number>`(
|
||||||
|
select count(*) from wishlist_item wi
|
||||||
|
where wi.wishlist_id = ${wishlist.id}
|
||||||
|
and wi.reserved_at is null
|
||||||
|
and wi.fulfilled_at is null
|
||||||
|
)`.mapWith(Number);
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const lists: ReminderListRow[] = db
|
||||||
|
.select({
|
||||||
|
id: wishlist.id,
|
||||||
|
title: wishlist.title,
|
||||||
|
eventDate: wishlist.eventDate,
|
||||||
|
lastReminderAt: wishlist.lastReminderAt,
|
||||||
|
openItemCount: openItems,
|
||||||
|
})
|
||||||
|
.from(wishlist)
|
||||||
|
.where(eq(wishlist.ownerUserId, admin.id))
|
||||||
|
.all()
|
||||||
|
.filter((row) => row.eventDate !== null)
|
||||||
|
.map((row) => ({
|
||||||
|
...row,
|
||||||
|
daysLeft: row.eventDate ? daysUntil(row.eventDate, now) : null,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => (a.daysLeft ?? 0) - (b.daysLeft ?? 0));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ReminderSettings
|
||||||
|
configured={env.isMailConfigured}
|
||||||
|
daysBefore={env.REMINDER_DAYS_BEFORE}
|
||||||
|
lists={lists}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import { requireUser } from '@/lib/session';
|
|||||||
export const metadata: Metadata = { title: 'Verwaltung' };
|
export const metadata: Metadata = { title: 'Verwaltung' };
|
||||||
|
|
||||||
export default async function VerwaltungLayout({ children }: { children: React.ReactNode }) {
|
export default async function VerwaltungLayout({ children }: { children: React.ReactNode }) {
|
||||||
await requireUser();
|
const user = await requireUser();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -15,7 +15,7 @@ export default async function VerwaltungLayout({ children }: { children: React.R
|
|||||||
Stammdaten, die im Katalog und in der Sammlung ausgewählt werden.
|
Stammdaten, die im Katalog und in der Sammlung ausgewählt werden.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<VerwaltungNav />
|
<VerwaltungNav isAdmin={user.role === 'admin'} />
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</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 { WishlistItems } from '@/components/wunschlisten/wishlist-items';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { collectorBadgeClass } from '@/lib/collector-colors';
|
||||||
import { formatDate, pluralize } from '@/lib/format';
|
import { formatDate, pluralize } from '@/lib/format';
|
||||||
import { occasionLabel } from '@/lib/labels';
|
import { occasionLabel } from '@/lib/labels';
|
||||||
import { CATALOG_PAGE_SIZE, listSets, listSetYears } from '@/lib/queries/catalog';
|
import { CATALOG_PAGE_SIZE, listSets, listSetYears } from '@/lib/queries/catalog';
|
||||||
import { listManufacturers, listThemes } from '@/lib/queries/master-data';
|
import { listManufacturers, listThemes } from '@/lib/queries/master-data';
|
||||||
|
import { listCollectors } from '@/lib/queries/collectors';
|
||||||
import { getWishlist, listWishlistItems } from '@/lib/queries/wishlists';
|
import { getWishlist, listWishlistItems } from '@/lib/queries/wishlists';
|
||||||
import { requireUser } from '@/lib/session';
|
import { requireUser } from '@/lib/session';
|
||||||
|
|
||||||
@@ -39,7 +41,9 @@ export default async function WunschlisteDetailPage({ params, searchParams }: Pr
|
|||||||
const list = getWishlist(id);
|
const list = getWishlist(id);
|
||||||
if (!list || list.ownerUserId !== user.id) notFound();
|
if (!list || list.ownerUserId !== user.id) notFound();
|
||||||
|
|
||||||
const items = listWishlistItems(list.id);
|
const collectors = listCollectors();
|
||||||
|
const activeCollector = collectors.find((row) => row.id === list.collectorId) ?? null;
|
||||||
|
const items = listWishlistItems(list.id, list.collectorId);
|
||||||
|
|
||||||
// The embedded picker: missing sets that are not on this list yet. It shares
|
// The embedded picker: missing sets that are not on this list yet. It shares
|
||||||
// the URL parameters with the catalog, so the filters behave identically.
|
// the URL parameters with the catalog, so the filters behave identically.
|
||||||
@@ -52,6 +56,7 @@ export default async function WunschlisteDetailPage({ params, searchParams }: Pr
|
|||||||
year: yearParam ? Number(yearParam) : undefined,
|
year: yearParam ? Number(yearParam) : undefined,
|
||||||
ownership: 'fehlt',
|
ownership: 'fehlt',
|
||||||
excludeWishlistId: list.id,
|
excludeWishlistId: list.id,
|
||||||
|
collectorId: list.collectorId ?? undefined,
|
||||||
page,
|
page,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -63,6 +68,11 @@ export default async function WunschlisteDetailPage({ params, searchParams }: Pr
|
|||||||
<Badge variant="secondary">{occasionLabel(list.occasion)}</Badge>
|
<Badge variant="secondary">{occasionLabel(list.occasion)}</Badge>
|
||||||
{list.eventDate ? <Badge variant="outline">{formatDate(list.eventDate)}</Badge> : null}
|
{list.eventDate ? <Badge variant="outline">{formatDate(list.eventDate)}</Badge> : null}
|
||||||
{list.surpriseMode ? <Badge variant="outline">Überraschungsmodus</Badge> : null}
|
{list.surpriseMode ? <Badge variant="outline">Überraschungsmodus</Badge> : null}
|
||||||
|
{collectors.length > 1 && activeCollector ? (
|
||||||
|
<Badge className={collectorBadgeClass(activeCollector.color)}>
|
||||||
|
für {activeCollector.name}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<h1 className="mt-2 truncate text-2xl font-semibold tracking-tight">{list.title}</h1>
|
<h1 className="mt-2 truncate text-2xl font-semibold tracking-tight">{list.title}</h1>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
@@ -70,8 +80,10 @@ export default async function WunschlisteDetailPage({ params, searchParams }: Pr
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<WishlistHeaderActions
|
<WishlistHeaderActions
|
||||||
|
collectors={collectors.map((row) => ({ id: row.id, name: row.name }))}
|
||||||
wishlist={{
|
wishlist={{
|
||||||
id: list.id,
|
id: list.id,
|
||||||
|
collectorId: list.collectorId,
|
||||||
title: list.title,
|
title: list.title,
|
||||||
occasion: list.occasion,
|
occasion: list.occasion,
|
||||||
eventDate: list.eventDate,
|
eventDate: list.eventDate,
|
||||||
@@ -99,7 +111,9 @@ export default async function WunschlisteDetailPage({ params, searchParams }: Pr
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Aus fehlenden Sets hinzufügen</CardTitle>
|
<CardTitle>Aus fehlenden Sets hinzufügen</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Nur Sets, die noch nicht in der Sammlung und noch nicht auf dieser Liste sind. Kachel
|
{activeCollector
|
||||||
|
? `Nur Sets, die ${activeCollector.name} noch nicht hat und die noch nicht auf dieser Liste stehen.`
|
||||||
|
: 'Nur Sets, die noch nicht in der Sammlung und noch nicht auf dieser Liste sind.'}{' '} Kachel
|
||||||
lange drücken oder „Auswählen“ tippen, dann mehrere auf einmal übernehmen.
|
lange drücken oder „Auswählen“ tippen, dann mehrere auf einmal übernehmen.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -124,6 +138,7 @@ export default async function WunschlisteDetailPage({ params, searchParams }: Pr
|
|||||||
sets={missing.rows}
|
sets={missing.rows}
|
||||||
wishlists={[{ id: list.id, title: list.title }]}
|
wishlists={[{ id: list.id, title: list.title }]}
|
||||||
defaultWishlistId={list.id}
|
defaultWishlistId={list.id}
|
||||||
|
collectorId={list.collectorId ?? undefined}
|
||||||
emptyState="Es fehlt gerade nichts, was noch nicht auf dieser Liste steht."
|
emptyState="Es fehlt gerade nichts, was noch nicht auf dieser Liste steht."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
type ActionResult,
|
type ActionResult,
|
||||||
} from '@/lib/action-result';
|
} from '@/lib/action-result';
|
||||||
import { ownedCopiesSql } from '@/lib/ownership';
|
import { ownedCopiesSql } from '@/lib/ownership';
|
||||||
|
import { defaultCollectorId } from '@/lib/queries/collectors';
|
||||||
import {
|
import {
|
||||||
findExistingWishlistSetIds,
|
findExistingWishlistSetIds,
|
||||||
getWishlist,
|
getWishlist,
|
||||||
@@ -58,6 +59,7 @@ export async function createWishlist(input: unknown): Promise<ActionResult<{ id:
|
|||||||
.values({
|
.values({
|
||||||
id,
|
id,
|
||||||
ownerUserId: user.id,
|
ownerUserId: user.id,
|
||||||
|
collectorId: parsed.data.collectorId ?? defaultCollectorId(),
|
||||||
title: parsed.data.title,
|
title: parsed.data.title,
|
||||||
occasion: parsed.data.occasion,
|
occasion: parsed.data.occasion,
|
||||||
eventDate: parsed.data.eventDate,
|
eventDate: parsed.data.eventDate,
|
||||||
@@ -83,6 +85,7 @@ export async function updateWishlist(id: string, input: unknown): Promise<Action
|
|||||||
|
|
||||||
db.update(wishlist)
|
db.update(wishlist)
|
||||||
.set({
|
.set({
|
||||||
|
collectorId: parsed.data.collectorId,
|
||||||
title: parsed.data.title,
|
title: parsed.data.title,
|
||||||
occasion: parsed.data.occasion,
|
occasion: parsed.data.occasion,
|
||||||
eventDate: parsed.data.eventDate,
|
eventDate: parsed.data.eventDate,
|
||||||
@@ -153,7 +156,7 @@ export async function addSetsToWishlist(
|
|||||||
id: set.id,
|
id: set.id,
|
||||||
name: set.name,
|
name: set.name,
|
||||||
setNumber: set.setNumber,
|
setNumber: set.setNumber,
|
||||||
ownedCopies: ownedCopiesSql(set.id).mapWith(Number),
|
ownedCopies: ownedCopiesSql(set.id, list.collectorId).mapWith(Number),
|
||||||
})
|
})
|
||||||
.from(set)
|
.from(set)
|
||||||
.where(inArray(set.id, setIds))
|
.where(inArray(set.id, setIds))
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import { CalendarDays, Gift, Link2Off } from 'lucide-react';
|
|||||||
import { NewWishlistButton } from '@/components/wunschlisten/wishlist-form-dialog';
|
import { NewWishlistButton } from '@/components/wunschlisten/wishlist-form-dialog';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { collectorBadgeClass } from '@/lib/collector-colors';
|
||||||
import { formatDate, pluralize } from '@/lib/format';
|
import { formatDate, pluralize } from '@/lib/format';
|
||||||
import { occasionLabel } from '@/lib/labels';
|
import { occasionLabel } from '@/lib/labels';
|
||||||
|
import { listCollectors } from '@/lib/queries/collectors';
|
||||||
import { listWishlists } from '@/lib/queries/wishlists';
|
import { listWishlists } from '@/lib/queries/wishlists';
|
||||||
import { requireUser } from '@/lib/session';
|
import { requireUser } from '@/lib/session';
|
||||||
|
|
||||||
@@ -14,6 +16,7 @@ export const metadata: Metadata = { title: 'Wunschlisten' };
|
|||||||
export default async function WunschlistenPage() {
|
export default async function WunschlistenPage() {
|
||||||
const user = await requireUser();
|
const user = await requireUser();
|
||||||
const lists = listWishlists(user.id);
|
const lists = listWishlists(user.id);
|
||||||
|
const collectors = listCollectors();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
@@ -24,7 +27,9 @@ export default async function WunschlistenPage() {
|
|||||||
Zum Teilen mit Familie und Freunden – ganz ohne Konto für die anderen.
|
Zum Teilen mit Familie und Freunden – ganz ohne Konto für die anderen.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<NewWishlistButton />
|
<NewWishlistButton
|
||||||
|
collectors={collectors.map((row) => ({ id: row.id, name: row.name }))}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{lists.length === 0 ? (
|
{lists.length === 0 ? (
|
||||||
@@ -54,6 +59,11 @@ export default async function WunschlistenPage() {
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-wrap items-center gap-2 text-xs">
|
<CardContent className="flex flex-wrap items-center gap-2 text-xs">
|
||||||
|
{collectors.length > 1 && list.collectorName ? (
|
||||||
|
<Badge className={collectorBadgeClass(list.collectorColor)}>
|
||||||
|
{list.collectorName}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
<Badge variant="secondary">{pluralize(list.itemCount, 'Wunsch', 'Wünsche')}</Badge>
|
<Badge variant="secondary">{pluralize(list.itemCount, 'Wunsch', 'Wünsche')}</Badge>
|
||||||
{list.openCount > 0 ? (
|
{list.openCount > 0 ? (
|
||||||
<Badge variant="outline">{list.openCount} noch offen</Badge>
|
<Badge variant="outline">{list.openCount} noch offen</Badge>
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { headers } from 'next/headers';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { actionError, actionOk, parseForm, type ActionResult } from '@/lib/action-result';
|
||||||
|
import { auth } from '@/lib/auth';
|
||||||
|
import { findUsableInvite, markInviteUsed, withInviteGrant } from '@/lib/invites';
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
token: z.string().trim().min(1),
|
||||||
|
name: z.string().trim().min(1, 'Bitte gib deinen Namen an.').max(80),
|
||||||
|
email: z.email('Bitte gib eine gültige E-Mail-Adresse an.'),
|
||||||
|
password: z.string().min(10, 'Das Passwort braucht mindestens 10 Zeichen.'),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registration through an invitation link.
|
||||||
|
*
|
||||||
|
* The token is checked here, on the server, and only then is the sign-up
|
||||||
|
* wrapped in a grant that the user-create hook can see. The role comes from
|
||||||
|
* the invitation, never from the submitted form.
|
||||||
|
*/
|
||||||
|
export async function registerWithInvite(input: unknown): Promise<ActionResult> {
|
||||||
|
const parsed = parseForm(schema, input);
|
||||||
|
if (!parsed.ok) return parsed;
|
||||||
|
|
||||||
|
const invitation = findUsableInvite(parsed.data.token);
|
||||||
|
if (!invitation) {
|
||||||
|
return actionError('Diese Einladung ist abgelaufen oder wurde bereits verwendet.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invitation.email && invitation.email.toLowerCase() !== parsed.data.email.toLowerCase()) {
|
||||||
|
return actionError(`Diese Einladung gilt für ${invitation.email}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestHeaders = await headers();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await withInviteGrant(
|
||||||
|
{ inviteId: invitation.id, role: invitation.role },
|
||||||
|
() =>
|
||||||
|
auth.api.signUpEmail({
|
||||||
|
body: {
|
||||||
|
name: parsed.data.name,
|
||||||
|
email: parsed.data.email,
|
||||||
|
password: parsed.data.password,
|
||||||
|
},
|
||||||
|
headers: requestHeaders,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
markInviteUsed(invitation.id, result.user.id);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : '';
|
||||||
|
if (message.toLowerCase().includes('exist')) {
|
||||||
|
return actionError('Zu dieser E-Mail-Adresse gibt es schon ein Konto.');
|
||||||
|
}
|
||||||
|
console.error('[invite] Registrierung fehlgeschlagen', error);
|
||||||
|
return actionError('Die Registrierung hat nicht geklappt. Bitte versuch es noch einmal.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return actionOk();
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import { InviteForm } from '@/components/auth/invite-form';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { formatDate } from '@/lib/format';
|
||||||
|
import { findUsableInvite } from '@/lib/invites';
|
||||||
|
import { getSession } from '@/lib/session';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Einladung',
|
||||||
|
robots: { index: false, follow: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function InvitePage({ params }: { params: Promise<{ token: string }> }) {
|
||||||
|
if (await getSession()) redirect('/');
|
||||||
|
|
||||||
|
const { token } = await params;
|
||||||
|
const invitation = findUsableInvite(token);
|
||||||
|
|
||||||
|
if (!invitation) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Einladung ungültig</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Dieser Link ist abgelaufen oder wurde schon benutzt. Lass dir einfach einen neuen
|
||||||
|
schicken.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Link href="/anmelden" className="text-sm underline underline-offset-4">
|
||||||
|
Zur Anmeldung
|
||||||
|
</Link>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Konto anlegen</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Du wurdest zu Bricktrack eingeladen. Der Link gilt noch bis zum{' '}
|
||||||
|
{formatDate(invitation.expiresAt)}.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<InviteForm token={token} email={invitation.email} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Blocks } from 'lucide-react';
|
import { BrickLogo } from '@/components/brick/logo';
|
||||||
|
|
||||||
export default function AuthLayout({ children }: { children: React.ReactNode }) {
|
export default function AuthLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-dvh flex-col items-center justify-center gap-8 px-4 py-10">
|
<div className="flex min-h-dvh flex-col items-center justify-center gap-8 px-4 py-10">
|
||||||
<Link href="/" className="flex items-center gap-2 text-xl font-semibold tracking-tight">
|
<Link
|
||||||
<Blocks className="size-6 text-primary" aria-hidden />
|
href="/"
|
||||||
|
className="flex items-center gap-2 text-2xl font-bold tracking-tight [font-family:var(--font-display)]"
|
||||||
|
>
|
||||||
|
<BrickLogo className="h-8 w-9" />
|
||||||
Bricktrack
|
Bricktrack
|
||||||
</Link>
|
</Link>
|
||||||
<main className="w-full max-w-sm">{children}</main>
|
<main className="w-full max-w-sm">{children}</main>
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import {
|
||||||
|
buildCollectionCsv,
|
||||||
|
buildExportPayload,
|
||||||
|
buildExportZip,
|
||||||
|
exportFileName,
|
||||||
|
} from '@/lib/export';
|
||||||
|
import { formatDate } from '@/lib/format';
|
||||||
|
import { conditionLabel, completenessLabel, statusLabel } from '@/lib/labels';
|
||||||
|
import { centsToInput } from '@/lib/money';
|
||||||
|
import { listCollectionItems } from '@/lib/queries/collection';
|
||||||
|
import { getCurrentUser } from '@/lib/session';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
const CSV_COLUMNS = [
|
||||||
|
{ key: 'setNumber', label: 'Setnummer' },
|
||||||
|
{ key: 'setName', label: 'Name' },
|
||||||
|
{ key: 'manufacturer', label: 'Hersteller' },
|
||||||
|
{ key: 'theme', label: 'Thema' },
|
||||||
|
{ key: 'year', label: 'Jahr' },
|
||||||
|
{ key: 'pieceCount', label: 'Teile' },
|
||||||
|
{ key: 'status', label: 'Status' },
|
||||||
|
{ key: 'completeness', label: 'Vollständigkeit' },
|
||||||
|
{ key: 'condition', label: 'Zustand' },
|
||||||
|
{ key: 'location', label: 'Standort' },
|
||||||
|
{ key: 'acquiredAt', label: 'Im Besitz seit' },
|
||||||
|
{ key: 'acquiredPrice', label: 'Kaufpreis (EUR)' },
|
||||||
|
{ key: 'retailPrice', label: 'UVP (EUR)' },
|
||||||
|
{ key: 'acquiredFrom', label: 'Gekauft bei' },
|
||||||
|
{ key: 'giftedBy', label: 'Geschenkt von' },
|
||||||
|
{ key: 'notes', label: 'Notizen' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Downloads.
|
||||||
|
*
|
||||||
|
* The full archive is for admins - it contains the whole collection. The CSV
|
||||||
|
* of the collection is available to every signed-in member.
|
||||||
|
*/
|
||||||
|
export async function GET(
|
||||||
|
_request: Request,
|
||||||
|
context: { params: Promise<{ kind: string }> },
|
||||||
|
) {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
if (!user) return new NextResponse('Nicht angemeldet', { status: 401 });
|
||||||
|
|
||||||
|
const { kind } = await context.params;
|
||||||
|
|
||||||
|
if (kind === 'csv') {
|
||||||
|
const rows = listCollectionItems({}).map((item) => ({
|
||||||
|
setNumber: item.setNumber,
|
||||||
|
setName: item.setName,
|
||||||
|
manufacturer: item.manufacturerName,
|
||||||
|
theme: item.themeName,
|
||||||
|
year: item.year,
|
||||||
|
pieceCount: item.pieceCount,
|
||||||
|
status: statusLabel(item.status),
|
||||||
|
completeness: completenessLabel(item.completeness),
|
||||||
|
condition: conditionLabel(item.condition),
|
||||||
|
location: item.locationName,
|
||||||
|
acquiredAt: item.acquiredAt ? formatDate(item.acquiredAt) : '',
|
||||||
|
acquiredPrice: centsToInput(item.acquiredPriceCents),
|
||||||
|
retailPrice: centsToInput(item.retailPriceCents),
|
||||||
|
acquiredFrom: item.acquiredFrom,
|
||||||
|
giftedBy: item.giftedBy,
|
||||||
|
notes: item.notes,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return new NextResponse(buildCollectionCsv(rows, CSV_COLUMNS), {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/csv; charset=utf-8',
|
||||||
|
'Content-Disposition': `attachment; filename="${exportFileName('csv')}"`,
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.role !== 'admin') {
|
||||||
|
return new NextResponse('Nur Administratoren dürfen die Daten exportieren', { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === 'json') {
|
||||||
|
return new NextResponse(JSON.stringify(buildExportPayload(), null, 2), {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json; charset=utf-8',
|
||||||
|
'Content-Disposition': `attachment; filename="${exportFileName('json')}"`,
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === 'zip') {
|
||||||
|
const archive = buildExportZip();
|
||||||
|
return new NextResponse(archive as unknown as BodyInit, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/zip',
|
||||||
|
'Content-Length': String(archive.byteLength),
|
||||||
|
'Content-Disposition': `attachment; filename="${exportFileName('zip')}"`,
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new NextResponse('Unbekanntes Format', { status: 404 });
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { sql } from 'drizzle-orm';
|
import { sql } from 'drizzle-orm';
|
||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { db } from '@/db';
|
import { db } from '@/db';
|
||||||
|
import { ensureReminderScheduler } from '@/lib/reminder-job';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
@@ -9,6 +10,10 @@ export const dynamic = 'force-dynamic';
|
|||||||
* file is readable - a healthy Node process on a broken volume is not healthy.
|
* file is readable - a healthy Node process on a broken volume is not healthy.
|
||||||
*/
|
*/
|
||||||
export function GET() {
|
export function GET() {
|
||||||
|
// The container polls this every 30s, which makes it the earliest reliable
|
||||||
|
// moment to have the reminder scheduler running. Repeat calls are free.
|
||||||
|
ensureReminderScheduler();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
db.get(sql`select 1`);
|
db.get(sql`select 1`);
|
||||||
return NextResponse.json({ status: 'ok', time: new Date().toISOString() });
|
return NextResponse.json({ status: 'ok', time: new Date().toISOString() });
|
||||||
|
|||||||
@@ -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 "tailwindcss";
|
||||||
@import "tw-animate-css";
|
@import "tw-animate-css";
|
||||||
@import "shadcn/tailwind.css";
|
@import "shadcn/tailwind.css";
|
||||||
|
@import "./designs.css";
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
@@ -10,6 +11,7 @@
|
|||||||
"Segoe UI Emoji";
|
"Segoe UI Emoji";
|
||||||
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
|
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
|
||||||
"Liberation Mono", monospace;
|
"Liberation Mono", monospace;
|
||||||
|
--font-display: var(--font-sans);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -18,7 +20,16 @@
|
|||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--font-sans: var(--font-sans);
|
--font-sans: var(--font-sans);
|
||||||
--font-mono: var(--font-mono);
|
--font-mono: var(--font-mono);
|
||||||
--font-heading: var(--font-sans);
|
--font-heading: var(--font-display);
|
||||||
|
--color-edge: var(--edge);
|
||||||
|
--color-owned: var(--owned);
|
||||||
|
--color-owned-foreground: var(--owned-foreground);
|
||||||
|
--color-tone-ink: var(--tone-ink);
|
||||||
|
--color-tone-1: var(--tone-1);
|
||||||
|
--color-tone-2: var(--tone-2);
|
||||||
|
--color-tone-3: var(--tone-3);
|
||||||
|
--color-tone-4: var(--tone-4);
|
||||||
|
--color-tone-5: var(--tone-5);
|
||||||
--color-sidebar-ring: var(--sidebar-ring);
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
--color-sidebar-border: var(--sidebar-border);
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
@@ -90,6 +101,21 @@
|
|||||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||||
--sidebar-border: oklch(0.922 0 0);
|
--sidebar-border: oklch(0.922 0 0);
|
||||||
--sidebar-ring: oklch(0.708 0 0);
|
--sidebar-ring: oklch(0.708 0 0);
|
||||||
|
/* Brick look: outline, "lift" (the hard bottom edge) and studs. The
|
||||||
|
neutral design has none of it; see designs.css for the others. */
|
||||||
|
--edge: oklch(0.145 0 0 / 10%);
|
||||||
|
--edge-w: 1px;
|
||||||
|
--lift: none;
|
||||||
|
--lift-sm: none;
|
||||||
|
--studs: none;
|
||||||
|
--owned: oklch(0.596 0.145 163.225);
|
||||||
|
--owned-foreground: oklch(1 0 0);
|
||||||
|
--tone-ink: oklch(0.556 0 0);
|
||||||
|
--tone-1: oklch(0.97 0 0);
|
||||||
|
--tone-2: oklch(0.97 0 0);
|
||||||
|
--tone-3: oklch(0.97 0 0);
|
||||||
|
--tone-4: oklch(0.97 0 0);
|
||||||
|
--tone-5: oklch(0.97 0 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
@@ -136,4 +162,9 @@
|
|||||||
html {
|
html {
|
||||||
@apply font-sans;
|
@apply font-sans;
|
||||||
}
|
}
|
||||||
}
|
h1,
|
||||||
|
h2,
|
||||||
|
h3 {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
}
|
||||||
|
}␍
|
||||||
@@ -1,5 +1,14 @@
|
|||||||
import type { Metadata, Viewport } from 'next';
|
import type { Metadata, Viewport } from 'next';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import '@fontsource-variable/archivo';
|
||||||
|
import '@fontsource-variable/bricolage-grotesque';
|
||||||
|
import '@fontsource-variable/fredoka';
|
||||||
|
import '@fontsource-variable/inter';
|
||||||
|
import '@fontsource-variable/nunito';
|
||||||
|
import '@fontsource-variable/outfit';
|
||||||
|
import { ServiceWorkerRegistration } from '@/components/layout/service-worker';
|
||||||
import { Toaster } from '@/components/ui/sonner';
|
import { Toaster } from '@/components/ui/sonner';
|
||||||
|
import { DESIGN_COOKIE, DESIGN_INFO, resolveDesign } from '@/lib/design';
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -9,27 +18,49 @@ export const metadata: Metadata = {
|
|||||||
},
|
},
|
||||||
description: 'Sammlung und Wunschlisten für Spielzeug-Sets',
|
description: 'Sammlung und Wunschlisten für Spielzeug-Sets',
|
||||||
applicationName: 'Bricktrack',
|
applicationName: 'Bricktrack',
|
||||||
|
manifest: '/manifest.webmanifest',
|
||||||
|
icons: {
|
||||||
|
icon: [{ url: '/favicon.png', type: 'image/png', sizes: '32x32' }],
|
||||||
|
apple: [{ url: '/icons/apple-touch-icon.png', sizes: '180x180' }],
|
||||||
|
},
|
||||||
|
appleWebApp: {
|
||||||
|
capable: true,
|
||||||
|
title: 'Bricktrack',
|
||||||
|
statusBarStyle: 'default',
|
||||||
|
},
|
||||||
// A private, self-hosted instance never belongs in a search index - and the
|
// A private, self-hosted instance never belongs in a search index - and the
|
||||||
// public wishlist links are meant to be shared, not found.
|
// public wishlist links are meant to be shared, not found.
|
||||||
robots: { index: false, follow: false },
|
robots: { index: false, follow: false },
|
||||||
};
|
};
|
||||||
|
|
||||||
export const viewport: Viewport = {
|
async function currentDesign() {
|
||||||
width: 'device-width',
|
return resolveDesign((await cookies()).get(DESIGN_COOKIE)?.value);
|
||||||
initialScale: 1,
|
}
|
||||||
viewportFit: 'cover',
|
|
||||||
themeColor: [
|
export async function generateViewport(): Promise<Viewport> {
|
||||||
{ media: '(prefers-color-scheme: light)', color: '#ffffff' },
|
const design = await currentDesign();
|
||||||
{ media: '(prefers-color-scheme: dark)', color: '#0a0a0a' },
|
return {
|
||||||
],
|
width: 'device-width',
|
||||||
};
|
initialScale: 1,
|
||||||
|
viewportFit: 'cover',
|
||||||
|
themeColor: DESIGN_INFO[design].themeColor,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||||
|
const design = await currentDesign();
|
||||||
|
|
||||||
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
|
||||||
return (
|
return (
|
||||||
<html lang="de" suppressHydrationWarning>
|
<html
|
||||||
|
lang="de"
|
||||||
|
data-design={design}
|
||||||
|
className={DESIGN_INFO[design].dark ? 'dark' : undefined}
|
||||||
|
suppressHydrationWarning
|
||||||
|
>
|
||||||
<body className="min-h-dvh bg-background text-foreground antialiased">
|
<body className="min-h-dvh bg-background text-foreground antialiased">
|
||||||
{children}
|
{children}
|
||||||
<Toaster position="top-center" richColors closeButton />
|
<Toaster position="top-center" richColors closeButton />
|
||||||
|
<ServiceWorkerRegistration />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
import { CloudOff } from 'lucide-react';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Offline',
|
||||||
|
robots: { index: false, follow: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shown when a page is requested that the device has never loaded before and
|
||||||
|
* there is no connection. Pages that were visited once come from the cache
|
||||||
|
* instead of landing here.
|
||||||
|
*/
|
||||||
|
export default function OfflinePage() {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex min-h-dvh max-w-sm flex-col items-center justify-center gap-4 px-4 text-center">
|
||||||
|
<CloudOff className="size-10 text-muted-foreground" aria-hidden />
|
||||||
|
<h1 className="text-xl font-semibold tracking-tight">Gerade offline</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Diese Seite war noch nie geladen und lässt sich ohne Verbindung nicht anzeigen. Seiten,
|
||||||
|
die du schon einmal geöffnet hast – zum Beispiel der Katalog – funktionieren weiter.
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Sobald wieder Netz da ist, aktualisiert sich alles von selbst.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
import { Blocks, CalendarDays, Gift } from 'lucide-react';
|
import { CalendarDays, Gift } from 'lucide-react';
|
||||||
|
import { BrickLogo } from '@/components/brick/logo';
|
||||||
|
import { Studs } from '@/components/brick/studs';
|
||||||
import { PublicWishlist, type PublicItem } from '@/components/wunschlisten/public-wishlist';
|
import { PublicWishlist, type PublicItem } from '@/components/wunschlisten/public-wishlist';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { formatDate, pluralize } from '@/lib/format';
|
import { formatDate, pluralize } from '@/lib/format';
|
||||||
@@ -27,7 +29,7 @@ export default async function PublicWishlistPage({ params }: Props) {
|
|||||||
const list = getWishlistByShareToken(shareToken);
|
const list = getWishlistByShareToken(shareToken);
|
||||||
if (!list || !list.isPublic) notFound();
|
if (!list || !list.isPublic) notFound();
|
||||||
|
|
||||||
const rows = listWishlistItems(list.id);
|
const rows = listWishlistItems(list.id, list.collectorId);
|
||||||
|
|
||||||
// Fulfilled wishes are gone from the public view: the present is already
|
// Fulfilled wishes are gone from the public view: the present is already
|
||||||
// under the tree, no reason to let someone buy it twice.
|
// under the tree, no reason to let someone buy it twice.
|
||||||
@@ -54,50 +56,56 @@ export default async function PublicWishlistPage({ params }: Props) {
|
|||||||
const openCount = items.filter((item) => !item.isReserved).length;
|
const openCount = items.filter((item) => !item.isReserved).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto min-h-dvh w-full max-w-2xl px-4 py-8">
|
<div className="min-h-dvh">
|
||||||
<header className="space-y-3 border-b border-border pb-6">
|
<header className="border-b-[length:max(1px,var(--edge-w))] border-edge bg-primary text-primary-foreground">
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
<div className="mx-auto w-full max-w-2xl space-y-3 px-4 pt-6 pb-10">
|
||||||
<Blocks className="size-4 text-primary" aria-hidden />
|
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||||
Bricktrack
|
<BrickLogo className="h-5 w-6" />
|
||||||
</div>
|
Bricktrack
|
||||||
|
</div>
|
||||||
|
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">{list.title}</h1>
|
<h1 className="text-3xl font-semibold tracking-tight">{list.title}</h1>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Badge variant="secondary" className="gap-1">
|
<Badge className="gap-1 bg-card text-card-foreground">
|
||||||
<Gift className="size-3" aria-hidden />
|
<Gift className="size-3" aria-hidden />
|
||||||
{occasionLabel(list.occasion)}
|
{occasionLabel(list.occasion)}
|
||||||
</Badge>
|
|
||||||
{list.eventDate ? (
|
|
||||||
<Badge variant="outline" className="gap-1">
|
|
||||||
<CalendarDays className="size-3" aria-hidden />
|
|
||||||
{formatDate(list.eventDate)}
|
|
||||||
</Badge>
|
</Badge>
|
||||||
) : null}
|
{list.eventDate ? (
|
||||||
<span className="text-sm text-muted-foreground">
|
<Badge className="gap-1 bg-card text-card-foreground">
|
||||||
{pluralize(openCount, 'Wunsch noch offen', 'Wünsche noch offen')}
|
<CalendarDays className="size-3" aria-hidden />
|
||||||
</span>
|
{formatDate(list.eventDate)}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
<Badge variant="secondary">
|
||||||
|
{pluralize(openCount, 'Wunsch noch offen', 'Wünsche noch offen')}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{list.note ? (
|
|
||||||
<p className="rounded-lg bg-muted p-3 text-sm whitespace-pre-line">{list.note}</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Klick auf „Das schenke ich“, damit niemand dasselbe doppelt kauft. Eine Anmeldung
|
|
||||||
brauchst du dafür nicht.
|
|
||||||
</p>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="py-6">
|
<div className="mx-auto w-full max-w-2xl px-4 pb-8">
|
||||||
{items.length === 0 ? (
|
<div className="relative -mt-5">
|
||||||
<p className="rounded-lg border border-dashed border-border p-8 text-center text-sm text-muted-foreground">
|
<Studs />
|
||||||
Auf dieser Liste steht gerade nichts.
|
<div className="brick space-y-2 rounded-xl bg-card p-4 text-card-foreground">
|
||||||
</p>
|
{list.note ? <p className="text-sm whitespace-pre-line">{list.note}</p> : null}
|
||||||
) : (
|
<p className="text-xs text-muted-foreground">
|
||||||
<PublicWishlist shareToken={shareToken} items={items} />
|
Klick auf „Das schenke ich“, damit niemand dasselbe doppelt kauft. Eine Anmeldung
|
||||||
)}
|
brauchst du dafür nicht.
|
||||||
</main>
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main className="py-6">
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<p className="rounded-lg border border-dashed border-border p-8 text-center text-sm text-muted-foreground">
|
||||||
|
Auf dieser Liste steht gerade nichts.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<PublicWishlist shareToken={shareToken} items={items} />
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useState, useTransition } from 'react';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { registerWithInvite } from '@/app/(auth)/einladung/[token]/actions';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Field } from '@/components/ui/field';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
|
||||||
|
const MIN_PASSWORD_LENGTH = 10;
|
||||||
|
|
||||||
|
export function InviteForm({ token, email }: { token: string; email: string | null }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
|
||||||
|
function onSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
const input = Object.fromEntries(new FormData(event.currentTarget).entries());
|
||||||
|
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await registerWithInvite({ ...input, token });
|
||||||
|
if (!result.ok) {
|
||||||
|
toast.error(result.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast.success('Konto angelegt. Willkommen bei Bricktrack!');
|
||||||
|
router.push('/');
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={onSubmit} className="space-y-4">
|
||||||
|
<Field label="Name" htmlFor="name" required>
|
||||||
|
<Input id="name" name="name" autoComplete="name" required maxLength={80} />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label="E-Mail"
|
||||||
|
htmlFor="email"
|
||||||
|
required
|
||||||
|
hint={email ? 'Diese Einladung gilt für genau diese Adresse.' : undefined}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
inputMode="email"
|
||||||
|
autoComplete="username"
|
||||||
|
required
|
||||||
|
defaultValue={email ?? ''}
|
||||||
|
readOnly={Boolean(email)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label="Passwort"
|
||||||
|
htmlFor="password"
|
||||||
|
required
|
||||||
|
hint={`Mindestens ${MIN_PASSWORD_LENGTH} Zeichen. Einen Passkey kannst du danach unter „Konto“ hinterlegen.`}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
minLength={MIN_PASSWORD_LENGTH}
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Button type="submit" size="lg" className="w-full" disabled={pending}>
|
||||||
|
{pending ? <Loader2 className="animate-spin" /> : null}
|
||||||
|
Konto anlegen
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { Loader2, ScanLine } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { lookupScannedEan } from '@/app/(app)/katalog/scan-actions';
|
||||||
|
import { formatEan, isValidEan } from '@/lib/ean';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
|
||||||
|
type Controls = { stop: () => void };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scans the barcode on a box with the phone camera.
|
||||||
|
*
|
||||||
|
* Decoding runs in the browser with zxing, which is bundled with the app and
|
||||||
|
* 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() {
|
||||||
|
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);
|
||||||
|
|
||||||
|
const stop = useCallback(() => {
|
||||||
|
controlsRef.current?.stop();
|
||||||
|
controlsRef.current = null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onDetected = useCallback(
|
||||||
|
(raw: string) => {
|
||||||
|
if (handledRef.current) return;
|
||||||
|
if (!isValidEan(raw)) return; // keep scanning on a misread
|
||||||
|
|
||||||
|
handledRef.current = true;
|
||||||
|
stop();
|
||||||
|
setStatus('checking');
|
||||||
|
|
||||||
|
void lookupScannedEan(raw).then((result) => {
|
||||||
|
if (!result.ok) {
|
||||||
|
toast.error(result.error);
|
||||||
|
setOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setOpen(false);
|
||||||
|
|
||||||
|
if (result.data.found) {
|
||||||
|
toast.success(`${result.data.setNumber} · ${result.data.name}`);
|
||||||
|
router.push(`/katalog/${result.data.setId}`);
|
||||||
|
} else {
|
||||||
|
toast.info(`${formatEan(result.data.ean)} ist noch nicht im Katalog.`);
|
||||||
|
router.push(`/katalog/neu?ean=${encodeURIComponent(result.data.ean)}`);
|
||||||
|
}
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[router, stop],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
|
||||||
|
handledRef.current = false;
|
||||||
|
setStatus('starting');
|
||||||
|
setMessage(null);
|
||||||
|
|
||||||
|
// The camera needs a secure context - same rule as for passkeys.
|
||||||
|
if (!window.isSecureContext) {
|
||||||
|
setStatus('error');
|
||||||
|
setMessage(
|
||||||
|
'Die Kamera funktioniert nur über HTTPS. Ruf Bricktrack über deine Domain auf, nicht über die IP-Adresse.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const { BrowserMultiFormatReader } = await import('@zxing/browser');
|
||||||
|
const reader = new BrowserMultiFormatReader();
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video || cancelled) return;
|
||||||
|
|
||||||
|
const controls = await reader.decodeFromConstraints(
|
||||||
|
// The rear camera is the one pointing at the box.
|
||||||
|
{ video: { facingMode: 'environment' } },
|
||||||
|
video,
|
||||||
|
(result) => {
|
||||||
|
if (result) onDetected(result.getText());
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (cancelled) {
|
||||||
|
controls.stop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
controlsRef.current = controls;
|
||||||
|
setStatus('scanning');
|
||||||
|
} catch (error) {
|
||||||
|
if (cancelled) return;
|
||||||
|
console.error('[scan] Kamera konnte nicht gestartet werden', error);
|
||||||
|
setStatus('error');
|
||||||
|
|
||||||
|
const name = (error as { name?: string })?.name;
|
||||||
|
setMessage(
|
||||||
|
name === 'NotAllowedError'
|
||||||
|
? 'Der Zugriff auf die Kamera wurde abgelehnt. Erlaube ihn in den Browser-Einstellungen und versuch es noch einmal.'
|
||||||
|
: name === 'NotFoundError'
|
||||||
|
? 'Dieses Gerät hat keine nutzbare Kamera.'
|
||||||
|
: 'Die Kamera lässt sich nicht starten.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
stop();
|
||||||
|
};
|
||||||
|
}, [open, onDetected, stop]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon-lg"
|
||||||
|
aria-label="Barcode scannen"
|
||||||
|
title="Barcode scannen"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||