Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76af719fac | ||
|
|
ebab5627de | ||
|
|
01bbb3ba9a | ||
|
|
ebdd6c313a | ||
|
|
7d75e4345a | ||
|
|
7d7711eb8d | ||
|
|
a8d933bf5b | ||
|
|
295da414bb | ||
|
|
d5ffcbae60 | ||
|
|
d578eac743 | ||
|
|
95a28640e1 | ||
|
|
498a5c6226 | ||
|
|
78ae755761 | ||
|
|
62045468ce | ||
|
|
bac948fcb8 | ||
|
|
2d3ddc4dae | ||
|
|
604a8ac113 | ||
|
|
f2304b28e5 | ||
|
|
d009230dc2 | ||
|
|
1b825b44c4 | ||
|
|
e9093d0fad | ||
|
|
e52557b4bf | ||
|
|
0d08df0bff | ||
|
|
91fb66b13b | ||
|
|
87131b8e70 | ||
|
|
c23b754515 | ||
|
|
7265c03548 | ||
|
|
dcd076498a | ||
|
|
39271f8af7 | ||
|
|
45aa8f13cf | ||
|
|
5c5c5213bb | ||
|
|
baa94bf7e5 | ||
|
|
4e3cbb81ce | ||
|
|
52d69e5654 | ||
|
|
220a3303cd | ||
|
|
546040c837 | ||
|
|
f42cd31673 | ||
|
|
b1fc502d55 | ||
|
|
61b1249afd | ||
|
|
b0152b8667 | ||
|
|
79b0133083 | ||
|
|
856800b4e4 | ||
|
|
af0ae039cb | ||
|
|
4c78c5672b |
@@ -0,0 +1,20 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.github
|
||||
data
|
||||
data-e2e
|
||||
test-results
|
||||
playwright-report
|
||||
blob-report
|
||||
coverage
|
||||
.env
|
||||
.env.*.local
|
||||
*.log
|
||||
docs
|
||||
README.md
|
||||
QUICKSTART.md
|
||||
CHANGELOG.md
|
||||
CLAUDE.md
|
||||
.vscode
|
||||
.idea
|
||||
@@ -21,6 +21,27 @@ MAX_UPLOAD_MB=25
|
||||
# Zeitzone des Containers.
|
||||
TZ=Europe/Berlin
|
||||
|
||||
# Optional (ab Phase 2): Rebrickable-API-Key fuer den Lego-Import.
|
||||
# Ohne Key funktioniert alles manuell weiter.
|
||||
# Wo der Container auf dem Host lauscht. Voreinstellung: nur localhost, weil
|
||||
# der Reverse Proxy auf demselben Host laeuft. Sitzt der Proxy woanders:
|
||||
# BIND_ADDRESS=0.0.0.0 (und dann unbedingt die Firewall zuziehen).
|
||||
# BIND_ADDRESS=127.0.0.1
|
||||
# HOST_PORT=3000
|
||||
|
||||
# Optional: Rebrickable-API-Key fuer den Lego-Import. Damit reicht auf der
|
||||
# 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=
|
||||
|
||||
# Optional: Mailversand fuer Erinnerungen an Wunschlisten-Termine. Einfacher
|
||||
# geht es in der App unter Mehr -> Erinnerungen -> Mailserver. Steht der
|
||||
# Server hier, hat die .env Vorrang und die App zeigt ihn nur an.
|
||||
# SMTP_HOST=mail.example.de
|
||||
# SMTP_PORT=587
|
||||
# SMTP_SECURE=false
|
||||
# SMTP_USER=bricktrack@example.de
|
||||
# SMTP_PASSWORD=
|
||||
# SMTP_FROM=Bricktrack <bricktrack@example.de>
|
||||
|
||||
# Wie viele Tage vor dem Termin erinnert wird (Standard: 14).
|
||||
# REMINDER_DAYS_BEFORE=14
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# Normalise line endings so files written on Windows still work in the
|
||||
# Linux container. A shell script with CRLF fails with the confusing
|
||||
# "no such file or directory" on its shebang line.
|
||||
* text=auto eol=lf
|
||||
|
||||
*.sh text eol=lf
|
||||
Dockerfile text eol=lf
|
||||
*.yml text eol=lf
|
||||
*.yaml text eol=lf
|
||||
|
||||
# Binary files must never be touched.
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.webp binary
|
||||
*.pdf binary
|
||||
*.ico binary
|
||||
@@ -19,6 +19,12 @@ next-env.d.ts
|
||||
|
||||
# runtime data (database, uploads) - never in git, this is the backup target
|
||||
/data
|
||||
/data-e2e
|
||||
/data-shots
|
||||
/data-docker
|
||||
/backups
|
||||
/data-test
|
||||
/data-test-export
|
||||
|
||||
# production
|
||||
*.tsbuildinfo
|
||||
|
||||
@@ -7,14 +7,276 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
- **Mailserver in der App einrichten**: Unter Mehr → Erinnerungen → Mailserver
|
||||
lassen sich Server, Port, Verschlüsselung, Zugang und Absender eintragen –
|
||||
ohne `.env` und ohne Neustart. Das Passwort wird verschlüsselt gespeichert
|
||||
(AES-256-GCM, Schlüssel aus `BETTER_AUTH_SECRET`), nie wieder angezeigt und
|
||||
nicht exportiert. Steht der Server in der `.env`, hat sie weiter Vorrang.
|
||||
- **Set aus einem Link übernehmen**, für alle Hersteller: Amazon, idealo,
|
||||
geizhals, Hersteller-Shops, Brickfact oder Merlins Bricks. Bricktrack liest
|
||||
Produktdaten (schema.org, Open Graph, bei Amazon die Produktseite) und, wenn
|
||||
die Seite blockt, den Link selbst. Lego wird über Rebrickable ergänzt,
|
||||
BlueBrixx und Cobi über Merlins Bricks. Das Ergebnis füllt das Formular vor,
|
||||
das Bild wird beim Speichern lokal abgelegt.
|
||||
|
||||
### Geändert
|
||||
|
||||
- Der Zeitplan für Erinnerungen läuft immer und prüft bei jedem Durchlauf, ob
|
||||
ein Mailserver eingerichtet ist. Vorher wurde er nur gestartet, wenn SMTP
|
||||
schon beim Containerstart in der `.env` stand.
|
||||
- **Neue Navigation**: unten Start, Katalog, Sammlung, Wünsche und in der
|
||||
Mitte ein +-Stein mit allen Wegen zu einem neuen Set (Scannen, aus Link,
|
||||
Lego-Nummer, von Hand).
|
||||
- **„Mehr" statt Konto-Menü und Verwaltungsreitern**: Konto, Design,
|
||||
Sammler, Einladungen, Erinnerungen, Hersteller, Themen, Standorte und
|
||||
Export/Import als Kacheln in Gruppen, hinter dem Button „Mehr" oben rechts.
|
||||
- **„Für wen?" in der Kopfzeile**: Bei mehreren Sammlern wird das Kind einmal
|
||||
oben gewählt und gilt für Startseite, Katalog und Sammlung. Jedes Gerät
|
||||
merkt sich die Wahl; ein `?sammler=` im Link hat weiterhin Vorrang.
|
||||
|
||||
### Behoben
|
||||
|
||||
- Mitglieder (nicht Admin) kamen nicht an Sammler, Hersteller, Themen und
|
||||
Standorte heran, obwohl sie diese pflegen dürfen: Der Eintrag
|
||||
„Verwaltung" stand nur im Menü des Administrators.
|
||||
|
||||
### Sicherheit
|
||||
|
||||
- Abrufe im Auftrag eines Nutzers gehen nur an öffentliche Adressen: Jede
|
||||
Adresse wird vor dem Verbindungsaufbau geprüft, Weiterleitungen ebenso,
|
||||
Größe und Zeit sind begrenzt.
|
||||
|
||||
## [0.4.0] - 2026-10-25
|
||||
|
||||
Ein neues Gesicht: Bricktrack sieht jetzt aus wie das, was es verwaltet.
|
||||
Dazu alle bekannten Sicherheitslücken in den Abhängigkeiten geschlossen.
|
||||
|
||||
### Sicherheit
|
||||
|
||||
- Next.js 15.5.28: schließt unter anderem eine Denial-of-Service-Lücke über
|
||||
Server Actions und Cache-Poisoning bei selbst gehosteten Instanzen. Die
|
||||
kritische Lücke in der Bildoptimierung war hier nicht ausnutzbar
|
||||
(`images.unoptimized`), ist aber ebenfalls behoben.
|
||||
- nodemailer 10.2: mehrere Fehler im Adressparser (Erinnerungs-Mails).
|
||||
- better-auth 1.7.12, sharp 0.35.5 (librsvg), zod 4.6.7 (Speicherverbrauch
|
||||
bei übergroßen Eingaben), drizzle-orm 0.45.4.
|
||||
- Entwicklungswerkzeuge: vitest, vite, postcss, Playwright und die
|
||||
shadcn-CLI aktualisiert, transitive Pakete per `pnpm.overrides` angehoben.
|
||||
Übrig bleibt ein mittlerer Fund in einem alten esbuild, das drizzle-kit
|
||||
intern mitbringt - nur beim Entwickeln, nicht im Image.
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
- **Designauswahl** unter Konto → Design: Klemmbaustein (neuer Standard),
|
||||
Warm, Nachtblau (dunkel), Bonbon, Bauklotz und Schlicht (der bisherige
|
||||
Look). Die Wahl gilt pro Gerät und wirkt sofort.
|
||||
- **Platzhalter für Sets ohne Bild**: Farbe des Herstellers, gezeichneter
|
||||
Stein und Setnummer statt eines grauen „Bild fehlt".
|
||||
- **Startseite als Überblick**: Steinreihe für den Fortschritt (bei mehreren
|
||||
Sammlern in deren Farben), Teile, Wert, nächster Wunschlisten-Termin mit
|
||||
Countdown und Abkürzungen.
|
||||
- Neues Logo als Baustein, Schriften lokal gebündelt (kein CDN).
|
||||
|
||||
### Geändert
|
||||
|
||||
- Karten, Buttons und Badges tragen im Standard-Design Konturen, eine feste
|
||||
Unterkante und Noppen. In der Sammlung und auf der öffentlichen
|
||||
Wunschliste zeigen die Noppen den Sammler bzw. ob ein Wunsch reserviert ist.
|
||||
- Die öffentliche Wunschliste hat einen farbigen Kopf mit Anlass und Datum.
|
||||
- Screenshots der Anleitung zeigen die feste Navigation nicht mehr mitten
|
||||
im Bild.
|
||||
|
||||
## [0.3.0] - 2026-09-07
|
||||
|
||||
Phase 3: mehrere Kinder, Wertentwicklung, Erinnerungen und ein Schutz vor
|
||||
doppelten Katalogeintraegen.
|
||||
|
||||
### Hinzugefuegt
|
||||
|
||||
- Projekt-Scaffold: Next.js 15 (App Router, TypeScript strict),
|
||||
Tailwind CSS 4, shadcn/ui, pnpm.
|
||||
- Vollstaendiges Datenmodell in Drizzle ORM mit initialer Migration:
|
||||
Katalog, Besitz, Wunschlisten und die Auth-Tabellen von better-auth.
|
||||
- SQLite-Anbindung mit WAL-Modus, aktivierten Fremdschluesseln und
|
||||
Migrations-Runner (`pnpm db:migrate`).
|
||||
- Projektdokumentation: CLAUDE.md, Roadmap, Changelog, Devlog.
|
||||
**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.
|
||||
|
||||
[Unreleased]: https://git.wilde-server.de/florian.wilde/bricktrack/compare/main...HEAD
|
||||
**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
|
||||
|
||||
Erste lauffaehige Fassung: Phase 1 vollstaendig, im Docker-Stack verifiziert.
|
||||
|
||||
### Hinzugefuegt
|
||||
|
||||
**Anmeldung**
|
||||
- Registrierung des ersten Nutzers, der damit Administrator wird; danach ist
|
||||
die Registrierung geschlossen.
|
||||
- Anmeldung per E-Mail und Passwort oder per Passkey (WebAuthn), inklusive
|
||||
Conditional UI, wo der Browser sie unterstuetzt.
|
||||
- Passkey-Verwaltung unter "Konto": beliebig viele Geraete, umbenennen,
|
||||
loeschen.
|
||||
- Rate-Limiting auf den Login-Endpunkten, in der Datenbank abgelegt und damit
|
||||
neustartfest.
|
||||
|
||||
**Katalog**
|
||||
- Set-Katalog mit Kachelansicht, Suche ueber Setnummer, Name und EAN sowie
|
||||
Filtern nach Hersteller, Thema, Jahr und Besitzstatus.
|
||||
- Sets anlegen und bearbeiten, mit Bild-Upload oder Bild-URL.
|
||||
- Set-Detailseite mit Metadaten, Anleitungen, Fotos und Sammlungseintraegen.
|
||||
- Anleitungen als PDF-Upload mit eingebautem pdf.js-Viewer oder als externer
|
||||
Link; mehrere Anleitungen je Set.
|
||||
- Eigene Fotos je Set, am Handy direkt aus der Kamera.
|
||||
|
||||
**Sammlung**
|
||||
- Uebersicht aller besessenen Sets mit Filtern nach Status, Vollstaendigkeit,
|
||||
Standort und Hersteller.
|
||||
- Statistik-Kacheln: Anzahl Sets, Teile gesamt, Wert nach UVP, fehlende Sets,
|
||||
sowie die Aufteilung nach Hersteller.
|
||||
- Sammlungseintraege mit Status, Zustand, Standort, Kaufdatum, Kaufpreis und
|
||||
"geschenkt von".
|
||||
|
||||
**Wunschlisten**
|
||||
- Ansicht "Fehlende Sets" mit Mehrfachauswahl: langes Druecken startet die
|
||||
Auswahl, die Aktionsleiste uebernimmt sie in eine bestehende oder neue
|
||||
Wunschliste. Dubletten und bereits besessene Sets werden uebersprungen und
|
||||
im Ergebnis benannt.
|
||||
- Wunschlisten mit Prioritaet, Reihenfolge, Anlass, Termin und Hinweistext.
|
||||
- Bereich "Aus fehlenden Sets hinzufuegen" auf der Wunschlisten-Seite,
|
||||
vorgefiltert auf Sets, die noch nicht drauf sind.
|
||||
- Oeffentliche Ansicht unter /w/[shareToken] ohne Anmeldung; Besucher
|
||||
reservieren mit ihrem Namen und erhalten einen Storno-Link.
|
||||
- Ueberraschungsmodus: der Besitzer sieht nur "reserviert", nicht von wem.
|
||||
- Erfuellte Wuensche werden automatisch markiert, sobald das Set in der
|
||||
Sammlung landet, und verschwinden aus der oeffentlichen Ansicht.
|
||||
- Share-Token laesst sich neu erzeugen; der alte Link ist danach tot.
|
||||
|
||||
**Stammdaten**
|
||||
- Hersteller, Themen (mit Unterthemen) und Standorte (schachtelbar) verwalten.
|
||||
|
||||
**Betrieb**
|
||||
- Docker-Stack: Multi-Stage-Build mit standalone-Output, non-root Nutzer,
|
||||
Healthcheck unter /api/health, ein Service und ein Bind-Mount auf ./data.
|
||||
- Migrationen laufen bei jedem Containerstart automatisch.
|
||||
- update.sh mit Sicherung, Build, Healthcheck und automatischem Rollback.
|
||||
- Seed-Skript mit 3 Herstellern, 6 Themen, 10 Sets, 4 Sammlungseintraegen und
|
||||
einer Wunschliste.
|
||||
- Dokumentation: Schnellstart inklusive Nginx-Proxy-Manager-Konfiguration,
|
||||
bebilderte Anleitung, README, Roadmap und Devlog.
|
||||
|
||||
### Sicherheit
|
||||
|
||||
- Serverseitige Validierung aller Eingaben mit zod.
|
||||
- Uploads auf PDF, JPEG, PNG und WebP begrenzt, geprueft an den Magic Bytes
|
||||
statt am gemeldeten MIME-Typ; Dateinamen erzeugt immer der Server.
|
||||
- Oeffentliche Wunschlisten-Routen nutzen ausschliesslich den Share-Token und
|
||||
geben nichts ausserhalb der Wunschliste preis; die Seite ist noindex.
|
||||
- Storno- und Einladungs-Token liegen nur als SHA-256-Hash in der Datenbank.
|
||||
- Session-Cookies httpOnly, sameSite=lax und im Betrieb secure.
|
||||
- Anleitungs-PDFs und eigene Fotos sind nur mit Session abrufbar; oeffentlich
|
||||
sind allein die Set-Bilder, die die geteilte Wunschliste braucht.
|
||||
|
||||
### Bekannte Einschraenkungen
|
||||
|
||||
- Der Katalog enthaelt nur, was von Hand angelegt wurde. Der Import ueber
|
||||
Rebrickable kommt in 0.2.0.
|
||||
- Weitere Konten sind noch nicht moeglich; Einladungslinks kommen in 0.2.0.
|
||||
- Unter Windows scheitert `pnpm build` an fehlenden Symlink-Rechten. Der
|
||||
Docker-Build unter Linux ist davon nicht betroffen.
|
||||
|
||||
[Unreleased]: https://git.wilde-server.de/florian.wilde/bricktrack/compare/v0.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
|
||||
|
||||
@@ -19,6 +19,9 @@ teilbare Wunschlisten.
|
||||
| Validierung | zod (serverseitig, immer) | |
|
||||
| Bilder | sharp (nur serverseitig, Thumbnails) | |
|
||||
| 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 | |
|
||||
|
||||
Bewusst **kein** Prisma (Standalone-Build im Container), **keine**
|
||||
@@ -33,9 +36,9 @@ Bild-URLs, die der Nutzer selbst hinterlegt hat.
|
||||
(`retail_price_cents`, `acquired_price_cents`). SQLite hat keinen
|
||||
Decimal-Typ; Float-Summen waeren ungenau. Umrechnung ausschliesslich
|
||||
in `src/lib/money.ts`.
|
||||
- Datenbankspalten in `snake_case` - **ausser** in den Auth-Tabellen,
|
||||
die `camelCase` verwenden, weil das exakt der Ausgabe von
|
||||
`@better-auth/cli generate` entspricht.
|
||||
- Alle Datenbankspalten in `snake_case`. Auch in den Auth-Tabellen -
|
||||
der Drizzle-Adapter von better-auth loest Modelle ueber die
|
||||
TypeScript-Property-Namen auf, nicht ueber die Spaltennamen.
|
||||
- IDs sind Text-UUIDs (`crypto.randomUUID()`), Zeitstempel sind
|
||||
`integer(mode: 'timestamp')`.
|
||||
- Routen sind deutsch (`/katalog`, `/sammlung`, `/wunschlisten`),
|
||||
@@ -43,6 +46,39 @@ Bild-URLs, die der Nutzer selbst hinterlegt hat.
|
||||
- Commits: Conventional Commits, Betreff auf Deutsch, Imperativ,
|
||||
erste Zeile <= 72 Zeichen.
|
||||
|
||||
## Wichtige Module
|
||||
|
||||
| Datei | Zustaendig fuer |
|
||||
|---|---|
|
||||
| `src/lib/ownership.ts` | die eine Definition von "hat er" / "hat er nicht" |
|
||||
| `src/lib/wishlist-logic.ts` | Mehrfachauswahl, Dubletten, Prioritaeten (rein, testbar) |
|
||||
| `src/lib/tokens.ts` | Share-, Storno- und Einladungs-Token |
|
||||
| `src/lib/invites.ts` | Einladungen inklusive der Freigabe fuer die Registrierung |
|
||||
| `src/lib/rebrickable.ts` | Lego-Import, ausschliesslich serverseitig |
|
||||
| `src/lib/set-number.ts`, `src/lib/ean.ts` | Setnummern und Barcodes (rein, testbar) |
|
||||
| `src/lib/uploads.ts` | Datei-Uploads, Magic Bytes, Thumbnails |
|
||||
| `src/lib/similarity.ts` | Namensvergleich fuer die Dublettenpruefung (rein) |
|
||||
| `src/lib/prices.ts` | Wertentwicklung aus notierten Preisen (rein) |
|
||||
| `src/lib/reminders.ts` | Regeln, wann eine Erinnerung faellig ist (rein) |
|
||||
| `src/lib/reminder-job.ts`, `src/lib/mailer.ts` | Versand und Zeitplan |
|
||||
| `src/lib/mail-settings.ts`, `src/lib/secret-box.ts` | SMTP aus .env oder App (`app_setting`), Passwort verschluesselt |
|
||||
| `src/lib/export.ts` | Export und Import des gesamten Bestands |
|
||||
| `src/lib/money.ts`, `src/lib/format.ts` | Cent-Umrechnung, dd.MM.yyyy |
|
||||
| `src/components/layout/more-items.ts` | Eintraege der Seite „Mehr" (Konto, Design, Stammdaten), auch Titel der Verwaltungsseiten |
|
||||
| `src/lib/collector-choice.ts` | Aktiver Sammler: `?sammler=` vor Cookie aus dem Umschalter „Fuer wen?" |
|
||||
| `src/lib/link-import.ts` | Set-Daten aus Link und Seite lesen (rein, testbar) |
|
||||
| `src/lib/safe-fetch.ts`, `src/lib/net-guard.ts` | Abruf fremder URLs nur an oeffentliche Adressen |
|
||||
| `src/lib/design.ts`, `src/app/designs.css` | Designs: Namen und Cookie bzw. Tokens je `data-design` |
|
||||
| `src/components/brick/` | Noppen, Logo, Platzhalter fuer Sets ohne Bild |
|
||||
|
||||
### Designs
|
||||
|
||||
Das Design steht als `data-design` am `<html>` (Cookie `bricktrack-design`,
|
||||
pro Geraet, Standard `klemmbaustein`). Komponenten benutzen nur Tokens
|
||||
(`bg-primary`, `border-edge`, `--edge-w`, `--lift`, `bg-tone-1` ...), nie feste
|
||||
Farben - sonst stimmt ein Design nicht mehr. Karten mit Noppen: `<Studs />`
|
||||
neben die Karte in einen `relative`-Wrapper, die Karte selbst mit `brick`.
|
||||
|
||||
## Befehle
|
||||
|
||||
```bash
|
||||
@@ -58,8 +94,22 @@ pnpm db:studio # Drizzle Studio
|
||||
pnpm test # Vitest
|
||||
pnpm test:e2e # Playwright
|
||||
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
|
||||
|
||||
Kernregel: Ein **`set`** ist ein *Katalogeintrag* - es existiert,
|
||||
@@ -83,8 +133,16 @@ Diese Bedingung steht an genau einer Stelle: `src/lib/ownership.ts`.
|
||||
|
||||
### Besitz
|
||||
|
||||
Mehrere **Sammler** teilen sich den Katalog, nicht den Besitz: `collector`
|
||||
haelt Name und Farbe, `collection_item.collector_id` und
|
||||
`wishlist.collector_id` ordnen zu. Solange nur ein Sammler existiert, blendet
|
||||
die Oberflaeche das Thema vollstaendig aus - Umschalter, Abfragen und Badges
|
||||
erscheinen erst ab dem zweiten.
|
||||
|
||||
| Tabelle | Zweck | Wichtige Felder |
|
||||
|---|---|---|
|
||||
| `collector` | ein Kind | `name`, `color`, `birthday`, `sort_order` |
|
||||
| `price_point` | beobachteter Preis je Set | `price_cents`, `condition`, `observed_at`, `source` |
|
||||
| `collection_item` | ein besessenes Exemplar | `status` = `owned` / `built` / `disassembled` / `lent_out` / `sold`, `completeness`, `condition`, `location_id`, `acquired_*`, `gifted_by` |
|
||||
| `location` | Aufbewahrungsort, hierarchisch | `parent_location_id` |
|
||||
| `missing_part` | Fehlteile je Exemplar | `part_number`, `color_name`, `quantity`, `resolved` |
|
||||
@@ -142,18 +200,28 @@ Session.
|
||||
nichts preis ausser den Wunschlisten-Daten - keine Nutzernamen,
|
||||
keine Sammlungsdaten, keine IDs anderer Objekte.
|
||||
4. Externe API-Aufrufe (Rebrickable, ab Phase 2) laufen ausschliesslich
|
||||
serverseitig.
|
||||
serverseitig. URLs, die ein Nutzer eingibt (Link-Import, Bild-Uebernahme),
|
||||
werden nur ueber `safeFetch` abgerufen - nie mit `fetch` direkt, sonst
|
||||
erreicht der Server auch Geraete im Heimnetz.
|
||||
5. Session-Cookies: `httpOnly`, `secure`, `sameSite=lax`.
|
||||
Rate-Limiting auf den Login-Endpunkten.
|
||||
6. Der Export enthaelt **keine** Konten, Passwoerter oder Passkeys - und
|
||||
keine Reservierungen, Teilen-Token oder `app_setting` (SMTP-Zugang). Das Backup ist `./data`, nicht der
|
||||
Export.
|
||||
7. Der Service Worker beantwortet nur GET-Anfragen aus dem Cache und niemals
|
||||
`/api/auth`, `/api/export` oder den Healthcheck. Beim Abmelden wird der
|
||||
Cache geleert.
|
||||
|
||||
## Phasenplan
|
||||
|
||||
- **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,
|
||||
Einladungen, Export/Import, PWA.
|
||||
Einladungen, Export/Import, PWA. *Fertig in 0.2.0.*
|
||||
- **Phase 3 (Nice to have)** - mehrere Sammler, Preisbeobachtung,
|
||||
E-Mail-Erinnerungen, Dublettenpruefung.
|
||||
E-Mail-Erinnerungen, Dublettenpruefung. *Fertig in 0.3.0.*
|
||||
- **Design und Sicherheit** - Designauswahl (Standard Klemmbaustein),
|
||||
Abhaengigkeiten ohne bekannte Luecken. *Fertig in 0.4.0.*
|
||||
|
||||
Details und Stand: `docs/ROADMAP.md`. Zeitaufwaende: `docs/DEVLOG.md`.
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bricktrack - Multi-Stage-Build
|
||||
#
|
||||
# Ergebnis ist ein schlankes Runtime-Image mit Next.js im Standalone-Modus,
|
||||
# das als Nutzer "node" laeuft. Alle Daten liegen ausserhalb des Images unter
|
||||
# /data, damit ein Update nichts anfasst, was dir gehoert.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
FROM node:24-alpine AS base
|
||||
ENV PNPM_HOME=/pnpm
|
||||
ENV PATH=$PNPM_HOME:$PATH
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
RUN corepack enable
|
||||
|
||||
# --- Abhaengigkeiten -------------------------------------------------------
|
||||
# better-sqlite3 und sharp bringen Prebuilds mit; die Build-Werkzeuge sind der
|
||||
# Rueckfallweg, falls fuer die Plattform keines existiert.
|
||||
FROM base AS deps
|
||||
RUN apk add --no-cache libc6-compat python3 make g++
|
||||
WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
# --- Build -----------------------------------------------------------------
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
# Platzhalter nur fuer den Build: src/env.ts validiert beim Import, und die
|
||||
# echten Werte kommen zur Laufzeit aus der .env. Keiner dieser Werte landet im
|
||||
# Image-Zustand, weil keine Seite vorgerendert wird.
|
||||
ENV NODE_ENV=production
|
||||
ENV BASE_URL=http://localhost:3000
|
||||
ENV BETTER_AUTH_SECRET=build-platzhalter-0123456789-abcdefghij
|
||||
ENV DATA_DIR=/tmp/build-data
|
||||
|
||||
RUN pnpm build
|
||||
|
||||
# Der Migrations-Runner wird zu einer einzelnen Datei gebuendelt. So braucht
|
||||
# das Runtime-Image kein drizzle-kit und kein tsx - nur das native
|
||||
# better-sqlite3, das ohnehin dabei ist. Das Buendel ist CommonJS, weil
|
||||
# mitgebuendelte CJS-Abhaengigkeiten sonst am require() der Node-Builtins
|
||||
# scheitern.
|
||||
RUN pnpm exec esbuild scripts/migrate.ts \
|
||||
--bundle \
|
||||
--platform=node \
|
||||
--format=cjs \
|
||||
--target=node22 \
|
||||
--external:better-sqlite3 \
|
||||
--outfile=dist/migrate.cjs
|
||||
|
||||
# --- Laufzeit --------------------------------------------------------------
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
ENV DATA_DIR=/data
|
||||
ENV MIGRATIONS_DIR=/app/drizzle
|
||||
|
||||
# Standalone bringt seine eigenen node_modules mit - kein pnpm install noetig.
|
||||
COPY --from=builder --chown=node:node /app/.next/standalone ./
|
||||
COPY --from=builder --chown=node:node /app/.next/static ./.next/static
|
||||
COPY --from=builder --chown=node:node /app/public ./public
|
||||
COPY --from=builder --chown=node:node /app/drizzle ./drizzle
|
||||
COPY --from=builder --chown=node:node /app/dist/migrate.cjs ./migrate.cjs
|
||||
COPY --chown=node:node docker/entrypoint.sh /app/entrypoint.sh
|
||||
RUN chmod +x /app/entrypoint.sh
|
||||
|
||||
# Anlaufstelle fuer Datenbank und Uploads. Wird per Bind-Mount ueberdeckt;
|
||||
# das Anlegen hier sorgt nur dafuer, dass der Pfad auch ohne Mount existiert.
|
||||
RUN mkdir -p /data/db /data/files && chown -R node:node /data
|
||||
|
||||
USER node
|
||||
EXPOSE 3000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
CMD wget -q --spider http://127.0.0.1:3000/api/health || exit 1
|
||||
|
||||
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||
CMD ["node", "server.js"]
|
||||
@@ -0,0 +1,211 @@
|
||||
# Bricktrack – Schnellstart
|
||||
|
||||
Von null auf laufende Instanz. Zeitbedarf: etwa 15 Minuten, davon 10 für den
|
||||
ersten Docker-Build.
|
||||
|
||||
**Voraussetzungen:** Ein Linux-Host (z. B. eine LXC oder VM auf Proxmox) mit
|
||||
Docker und dem Compose-Plugin, ein Reverse Proxy mit gültigem TLS-Zertifikat
|
||||
und eine Subdomain, die auf den Proxy zeigt.
|
||||
|
||||
> **Warum HTTPS Pflicht ist:** Passkeys (WebAuthn) funktionieren
|
||||
> ausschließlich über HTTPS und sind fest an den Hostnamen gebunden. Ohne
|
||||
> Zertifikat bleibt nur die Anmeldung per Passwort. Wird die Domain später
|
||||
> geändert, sind **alle** Passkeys ungültig und müssen neu angelegt werden.
|
||||
|
||||
---
|
||||
|
||||
## 1. Projekt holen
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /opt/bricktrack
|
||||
sudo chown "$USER" /opt/bricktrack
|
||||
git clone https://git.wilde-server.de/florian.wilde/bricktrack.git /opt/bricktrack
|
||||
cd /opt/bricktrack
|
||||
```
|
||||
|
||||
## 2. Konfiguration anlegen
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Jetzt `.env` öffnen und zwei Werte setzen:
|
||||
|
||||
```bash
|
||||
# Die Adresse, unter der du die App im Browser aufrufst.
|
||||
BASE_URL=https://bricktrack.example.de
|
||||
|
||||
# Signiergeheimnis - hiermit erzeugen:
|
||||
# openssl rand -base64 48
|
||||
BETTER_AUTH_SECRET=<dein-erzeugter-wert>
|
||||
```
|
||||
|
||||
Alles andere kann so bleiben. `DATA_DIR=/data` ist der Pfad **im Container**
|
||||
und wird nicht angefasst.
|
||||
|
||||
## 3. Datenverzeichnis vorbereiten
|
||||
|
||||
Der Container läuft aus Sicherheitsgründen nicht als root, sondern als Nutzer
|
||||
mit der UID 1000. Das gemountete Verzeichnis muss ihm gehören:
|
||||
|
||||
```bash
|
||||
mkdir -p ./data
|
||||
sudo chown -R 1000:1000 ./data
|
||||
```
|
||||
|
||||
Diesen Schritt vergisst man gern – der Container sagt dir dann aber beim Start
|
||||
genau das.
|
||||
|
||||
## 4. Starten
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Der erste Build dauert einige Minuten. Danach:
|
||||
|
||||
```bash
|
||||
docker compose ps # Status "healthy" abwarten
|
||||
docker compose logs -f app # Log mitlesen, mit Strg+C beenden
|
||||
curl -s http://127.0.0.1:3000/api/health
|
||||
# {"status":"ok","time":"..."}
|
||||
```
|
||||
|
||||
## 5. Reverse Proxy einrichten (Nginx Proxy Manager)
|
||||
|
||||
In NPM unter **Hosts → Proxy Hosts → Add Proxy Host**:
|
||||
|
||||
**Reiter „Details"**
|
||||
|
||||
| Feld | Wert |
|
||||
|---|---|
|
||||
| Domain Names | `bricktrack.example.de` |
|
||||
| Scheme | `http` |
|
||||
| Forward Hostname / IP | IP des Docker-Hosts, z. B. `192.168.1.50` |
|
||||
| Forward Port | `3000` |
|
||||
| Cache Assets | **aus** (Next.js setzt seine Cache-Header selbst) |
|
||||
| Block Common Exploits | an |
|
||||
| Websockets Support | **an** |
|
||||
|
||||
**Reiter „SSL"**
|
||||
|
||||
| Feld | Wert |
|
||||
|---|---|
|
||||
| SSL Certificate | Let's Encrypt anfordern oder vorhandenes wählen |
|
||||
| Force SSL | **an** |
|
||||
| HTTP/2 Support | an |
|
||||
| HSTS Enabled | an (optional, aber sinnvoll) |
|
||||
|
||||
**Reiter „Advanced"** – hier hinein, sonst scheitern große Anleitungs-PDFs am
|
||||
Standardlimit von 1 MB:
|
||||
|
||||
```nginx
|
||||
# Anleitungs-PDFs koennen mehrere Megabyte gross sein.
|
||||
client_max_body_size 64m;
|
||||
|
||||
# Uploads und PDF-Auslieferung duerfen etwas dauern.
|
||||
proxy_read_timeout 300;
|
||||
proxy_send_timeout 300;
|
||||
|
||||
# Die App leitet ihre oeffentliche Adresse und die Client-IP hieraus ab.
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
```
|
||||
|
||||
`client_max_body_size` muss mindestens so groß sein wie `MAX_UPLOAD_MB` in
|
||||
deiner `.env` (Standard 25 MB); 64m gibt Luft.
|
||||
|
||||
> **Wo lauscht der Container?** Voreinstellung ist `127.0.0.1:3000` – die App
|
||||
> ist dann ausschließlich über einen Proxy auf demselben Host erreichbar.
|
||||
> Zwei Fälle brauchen eine Anpassung in der `.env`:
|
||||
>
|
||||
> - **NPM läuft auf einem anderen Host:** `BIND_ADDRESS=0.0.0.0` setzen und
|
||||
> den Zugriff per Firewall auf die Proxy-IP beschränken.
|
||||
> - **Port 3000 ist schon belegt:** `HOST_PORT=3001` (o. ä.) setzen und im
|
||||
> Proxy denselben Port eintragen.
|
||||
>
|
||||
> **Läuft NPM im selben Docker-Netz?** Dann brauchst du gar kein
|
||||
> Port-Mapping: die `ports`-Zeile in `docker-compose.yml` entfernen, beide
|
||||
> Container in ein gemeinsames Netzwerk hängen und als Forward Hostname den
|
||||
> Container-Namen `bricktrack` mit Port `3000` eintragen.
|
||||
|
||||
## 6. Ersten Nutzer anlegen
|
||||
|
||||
`https://bricktrack.example.de` aufrufen. Die App leitet direkt auf die
|
||||
Registrierung.
|
||||
|
||||
**Der erste registrierte Nutzer wird automatisch Administrator.** Danach ist
|
||||
die Registrierung geschlossen – weitere Konten entstehen nur über einen
|
||||
Einladungslink (ab Version 0.2.0).
|
||||
|
||||
Also: gleich beim ersten Aufruf selbst registrieren, bevor es jemand anderes
|
||||
tut.
|
||||
|
||||
## 7. Passkey hinterlegen
|
||||
|
||||
Unter **Mehr → Konto & Passkeys → Passkey hinzufügen** (der Button „Mehr" oben rechts). Ab dann meldest du dich per
|
||||
Fingerabdruck, Gesichtserkennung oder Geräte-PIN an. Für jedes Gerät ein
|
||||
eigener Passkey – Handy, Tablet, Laptop.
|
||||
|
||||
## 8. Loslegen
|
||||
|
||||
1. **Mehr → Hersteller**: Lego, Playmobil, Cobi … anlegen.
|
||||
2. **Mehr → Themen**: „City", „Technic", „Novelmore" …
|
||||
3. **Mehr → Standorte**: „Regal Kinderzimmer", „Kiste Keller 3" …
|
||||
4. **+ in der Navigation → Von Hand**: Setnummer, Name, Bild.
|
||||
5. Auf der Set-Seite **„Zur Sammlung hinzufügen"**, wenn das Set da ist.
|
||||
6. **Katalog → Filter „Fehlt noch"**: Kachel lange drücken, mehrere auswählen,
|
||||
**„Zur Wunschliste hinzufügen"**.
|
||||
7. **Wunschlisten → Liste öffnen → Link kopieren** und verschicken.
|
||||
|
||||
Wie das im Einzelnen aussieht, zeigt die [bebilderte
|
||||
Anleitung](docs/ANLEITUNG.md).
|
||||
|
||||
> Beispieldaten (`pnpm db:seed`) gibt es nur in der Entwicklungsumgebung – das
|
||||
> Produktions-Image bringt sie bewusst nicht mit, damit in deiner Sammlung
|
||||
> nichts steht, was dir nicht gehört.
|
||||
|
||||
---
|
||||
|
||||
## Sichern
|
||||
|
||||
Alles, was zählt, liegt in **einem** Verzeichnis:
|
||||
|
||||
```bash
|
||||
docker compose stop app
|
||||
tar czf bricktrack-$(date +%F).tar.gz data .env
|
||||
docker compose start app
|
||||
```
|
||||
|
||||
Zurückspielen:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
tar xzf bricktrack-2026-09-06.tar.gz
|
||||
sudo chown -R 1000:1000 ./data
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## Aktualisieren
|
||||
|
||||
```bash
|
||||
./update.sh
|
||||
```
|
||||
|
||||
Das Skript sichert vorher automatisch, baut neu, wartet auf den Healthcheck
|
||||
und rollt bei einem Fehlschlag zurück. Details in der [README](README.md).
|
||||
|
||||
---
|
||||
|
||||
## Wenn etwas klemmt
|
||||
|
||||
| Symptom | Ursache und Lösung |
|
||||
|---|---|
|
||||
| `FEHLER: In /data darf nicht geschrieben werden` | `sudo chown -R 1000:1000 ./data` |
|
||||
| Container startet, aber `unhealthy` | `docker compose logs app` – meist eine fehlende Variable in `.env` |
|
||||
| `Ungueltige Umgebungsvariablen: BETTER_AUTH_SECRET` | Das Geheimnis braucht mindestens 32 Zeichen |
|
||||
| Passkey lässt sich nicht anlegen | Kein HTTPS, oder `BASE_URL` passt nicht zur aufgerufenen Domain |
|
||||
| Passkeys funktionieren nach einem Domainwechsel nicht mehr | Erwartet – Passkeys sind an die Domain gebunden, neu anlegen |
|
||||
| `413 Request Entity Too Large` beim PDF-Upload | `client_max_body_size` im Proxy erhöhen (Schritt 5) |
|
||||
| Anmeldung meldet „Zu viele Versuche" | Rate-Limit: fünf Fehlversuche pro Minute, danach kurz warten |
|
||||
@@ -1,36 +1,244 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
# Bricktrack
|
||||
|
||||
## Getting Started
|
||||
Self-hosted Verwaltung für die Spielzeug-Set-Sammlung deines Kindes – Lego,
|
||||
Playmobil, Cobi, BlueBrixx, Mega Bloks und jeden weiteren Hersteller, den du
|
||||
selbst anlegst.
|
||||
|
||||
First, run the development server:
|
||||
Die App beantwortet zwei Fragen sofort:
|
||||
|
||||
- **Welche Sets hat er?** → die Sammlung, mit Standort, Zustand und Statistik.
|
||||
- **Welche Sets hat er noch nicht?** → alles im Katalog ohne Sammlungseintrag.
|
||||
|
||||
Aus den fehlenden Sets entsteht in wenigen Handgriffen eine Wunschliste, die
|
||||
du per Link mit Familie und Freunden teilst. Die reservieren dort ihr Geschenk
|
||||
– **ohne sich zu registrieren**, und ohne dass jemand doppelt kauft.
|
||||
|
||||
> **Neu hier?** → [QUICKSTART.md](QUICKSTART.md) führt dich in 15 Minuten von
|
||||
> null zur laufenden Instanz, inklusive Nginx Proxy Manager.
|
||||
> **Bedienung?** → [docs/ANLEITUNG.md](docs/ANLEITUNG.md), bebildert.
|
||||
|
||||
---
|
||||
|
||||
## Was drin ist
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Katalog** | Sets aller Hersteller, Suche nach Nummer, Name oder EAN, Filter nach Hersteller, Thema, Jahr und Besitzstatus |
|
||||
| **Sammlung** | Was tatsächlich da ist: Status, Vollständigkeit, Zustand, Standort, Kaufpreis, „geschenkt von" |
|
||||
| **Anleitungen** | PDFs hochladen und im Browser blättern (pdf.js, ohne externe Dienste) oder auf die Hersteller-Seite verlinken |
|
||||
| **Fotos** | Eigene Bilder je Set – direkt aus der Handykamera |
|
||||
| **Wunschlisten** | Mehrfachauswahl aus den fehlenden Sets, Priorität, Reihenfolge, Termin |
|
||||
| **Teilen** | Öffentlicher Link ohne Anmeldung, Reservierung mit Namen, Storno-Link, Überraschungsmodus |
|
||||
| **Mehrere Kinder** | Gemeinsamer Katalog, getrennte Sammlungen und Wunschlisten – sichtbar erst ab dem zweiten Sammler |
|
||||
| **Preise** | Notierte Preise je Set, Wertentwicklung gegen die UVP |
|
||||
| **Erinnerungen** | E-Mail, wenn ein Termin näher rückt und noch Wünsche offen sind (SMTP optional) |
|
||||
| **Anmeldung** | E-Mail und Passwort oder Passkey (WebAuthn), mehrere Geräte je Konto |
|
||||
|
||||
Mobil zuerst gebaut: Die App wird im Laden und im Kinderzimmer benutzt, nicht
|
||||
am Schreibtisch.
|
||||
|
||||
## Technik
|
||||
|
||||
Next.js 15 (App Router, Server Actions, TypeScript strict) · Tailwind CSS 4 ·
|
||||
shadcn/ui · SQLite über Drizzle ORM und better-sqlite3 · better-auth mit
|
||||
Passkey-Plugin · alles in **einem** Docker-Container.
|
||||
|
||||
Keine Cloud, keine externen Dienste, keine Telemetrie. Der Browser lädt nichts
|
||||
von fremden Servern außer den Bild-URLs, die du selbst hinterlegt hast.
|
||||
|
||||
Details zu Stack, Konventionen und Datenmodell: [CLAUDE.md](CLAUDE.md).
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
Ausführlich in [QUICKSTART.md](QUICKSTART.md). Die Kurzfassung:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
git clone https://git.wilde-server.de/florian.wilde/bricktrack.git
|
||||
cd bricktrack
|
||||
|
||||
cp .env.example .env
|
||||
# BASE_URL und BETTER_AUTH_SECRET setzen:
|
||||
# openssl rand -base64 48
|
||||
|
||||
mkdir -p ./data
|
||||
sudo chown -R 1000:1000 ./data # der Container laeuft als UID 1000
|
||||
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
Danach `https://deine-domain` aufrufen. **Der erste registrierte Nutzer wird
|
||||
Administrator**, danach ist die Registrierung geschlossen.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
### Konfiguration
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
Alle Werte kommen aus `.env` (Vorlage: `.env.example`).
|
||||
|
||||
## Learn More
|
||||
| Variable | Pflicht | Standard | Bedeutung |
|
||||
|---|---|---|---|
|
||||
| `BASE_URL` | ja | `http://localhost:3000` | Öffentliche Adresse. Bestimmt die Passkey-Domain (RP-ID). |
|
||||
| `BETTER_AUTH_SECRET` | ja | – | Signiergeheimnis, mindestens 32 Zeichen. |
|
||||
| `DATA_DIR` | nein | `/data` | Datenverzeichnis im Container. Nicht ändern. |
|
||||
| `MAX_UPLOAD_MB` | nein | `25` | Größenlimit je Datei. Muss zum Proxy passen. |
|
||||
| `TZ` | nein | `Europe/Berlin` | Zeitzone des Containers. |
|
||||
| `BIND_ADDRESS` | nein | `127.0.0.1` | Auf welcher Host-Adresse der Container lauscht. `0.0.0.0`, wenn der Proxy auf einem anderen Host läuft. |
|
||||
| `HOST_PORT` | nein | `3000` | Port auf dem Host, falls 3000 belegt ist. |
|
||||
| `REBRICKABLE_API_KEY` | nein | – | 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. |
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
### Reverse Proxy
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
TLS wird außerhalb terminiert, der Container spricht nur HTTP auf Port 3000.
|
||||
Drei Dinge sind wichtig:
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
1. **HTTPS mit Force-SSL** – ohne das keine Passkeys.
|
||||
2. **`client_max_body_size`** mindestens so groß wie `MAX_UPLOAD_MB`, sonst
|
||||
scheitern große Anleitungs-PDFs mit „413".
|
||||
3. **`X-Forwarded-For`** durchreichen, damit das Rate-Limit auf dem
|
||||
Login-Endpunkt die echte Client-IP sieht.
|
||||
|
||||
## Deploy on Vercel
|
||||
Die vollständige Nginx-Proxy-Manager-Konfiguration steht in
|
||||
[QUICKSTART.md, Schritt 5](QUICKSTART.md#5-reverse-proxy-einrichten-nginx-proxy-manager).
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
---
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
## Backup und Restore
|
||||
|
||||
**Ein Verzeichnis reicht.** `./data` enthält die Datenbank *und* alle
|
||||
hochgeladenen Dateien:
|
||||
|
||||
```
|
||||
data/
|
||||
db/bricktrack.db SQLite (dazu -wal und -shm)
|
||||
files/images/sets/… Set-Bilder
|
||||
files/photos/… eigene Fotos
|
||||
files/instructions/… Anleitungs-PDFs
|
||||
```
|
||||
|
||||
**Sichern** (Container kurz anhalten, damit die Datenbank nicht mitten im
|
||||
Schreiben eingepackt wird):
|
||||
|
||||
```bash
|
||||
docker compose stop app
|
||||
tar czf bricktrack-$(date +%F).tar.gz data .env
|
||||
docker compose start app
|
||||
```
|
||||
|
||||
**Zurückspielen:**
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
rm -rf data
|
||||
tar xzf bricktrack-2026-09-06.tar.gz
|
||||
sudo chown -R 1000:1000 ./data
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Migrationen laufen bei jedem Start automatisch – ein altes Backup wird beim
|
||||
Zurückspielen also auf den aktuellen Schemastand gehoben.
|
||||
|
||||
## Aktualisieren
|
||||
|
||||
```bash
|
||||
./update.sh
|
||||
```
|
||||
|
||||
Das Skript sichert `./data` und `.env`, holt den neuen Stand, baut das Image,
|
||||
startet neu und wartet auf den Healthcheck. Wird der Container nicht gesund,
|
||||
rollt es Code **und** Container automatisch auf den vorherigen Commit zurück
|
||||
und sagt dir, wo die Sicherung liegt.
|
||||
|
||||
```bash
|
||||
./update.sh --no-pull # nur neu bauen, ohne git pull
|
||||
./update.sh --no-backup # ohne Sicherung (nicht empfohlen)
|
||||
```
|
||||
|
||||
Die letzten zehn Sicherungen bleiben unter `./backups` liegen.
|
||||
|
||||
---
|
||||
|
||||
## Entwicklung
|
||||
|
||||
```bash
|
||||
corepack enable pnpm # pnpm 10
|
||||
pnpm install
|
||||
|
||||
cp .env.example .env # DATA_DIR=./data, BASE_URL=http://localhost:3000
|
||||
pnpm db:migrate # Datenbank anlegen
|
||||
pnpm db:seed # Beispieldaten: 3 Hersteller, 10 Sets, 1 Wunschliste
|
||||
pnpm dev # http://localhost:3000
|
||||
```
|
||||
|
||||
| Befehl | Wirkung |
|
||||
|---|---|
|
||||
| `pnpm dev` | Entwicklungsserver |
|
||||
| `pnpm build` | Produktionsbuild (standalone) |
|
||||
| `pnpm lint` / `pnpm typecheck` | ESLint / TypeScript – beide müssen sauber sein |
|
||||
| `pnpm test` | Unit-Tests (Vitest) |
|
||||
| `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:migrate` | Migrationen anwenden |
|
||||
| `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
|
||||
> Standalone-Output legt Symlinks an, wofür Windows den Entwicklermodus oder
|
||||
> administrative Rechte braucht. Der Docker-Build unter Linux ist davon nicht
|
||||
> betroffen. Zum Prüfen lokal: `docker build -t bricktrack:test .`
|
||||
|
||||
Die Passkey-Anmeldung funktioniert lokal auch über `http://localhost` –
|
||||
WebAuthn behandelt localhost als sicheren Kontext. Auf jeder anderen Adresse
|
||||
braucht es HTTPS.
|
||||
|
||||
---
|
||||
|
||||
## Datenmodell in einem Absatz
|
||||
|
||||
Ein **`set`** ist ein Katalogeintrag: Das Set existiert in der Welt,
|
||||
unabhängig davon, ob wir es besitzen. Ein **`collection_item`** verknüpft ein
|
||||
Set mit einem tatsächlich vorhandenen Exemplar – dasselbe Set zweimal im Regal
|
||||
sind zwei Einträge. Daraus folgt die zentrale Regel:
|
||||
|
||||
> „Hat er nicht" = Sets im Katalog, zu denen kein `collection_item` mit einem
|
||||
> Status ungleich `sold` existiert.
|
||||
|
||||
Ein verkauftes Exemplar zählt also nicht mehr als Besitz – das Set taucht
|
||||
wieder unter „fehlt noch" auf und darf zurück auf eine Wunschliste. Diese
|
||||
Regel steht an genau einer Stelle im Code: `src/lib/ownership.ts`.
|
||||
|
||||
Gibt es mehrere **Sammler**, bekommt die Frage ein „für wen": Der Katalog ist
|
||||
gemeinsam, der Besitz nicht. Ein Set, das nur das Geschwisterkind hat, fehlt
|
||||
dem anderen weiterhin.
|
||||
|
||||
Das vollständige Modell inklusive aller Tabellen: [CLAUDE.md](CLAUDE.md#datenmodell).
|
||||
|
||||
## Sicherheit
|
||||
|
||||
- Jede Eingabe wird serverseitig mit zod validiert.
|
||||
- Uploads sind auf PDF, JPEG, PNG und WebP begrenzt – geprüft an den **Magic
|
||||
Bytes**, nicht am gemeldeten MIME-Typ. Dateinamen erzeugt immer der Server.
|
||||
- Die öffentliche Wunschliste nutzt ausschließlich den Share-Token als
|
||||
Schlüssel und gibt nichts außer den Wunschlisten-Daten preis. Sie ist
|
||||
`noindex`.
|
||||
- Storno- und Einladungs-Token liegen nur als SHA-256-Hash in der Datenbank.
|
||||
- Session-Cookies sind `httpOnly`, `sameSite=lax` und im Betrieb `secure`.
|
||||
Auf den Login-Endpunkten liegt ein Rate-Limit (5 Versuche pro Minute).
|
||||
- Set-Bilder sind ohne Anmeldung abrufbar – die geteilte Wunschliste braucht
|
||||
sie. Anleitungs-PDFs und eigene Fotos **nie**.
|
||||
|
||||
## Stand und Pläne
|
||||
|
||||
Aktuelle Version und Änderungen: [CHANGELOG.md](CHANGELOG.md).
|
||||
Was als Nächstes kommt: [docs/ROADMAP.md](docs/ROADMAP.md).
|
||||
Wie lange was gedauert hat: [docs/DEVLOG.md](docs/DEVLOG.md).
|
||||
|
||||
## Lizenz
|
||||
|
||||
Privates Projekt, keine Lizenz vergeben.
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bricktrack - ein Service, ein Datenverzeichnis.
|
||||
#
|
||||
# TLS terminiert der Reverse Proxy (z. B. Nginx Proxy Manager). Der Container
|
||||
# spricht nur HTTP und muss nicht nach aussen erreichbar sein.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: bricktrack:latest
|
||||
container_name: bricktrack
|
||||
restart: unless-stopped
|
||||
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
environment:
|
||||
# Im Container liegen die Daten immer unter /data - der Pfad auf dem Host
|
||||
# wird ueber den Bind-Mount unten bestimmt.
|
||||
DATA_DIR: /data
|
||||
PORT: 3000
|
||||
|
||||
ports:
|
||||
# Standardmaessig nur an localhost gebunden: erreichbar wird die App
|
||||
# ueber den Reverse Proxy. Sitzt der Proxy auf einem anderen Host, in
|
||||
# der .env BIND_ADDRESS=0.0.0.0 setzen (und die Firewall zuziehen).
|
||||
- "${BIND_ADDRESS:-127.0.0.1}:${HOST_PORT:-3000}:3000"
|
||||
|
||||
volumes:
|
||||
# Das eine Verzeichnis, das gesichert werden muss.
|
||||
- ./data:/data
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:3000/api/health"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
start_period: 20s
|
||||
retries: 3
|
||||
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
@@ -0,0 +1,30 @@
|
||||
#!/bin/sh
|
||||
# Startet den Container: Verzeichnisse pruefen, Migrationen anwenden, App starten.
|
||||
set -e
|
||||
|
||||
DATA_DIR="${DATA_DIR:-/data}"
|
||||
|
||||
echo "[bricktrack] Datenverzeichnis: $DATA_DIR"
|
||||
|
||||
if ! mkdir -p "$DATA_DIR/db" "$DATA_DIR/files" 2>/dev/null; then
|
||||
echo ""
|
||||
echo " FEHLER: In $DATA_DIR darf nicht geschrieben werden."
|
||||
echo ""
|
||||
echo " Der Container laeuft als Nutzer 'node' (UID 1000). Das gemountete"
|
||||
echo " Verzeichnis muss ihm gehoeren. Auf dem Host einmalig:"
|
||||
echo ""
|
||||
echo " mkdir -p ./data && sudo chown -R 1000:1000 ./data"
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -w "$DATA_DIR/db" ]; then
|
||||
echo " FEHLER: $DATA_DIR/db ist nicht beschreibbar (sudo chown -R 1000:1000 ./data)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[bricktrack] Migrationen werden angewendet …"
|
||||
node /app/migrate.cjs
|
||||
|
||||
echo "[bricktrack] Start auf Port ${PORT:-3000}"
|
||||
exec "$@"
|
||||
@@ -0,0 +1,634 @@
|
||||
# Bricktrack – Anleitung
|
||||
|
||||
So benutzt du Bricktrack im Alltag. Die Bilder sind auf einem Handy
|
||||
aufgenommen, weil die App genau dafür gebaut ist: für den Laden und das
|
||||
Kinderzimmer, nicht für den Schreibtisch.
|
||||
|
||||
> Installation und Reverse Proxy stehen im [Schnellstart](../QUICKSTART.md).
|
||||
> Die Bilder hier lassen sich mit `pnpm screenshots` jederzeit neu erzeugen –
|
||||
> sie stammen aus den Beispieldaten des Seed-Skripts.
|
||||
|
||||
---
|
||||
|
||||
## Inhalt
|
||||
|
||||
1. [Das erste Konto](#1-das-erste-konto)
|
||||
2. [Der Überblick](#2-der-überblick)
|
||||
3. [Stammdaten anlegen](#3-stammdaten-anlegen)
|
||||
4. [Sets in den Katalog](#4-sets-in-den-katalog)
|
||||
5. [Was fehlt noch? Mehrere Sets auf einmal auswählen](#5-was-fehlt-noch)
|
||||
6. [Die Set-Detailseite](#6-die-set-detailseite)
|
||||
7. [Die Sammlung](#7-die-sammlung)
|
||||
8. [Wunschlisten](#8-wunschlisten)
|
||||
9. [Teilen – so sehen es Oma und Tante Anne](#9-teilen)
|
||||
10. [Passkeys einrichten](#10-passkeys-einrichten)
|
||||
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)
|
||||
|
||||
---
|
||||
|
||||
## 1. Das erste Konto
|
||||
|
||||

|
||||
|
||||
Beim allerersten Aufruf landest du direkt auf der Registrierung. **Wer sich
|
||||
zuerst registriert, wird Administrator** – danach ist die Registrierung
|
||||
geschlossen. Melde dich also gleich selbst an, bevor es jemand anderes tut.
|
||||
|
||||
Das Passwort braucht mindestens zehn Zeichen. Einen Passkey hinterlegst du
|
||||
gleich danach unter „Konto" (siehe [Abschnitt 10](#10-passkeys-einrichten)).
|
||||
|
||||
---
|
||||
|
||||
## 2. Der Überblick
|
||||
|
||||

|
||||
|
||||
Die Startseite zeigt auf einen Blick, wo die Sammlung steht:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Steinreihe** | Zehn Plätze für den ganzen Katalog. Jeder volle Stein ist ein Zehntel, das schon da ist – bei mehreren Kindern in deren Farbe |
|
||||
| **Teile und Wert** | Alle verbauten Teile und was die Sammlung heute wert ist |
|
||||
| **Nächster Termin** | Die nächste Wunschliste mit Datum, wie viele Tage noch bleiben und wie viel offen ist |
|
||||
| **Abkürzungen** | Direkt zu den fehlenden Sets, zu den Wunschlisten oder zu einem neuen Set |
|
||||
|
||||
Unten am Bildschirmrand liegt die Navigation – auf dem Handy mit dem Daumen
|
||||
erreichbar: **Start, Katalog, Sammlung, Wünsche** und in der Mitte der rote
|
||||
**+-Stein**. Der aktive Bereich sitzt auf einer kleinen Platte.
|
||||
|
||||

|
||||
|
||||
Das **+** bündelt alle Wege, wie ein Set in den Katalog kommt: Strichcode
|
||||
scannen, aus einem Link übernehmen, Lego-Nummer holen oder von Hand eintragen.
|
||||
|
||||

|
||||
|
||||
Alles, was man nicht täglich braucht, liegt hinter dem Button
|
||||
**„Mehr"** oben rechts: Konto und Passkeys, Design,
|
||||
Sammler, Einladungen, Erinnerungen, Hersteller, Themen, Standorte sowie
|
||||
Export und Import. Was nur der Administrator darf, sehen Mitglieder dort gar
|
||||
nicht erst.
|
||||
|
||||
Sets ohne Bild bekommen einen farbigen Platzhalter mit der Setnummer. Jeder
|
||||
Hersteller hat seine eigene Farbe – Lego gelb, Playmobil blau, Cobi grün.
|
||||
|
||||
---
|
||||
|
||||
## 3. Stammdaten anlegen
|
||||
|
||||

|
||||
|
||||
Bevor du Sets von Hand anlegst, brauchst du mindestens einen **Hersteller**.
|
||||
Zu finden unter **Mehr → Hersteller**. Der Lego-Import und die Übernahme aus
|
||||
einem Link legen fehlende Hersteller selbst an.
|
||||
|
||||
Drei Arten von Stammdaten:
|
||||
|
||||
- **Hersteller** – Lego, Playmobil, Cobi, BlueBrixx … beliebig erweiterbar.
|
||||
Die hinterlegte Website erscheint später als „Zum Shop"-Link auf der
|
||||
geteilten Wunschliste.
|
||||
- **Themen** – Serien wie „City", „Technic", „Novelmore". Themen gehören zu
|
||||
einem Hersteller und können Unterthemen haben („City → Feuerwehr").
|
||||
- **Standorte** – „Regal Kinderzimmer", „Kiste Keller 3". Auch schachtelbar,
|
||||
damit „Keller → Kiste 3" möglich ist.
|
||||
|
||||
> Ein Hersteller oder Thema lässt sich nicht löschen, solange noch Sets daran
|
||||
> hängen. Standorte schon – die betroffenen Sammlungseinträge verlieren dann
|
||||
> nur ihren Standort.
|
||||
|
||||
---
|
||||
|
||||
## 4. Sets in den Katalog
|
||||
|
||||

|
||||
|
||||
Der Katalog ist die Liste **aller Sets, die du kennst** – unabhängig davon, ob
|
||||
ihr sie besitzt. Das ist der wichtigste Gedanke der App:
|
||||
|
||||
> Ein Set im Katalog heißt „das gibt es". Erst ein Eintrag in der Sammlung
|
||||
> heißt „das haben wir".
|
||||
|
||||
Deshalb kannst du unbesorgt Sets anlegen, die ihr euch nur anschaut – genau
|
||||
die braucht die Wunschliste später.
|
||||
|
||||
**Suchen** kannst du nach Setnummer, Name oder EAN. Die vier Filter darunter:
|
||||
Besitzstatus, Hersteller, Thema und Jahr. Jeder Filterstand steckt in der
|
||||
URL – ein Lesezeichen auf „alle fehlenden Lego-Technic-Sets" ist damit möglich.
|
||||
|
||||
Die Kacheln zeigen auf einen Blick:
|
||||
|
||||
- **grüner Haken „Vorhanden"** – ist in der Sammlung (bei mehreren Exemplaren
|
||||
„2× vorhanden")
|
||||
- **„Auf Wunschliste"** – steht auf mindestens einer Liste
|
||||
- Setnummer, Hersteller, Thema, Jahr und UVP
|
||||
|
||||
Über das **+** in der Navigation kommt ein neues Set dazu. Pflicht sind nur
|
||||
Hersteller, Setnummer und Name; alles andere kannst du nachtragen. Ein Bild
|
||||
lädst du entweder hoch (es wird verkleinert und lokal gespeichert) oder du
|
||||
hinterlegst eine Bild-URL.
|
||||
|
||||
---
|
||||
|
||||
## 5. Was fehlt noch?
|
||||
|
||||

|
||||
|
||||
**Katalog → Filter „Fehlt noch"** (oder direkt `/katalog?besitz=fehlt`) zeigt
|
||||
ausschließlich Sets, die **nicht** in der Sammlung sind. Das ist der zentrale
|
||||
Weg zur Wunschliste.
|
||||
|
||||
### Mehrere Sets auf einmal
|
||||
|
||||

|
||||
|
||||
Eine Kachel **lange drücken** startet den Auswahlmodus – am Handy die
|
||||
natürliche Geste, das Gerät vibriert kurz. Am Rechner tut es der Knopf
|
||||
**„Auswählen"**. Danach wählt ein Tippen jede weitere Kachel an oder ab.
|
||||
|
||||
Unten erscheint die Aktionsleiste mit der Anzahl und dem Knopf **„Zur
|
||||
Wunschliste hinzufügen"**.
|
||||
|
||||

|
||||
|
||||
Im Dialog wählst du die Ziel-Wunschliste oder legst gleich eine neue an, dazu
|
||||
eine Priorität für alle ausgewählten Sets:
|
||||
|
||||
| Priorität | Bedeutung |
|
||||
|---|---|
|
||||
| Großer Wunsch | Das steht ganz oben |
|
||||
| Gerne | Der Normalfall |
|
||||
| Irgendwann | Nice to have |
|
||||
|
||||
Die Priorität lässt sich später je Eintrag ändern.
|
||||
|
||||
**Doppelte werden übersprungen.** Sets, die schon auf der Liste stehen oder
|
||||
bereits in der Sammlung sind, werden nicht noch einmal hinzugefügt – die
|
||||
Meldung danach sagt dir genau, welche das waren und warum. Das ist keine
|
||||
UI-Höflichkeit, sondern in der Datenbank abgesichert.
|
||||
|
||||
---
|
||||
|
||||
## 6. Die Set-Detailseite
|
||||
|
||||

|
||||
|
||||
Hier steht alles zu einem Set:
|
||||
|
||||
- **Zur Sammlung hinzufügen** – ein Tipp, und das Set gilt als vorhanden.
|
||||
Details wie Standort, Zustand oder Kaufpreis trägst du danach nach.
|
||||
- **Auf Wunschliste** – derselbe Zieldialog wie oben, nur für dieses eine Set.
|
||||
Ist das Set bereits in der Sammlung, ist der Knopf ausgegraut: Was da ist,
|
||||
gehört nicht auf einen Wunschzettel.
|
||||
- **Anleitungen** – PDFs hochladen (mehrere je Set, etwa „Buch 1 von 3") und
|
||||
direkt im Browser durchblättern, ohne Download und ohne fremde Dienste.
|
||||
Alternativ verlinkst du die Anleitung beim Hersteller.
|
||||
- **Fotos** – eigene Bilder, am Handy direkt aus der Kamera.
|
||||
- **In der Sammlung** – jedes Exemplar mit Status, Vollständigkeit, Zustand,
|
||||
Standort, Kaufdatum, Preis und „geschenkt von".
|
||||
|
||||
### Die fünf Status eines Exemplars
|
||||
|
||||
| Status | Bedeutung |
|
||||
|---|---|
|
||||
| Vorhanden | Da, aber weder auf- noch abgebaut vermerkt |
|
||||
| Aufgebaut | Steht zusammengebaut im Regal |
|
||||
| Zerlegt | In Einzelteilen in der Kiste |
|
||||
| Verliehen | Gerade bei jemand anderem |
|
||||
| **Verkauft** | Zählt **nicht** mehr als Besitz |
|
||||
|
||||
Der letzte Punkt ist wichtig: Ein verkauftes Set taucht wieder unter „Fehlt
|
||||
noch" auf und darf zurück auf eine Wunschliste.
|
||||
|
||||
---
|
||||
|
||||
## 7. Die Sammlung
|
||||
|
||||

|
||||
|
||||
Oben vier Kacheln: Anzahl Sets, Teile insgesamt, Wert nach UVP und wie viele
|
||||
Sets im Katalog noch fehlen. Darunter die Aufteilung nach Hersteller.
|
||||
|
||||
Gefiltert wird nach Status, Vollständigkeit, Standort und Hersteller – nützlich
|
||||
für die Frage „Was steht eigentlich in Kiste 3?".
|
||||
|
||||
Verkaufte Exemplare zählen in keiner Kachel mit.
|
||||
|
||||
---
|
||||
|
||||
## 8. Wunschlisten
|
||||
|
||||

|
||||
|
||||
Jede Liste hat einen Titel, einen Anlass und optional einen Termin. Die Karte
|
||||
zeigt, wie viele Wünsche drauf sind und wie viele davon noch niemand
|
||||
übernommen hat.
|
||||
|
||||

|
||||
|
||||
Auf der Liste selbst kannst du:
|
||||
|
||||
- die **Reihenfolge** mit den Pfeilen ändern – so stehen die wichtigsten
|
||||
Wünsche oben,
|
||||
- die **Priorität** je Eintrag anpassen,
|
||||
- Einträge entfernen,
|
||||
- unten im Bereich **„Aus fehlenden Sets hinzufügen"** direkt weitere Sets
|
||||
dazunehmen. Dort steckt dieselbe Auswahlansicht wie im Katalog, schon
|
||||
vorgefiltert auf Sets, die weder in der Sammlung noch auf dieser Liste sind.
|
||||
|
||||
### Zwei Einstellungen, die den Unterschied machen
|
||||
|
||||
- **Über Link teilbar** – ohne diesen Haken ist der öffentliche Link tot.
|
||||
Praktisch, wenn du eine Liste erst fertig bauen willst.
|
||||
- **Überraschungsmodus** – dann siehst *du* nur „Reserviert", nicht von wem.
|
||||
Die Schenkenden untereinander sehen die Namen weiterhin, damit niemand
|
||||
doppelt kauft.
|
||||
|
||||
---
|
||||
|
||||
## 9. Teilen
|
||||
|
||||
Auf der Wunschliste steht oben der **Link zum Teilen**. Kopieren, oder am
|
||||
Handy über den Teilen-Knopf direkt in WhatsApp, Signal oder eine Mail geben.
|
||||
|
||||
> Der Link ist der einzige Schlüssel. Wer ihn hat, sieht die Liste – mehr
|
||||
> nicht: keine Sammlung, keine anderen Listen, keine Namen von Konten. Für
|
||||
> Suchmaschinen ist die Seite gesperrt.
|
||||
>
|
||||
> Ist ein Link in falsche Hände geraten, erzeugst du mit **„Neuen Link
|
||||
> erzeugen"** einen frischen. Der alte funktioniert sofort nicht mehr.
|
||||
|
||||
### So sieht es für die Beschenkenden aus
|
||||
|
||||

|
||||
|
||||
Kein Konto, keine Anmeldung, keine App. Nur die Liste, mit Bild, Setnummer,
|
||||
Preis, Priorität und – falls hinterlegt – einem Link zum Shop des Herstellers.
|
||||
|
||||

|
||||
|
||||
Ein Tipp auf **„Das schenke ich"**, Name eintragen, fertig.
|
||||
|
||||

|
||||
|
||||
Danach sehen alle anderen, dass dieser Wunsch vergeben ist. Die Person selbst
|
||||
sieht „Das schenkst du" und kann die Reservierung jederzeit wieder aufheben –
|
||||
auf demselben Gerät mit einem Tipp, von einem anderen Gerät über den
|
||||
Storno-Link, den sie beim Reservieren bekommt.
|
||||
|
||||

|
||||
|
||||
Auf deiner Seite siehst du, wer was übernimmt – oder im Überraschungsmodus
|
||||
eben nur, *dass* etwas übernommen ist.
|
||||
|
||||
**Was passiert, wenn das Geschenk ankommt?** Sobald du das Set zur Sammlung
|
||||
hinzufügst, markiert Bricktrack den Wunsch automatisch als **erfüllt** und
|
||||
blendet ihn aus der öffentlichen Ansicht aus. Niemand kauft es dann noch ein
|
||||
zweites Mal.
|
||||
|
||||
---
|
||||
|
||||
## 10. Passkeys einrichten
|
||||
|
||||

|
||||
|
||||
Unter **Mehr → Konto & Passkeys → Passkey hinzufügen** hinterlegst du Fingerabdruck,
|
||||
Gesichtserkennung oder die Geräte-PIN als Anmeldung. Danach reicht auf der
|
||||
Anmeldeseite ein Tipp auf „Mit Passkey anmelden" – auf vielen Geräten schlägt
|
||||
der Browser den Passkey sogar von selbst vor.
|
||||
|
||||
Ein Passkey gilt pro Gerät. Leg ruhig mehrere an – Handy, Tablet, Laptop – und
|
||||
gib ihnen sprechende Namen.
|
||||
|
||||
> **Zwei Dinge, die man wissen muss:**
|
||||
> 1. Passkeys brauchen HTTPS. Über eine reine `http://`-Adresse (außer
|
||||
> `localhost`) bietet der Browser sie gar nicht erst an.
|
||||
> 2. Passkeys sind an die Domain gebunden. Ziehst du von
|
||||
> `bricktrack.example.de` auf eine andere Adresse um, musst du alle
|
||||
> Passkeys neu anlegen. Das Passwort funktioniert weiterhin – deshalb ist
|
||||
> es sinnvoll, es nicht zu vergessen.
|
||||
|
||||
---
|
||||
|
||||
## 11. Import und Scanner
|
||||
|
||||
Sets von Hand einzutippen ist der langsame Weg. Es gibt drei schnellere.
|
||||
|
||||
### Aus einem Link übernehmen – für alle Hersteller
|
||||
|
||||
Über **+ → Aus Link** landest du im Feld „Aus einem Link übernehmen".
|
||||
Dort passt jeder Link zu einem Set hinein: Amazon, idealo, geizhals, der Shop
|
||||
des Herstellers oder eine Set-Datenbank wie
|
||||
[Brickfact](https://brickfact.com) (Lego) und
|
||||
[Merlins Bricks](https://www.merlinsbricks.com) (BlueBrixx, Cobi und viele
|
||||
mehr). „Übernehmen" füllt das Formular darunter aus – **gespeichert wird erst,
|
||||
wenn du „Set anlegen" drückst**. Ein Blick auf Name und Nummer lohnt sich,
|
||||
denn Shops schreiben gern das halbe Sortiment in den Titel.
|
||||
|
||||
Was dabei passiert:
|
||||
|
||||
1. Bricktrack liest die Seite und übernimmt Name, Setnummer, EAN, Bild,
|
||||
Teile und Jahr, soweit sie dort stehen.
|
||||
2. Manche Shops lassen sich nicht abfragen. Dann reicht oft schon der Link
|
||||
selbst: In `amazon.de/LEGO-60398-Familienhaus-…` stecken Hersteller,
|
||||
Nummer und Name.
|
||||
3. Mit Hersteller und Nummer ergänzt Bricktrack den Rest: Lego über
|
||||
Rebrickable (mit API-Key), BlueBrixx und Cobi über Merlins Bricks.
|
||||
|
||||
Das Bild wird beim Speichern lokal abgelegt, die Quelle steht in den Notizen.
|
||||
Gibt es den Hersteller noch nicht, wird er angelegt. Steht das Set schon im
|
||||
Katalog, kommt statt des Formulars ein Link zum vorhandenen Eintrag.
|
||||
|
||||
> Bricktrack ruft dafür nur öffentliche Adressen ab. Links ins eigene Netz –
|
||||
> Router, NAS, andere Container – werden abgewiesen.
|
||||
|
||||
### Lego-Import über die Setnummer
|
||||
|
||||

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

|
||||
|
||||
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 **Mehr → Einladungen**:
|
||||
|
||||
1. „Einladung erstellen", optional an eine E-Mail-Adresse gebunden, mit Rolle
|
||||
und Gültigkeit.
|
||||
2. Den Link kopieren und weitergeben. Er wird **nur einmal angezeigt** – in
|
||||
der Datenbank liegt nur sein Hash.
|
||||
3. Die eingeladene Person legt darüber ihr Konto an. Danach ist der Link
|
||||
verbraucht.
|
||||
|
||||
„Mitglied" darf die Sammlung pflegen und Wunschlisten anlegen; „Administrator"
|
||||
zusätzlich einladen und Daten exportieren.
|
||||
|
||||
---
|
||||
|
||||
## 14. Daten
|
||||
|
||||

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

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

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

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

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

|
||||
|
||||
Unter **Mehr → Design** gibt es sechs Looks zur Auswahl:
|
||||
|
||||
| Design | |
|
||||
|---|---|
|
||||
| **Klemmbaustein** | Der Standard: Karten mit Noppen, dunkle Konturen, kräftige Steinfarben |
|
||||
| **Warm** | Ruhiger Sandton mit Ziegelrot |
|
||||
| **Nachtblau** | Dunkel mit gelbem Akzent – angenehm am Abend |
|
||||
| **Bonbon** | Weiches Lila mit Pastellfarben |
|
||||
| **Bauklotz** | Primärfarben und dicke schwarze Konturen |
|
||||
| **Schlicht** | Neutrales Grau, so wie Bricktrack bis Version 0.3 aussah |
|
||||
|
||||
Die Auswahl wirkt sofort und gilt **für das Gerät**, nicht für das Konto: Das
|
||||
Tablet im Kinderzimmer kann bunt sein, während das Handy dunkel bleibt.
|
||||
Öffentliche Wunschlisten sehen Gäste immer im Standard-Design.
|
||||
|
||||
---
|
||||
|
||||
## Kleinigkeiten, die den Alltag leichter machen
|
||||
|
||||
- **Filter stecken in der URL.** Ein Lesezeichen auf
|
||||
`/katalog?besitz=fehlt&hersteller=…` ist eine gespeicherte Suche.
|
||||
- **Preise** kannst du als `12,99` oder `12.99` eintippen – beides wird
|
||||
verstanden, gespeichert wird auf den Cent genau.
|
||||
- **Dasselbe Set zweimal** ist kein Problem: Zwei Exemplare sind zwei Einträge
|
||||
in der Sammlung, mit eigenem Standort und Zustand.
|
||||
- **Anleitungs-PDFs** können groß sein. Kommt beim Hochladen ein Fehler
|
||||
„413", muss im Reverse Proxy `client_max_body_size` hoch – siehe
|
||||
[QUICKSTART, Schritt 5](../QUICKSTART.md#5-reverse-proxy-einrichten-nginx-proxy-manager).
|
||||
- **Alles Wichtige liegt in `./data`.** Dieses eine Verzeichnis sichern
|
||||
genügt – Datenbank, Bilder, Fotos und Anleitungen sind darin.
|
||||
@@ -1,32 +1,166 @@
|
||||
# Devlog
|
||||
|
||||
Zeiterfassung und Nachvollziehbarkeit der Entwicklung. Eine Zeile je
|
||||
abgeschlossenem Arbeitsschritt, Zeiten in `HH:mm` (Europe/Berlin).
|
||||
abgeschlossenem Arbeitsschritt, Zeiten in `HH:mm` (Europe/Berlin). Die
|
||||
Commit-Spalte verweist auf den Stand am Ende des Schritts.
|
||||
|
||||
## Phase 1 - MVP
|
||||
## Phase 1 – MVP (v0.1.0)
|
||||
|
||||
### 2026-09-06
|
||||
### 06.09.2026
|
||||
|
||||
| Von | Bis | Dauer | Schritt | Ergebnis | Commit |
|
||||
|---|---|---|---|---|---|
|
||||
| 22:06 | 22:12 | 0:06 | Planung | Phase-1-Plan und Drizzle-Schema zur Freigabe vorgelegt; 5 Entscheidungsfragen geklaert (Cent statt Float, Migrator statt drizzle-kit im Image, Bilder oeffentlich / PDFs geschuetzt, Token gehasht, Branch pro Phase) | - |
|
||||
| 22:12 | 22:20 | 0:08 | Scaffold | pnpm via npm global installiert (corepack scheiterte an Rechten in `C:\Program Files\nodejs`), Next 15.5.25 + Tailwind 4 + shadcn/ui (radix-nova) mit 20 Komponenten | `0de49a9` |
|
||||
| 22:20 | 22:26 | 0:06 | Datenmodell | 19 Tabellen in Drizzle, CHECK-Constraints fuer `instruction`, `photo` und `priority`, Unique-Index `(wishlist_id, set_id)`, Migration erzeugt und angewendet, WAL verifiziert | `06268bc` |
|
||||
| 22:26 | 22:34 | 0:08 | Dokumentation | CLAUDE.md (Stack, Konventionen, Datenmodell, Sicherheitsregeln), Roadmap, Changelog, Devlog | `folgt` |
|
||||
| 22:06 | 22:12 | 0:06 | Planung | Phase-1-Plan und Drizzle-Schema zur Freigabe vorgelegt; fünf Entscheidungsfragen geklärt (Cent statt Float, Migrator statt drizzle-kit im Image, Bilder öffentlich / PDFs geschützt, Token gehasht, Branch pro Phase) | – |
|
||||
| 22:12 | 22:20 | 0:08 | Scaffold | pnpm installiert, Next 15.5.25 + Tailwind 4 + shadcn/ui (radix-nova) mit 20 Komponenten, Touch-Größen auf 44 px angehoben | `0de49a9` |
|
||||
| 22:20 | 22:25 | 0:05 | Datenmodell | 19 Tabellen in Drizzle, CHECK-Constraints, Unique-Index `(wishlist_id, set_id)`, Migration erzeugt und angewendet, WAL verifiziert | `06268bc` |
|
||||
| 22:25 | 22:26 | 0:01 | Dokumentation | CLAUDE.md, Roadmap, Changelog, Devlog | `b74cecb` |
|
||||
| 22:26 | 22:40 | 0:14 | Auth und App-Shell | better-auth mit Passwort und Passkeys, erster Nutzer wird Admin, Rate-Limit in der Datenbank, Konto-Seite, Bottom-Navigation, `/api/health` | `4c78c56` |
|
||||
| 22:40 | 22:46 | 0:06 | Stammdaten | CRUD für Hersteller, Themen und Standorte über eine gemeinsame CrudSection; zod-Schemata, ActionResult, Slug, Geld- und Datumshelfer | `af0ae03` |
|
||||
| 22:46 | 22:59 | 0:13 | Katalog | Kachelliste mit Suche und vier Filtern, Besitzlogik in `ownership.ts`, Set-Formular, Detailseite, Upload-Pipeline mit Magic-Byte-Prüfung, pdf.js-Viewer, Seed-Skript | `856800b` |
|
||||
| 22:59 | 23:06 | 0:07 | Wunschlisten und Sammlung | Mehrfachauswahl mit Dubletten-Skip, Wunschlisten-Verwaltung, Teilen, Überraschungsmodus, öffentliche Ansicht mit Reservierung und Storno, Sammlungsübersicht mit Statistik | `79b0133` |
|
||||
| 23:06 | 23:31 | 0:25 | Tests | 50 Vitest-Unit-Tests, Playwright-Smoke-Test über den ganzen Weg bis zur Reservierung durch einen Besucher ohne Konto | `b0152b8` |
|
||||
|
||||
**Summe Phase 1 bisher: 0:28**
|
||||
### 07.09.2026
|
||||
|
||||
| Von | Bis | Dauer | Schritt | Ergebnis | Commit |
|
||||
|---|---|---|---|---|---|
|
||||
| 23:31 | 00:09 | 0:38 | Docker | Multi-Stage-Build, Entrypoint mit Rechteprüfung und Migration, Compose-Stack, Healthcheck, `update.sh` mit Rollback; Image gebaut und Container verifiziert | `61b1249` |
|
||||
| 00:09 | 00:50 | 0:41 | Dokumentation und Screenshots | README, QUICKSTART mit Nginx-Proxy-Manager-Konfiguration, bebilderte Anleitung, 16 automatisiert erzeugte Screenshots, Changelog 0.1.0 | folgt |
|
||||
|
||||
**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
|
||||
|
||||
### Umgebung
|
||||
|
||||
- **pnpm**: `corepack enable` scheitert auf diesem Rechner an fehlenden
|
||||
Schreibrechten in `C:\Program Files\nodejs`. Stattdessen
|
||||
`npm install -g pnpm@10`; pnpm liegt unter
|
||||
`%APPDATA%\npm\pnpm.ps1` und ist im PATH.
|
||||
- **shadcn-CLI**: Die aktuelle Version (4.21) arbeitet mit Presets
|
||||
(`-p nova`) und Base-Libraries (`-b radix`) statt der frueheren
|
||||
Style-Auswahl. Sie zieht `cn` und `radix-ui` als Pakete; `clsx` und
|
||||
`tailwind-merge` werden dadurch nicht mehr direkt gebraucht.
|
||||
`npm install -g pnpm@10`; pnpm liegt unter `%APPDATA%\npm` und ist im PATH.
|
||||
- **Port 3000** ist auf dem Entwicklungsrechner von einer anderen Anwendung
|
||||
belegt. Der Dev-Server läuft deshalb lokal auf 3100, die Testläufe auf
|
||||
3210 und 3220.
|
||||
- **`pnpm build` unter Windows** scheitert mit `EPERM … symlink`: Der
|
||||
Standalone-Output legt Symlinks in `.next/standalone` an, wofür Windows den
|
||||
Entwicklermodus oder administrative Rechte braucht. Der Docker-Build unter
|
||||
Linux ist davon nicht betroffen; lokal prüft man mit
|
||||
`docker build -t bricktrack:test .`.
|
||||
|
||||
### Bibliotheken
|
||||
|
||||
- **shadcn-CLI 4.21** arbeitet mit Presets (`-p nova`) und Base-Libraries
|
||||
(`-b radix`) statt der früheren Style-Auswahl. Sie zieht `cn` und `radix-ui`
|
||||
als Pakete; `clsx` und `tailwind-merge` werden dadurch nicht mehr direkt
|
||||
gebraucht und wurden wieder entfernt. Die Voreinstellungen sind auf
|
||||
Desktop-Dichte ausgelegt (Buttons `h-8`) – für die Bedienung am Handy auf
|
||||
44 px angehoben.
|
||||
- **better-auth 1.7.3**: Das Passkey-Plugin liegt nicht mehr unter
|
||||
`better-auth/plugins/passkey`, sondern im eigenen Paket
|
||||
`@better-auth/passkey` (Exports `.` und `./client`). Das erwartete
|
||||
Schema deckt sich exakt mit der Tabelle `passkey`.
|
||||
`@better-auth/passkey`. Der Drizzle-Adapter löst Modelle über die
|
||||
TypeScript-Property-Namen auf, nicht über die Spaltennamen – deshalb können
|
||||
auch die Auth-Tabellen durchgehend `snake_case` verwenden.
|
||||
- **Drizzle**: Selbstbezügliche Fremdschlüssel (`parent_theme_id`) brauchen
|
||||
`AnySQLiteColumn` als Rückgabetyp, nicht `AnyColumn`.
|
||||
- **pdf.js** wird lokal gebündelt, inklusive Worker. Kein CDN, damit der
|
||||
Browser des Nutzers keine fremden Server kontaktiert.
|
||||
|
||||
### Stolpersteine
|
||||
|
||||
- **CRLF im Entrypoint**: Eine unter Windows geschriebene `entrypoint.sh` mit
|
||||
CRLF scheitert im Container mit dem irreführenden
|
||||
`exec /app/entrypoint.sh: no such file or directory` – der Kernel sucht
|
||||
dann `/bin/sh\r`. Behoben mit einer `.gitattributes`, die für Shell-Skripte
|
||||
LF erzwingt.
|
||||
- **Migrations-Bundle**: Der mit esbuild als ESM gebündelte Migrations-Runner
|
||||
scheiterte an `Dynamic require of "fs" is not supported` – mitgebündelte
|
||||
CJS-Abhängigkeiten (dotenv) rufen `require()` für Node-Builtins auf. Als
|
||||
CommonJS gebündelt läuft es.
|
||||
- **Playwright und Reihenfolge**: Playwright startet den Web-Server **vor**
|
||||
dem `globalSetup`. Das Löschen des Testdatenverzeichnisses im globalSetup
|
||||
scheiterte deshalb unter Windows an der bereits geöffneten SQLite-Datei;
|
||||
das Aufräumen läuft jetzt im npm-Skript davor.
|
||||
- **Screenshots gegen den Dev-Server** waren nicht reproduzierbar: unter Last
|
||||
brach der Stream mit `ChunkLoadError` beziehungsweise
|
||||
`Unexpected end of JSON input` ab. Die Bilder entstehen jetzt gegen das
|
||||
Produktions-Image im Container – verlässlich und zugleich näher an dem, was
|
||||
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
|
||||
|
||||
- Eine spontan aus dem Katalog angelegte Wunschliste war **nicht teilbar**:
|
||||
Das Feld `isPublic` kommt aus einer Checkbox und fiel beim programmatischen
|
||||
Anlegen auf `false`. Die Schnellanlage setzt es jetzt ausdrücklich.
|
||||
- Das Sternchen an Pflichtfeldern gehörte zum zugänglichen Namen des Feldes
|
||||
(„Name *"). Jetzt `aria-hidden` – der Pflichtstatus steckt ohnehin im
|
||||
`required` des Eingabefeldes.
|
||||
- In der Mehrfachauswahl verdeckte die Aktionsleiste die letzte Kachelreihe;
|
||||
das Raster bekommt im Auswahlmodus zusätzliches Polster nach unten.
|
||||
- Die Sperrfrist der Erinnerungen rechnete mit umgedrehtem Vorzeichen und
|
||||
hätte jede Erinnerung dauerhaft unterdrückt. Der Test dafür war zuerst da.
|
||||
- Der Screenshot-Lauf wählte plötzlich die falschen Kacheln aus: Der neue
|
||||
Sammler-Umschalter benutzt ebenfalls `aria-pressed`. Die Auswahlkacheln
|
||||
tragen jetzt ein eigenes `data-set-card`.
|
||||
|
||||
@@ -2,56 +2,79 @@
|
||||
|
||||
Legende: `[ ]` offen · `[~]` in Arbeit · `[x]` fertig
|
||||
|
||||
## Phase 1 - MVP (Ziel: v0.1.0)
|
||||
## Phase 1 - MVP (v0.1.0, abgeschlossen am 07.09.2026)
|
||||
|
||||
Alles laeuft vollstaendig im Docker-Stack.
|
||||
Laeuft vollstaendig im Docker-Stack.
|
||||
|
||||
- [x] Projekt-Scaffold (Next 15, Tailwind 4, shadcn/ui, pnpm)
|
||||
- [x] Datenmodell in Drizzle, initiale Migration
|
||||
- [x] Projektdokumentation (CLAUDE.md, Roadmap, Changelog, Devlog)
|
||||
- [ ] Auth: Registrierung des ersten Users (wird Admin), Login mit
|
||||
- [x] Auth: Registrierung des ersten Users (wird Admin), Login mit
|
||||
Passwort, Passkey anlegen und damit einloggen (Conditional UI),
|
||||
Passkey-Verwaltung unter "Konto", Rate-Limit auf Login
|
||||
- [ ] Mobile-first Layout-Shell mit Bottom-Navigation
|
||||
- [ ] Stammdaten: Hersteller, Themen (mit Unterthemen), Standorte
|
||||
- [ ] Set-Katalog: Kachelliste, Suche, Filter (Hersteller, Thema, Jahr,
|
||||
- [x] Mobile-first Layout-Shell mit Bottom-Navigation
|
||||
- [x] Stammdaten: Hersteller, Themen (mit Unterthemen), Standorte
|
||||
- [x] Set-Katalog: Kachelliste, Suche, Filter (Hersteller, Thema, Jahr,
|
||||
Besitzstatus), Anlegen und Bearbeiten
|
||||
- [ ] Upload-Pipeline (PDF/JPEG/PNG/WebP, Magic-Byte-Pruefung,
|
||||
- [x] Upload-Pipeline (PDF/JPEG/PNG/WebP, Magic-Byte-Pruefung,
|
||||
serverseitige Dateinamen, Thumbnails)
|
||||
- [ ] Set-Detailseite: Metadaten, Bild, Anleitungen mit pdf.js-Viewer,
|
||||
- [x] Set-Detailseite: Metadaten, Bild, Anleitungen mit pdf.js-Viewer,
|
||||
Fotos, Sammlungseintraege
|
||||
- [ ] Sammlung: Uebersicht, Filter, Statistik-Kacheln
|
||||
- [ ] Fehlende Sets (`/katalog?besitz=fehlt`) mit Mehrfachauswahl und
|
||||
- [x] Sammlung: Uebersicht, Filter, Statistik-Kacheln
|
||||
- [x] Fehlende Sets (`/katalog?besitz=fehlt`) mit Mehrfachauswahl und
|
||||
Bulk-Aktion "Zur Wunschliste hinzufuegen"
|
||||
- [ ] Wunschlisten: Verwaltung, Prioritaet, Sortierung, Teilen,
|
||||
- [x] Wunschlisten: Verwaltung, Prioritaet, Sortierung, Teilen,
|
||||
Ueberraschungsmodus, automatisches "erfuellt"
|
||||
- [ ] Oeffentliche Wunschliste `/w/[shareToken]` mit Reservierung ohne
|
||||
- [x] Oeffentliche Wunschliste `/w/[shareToken]` mit Reservierung ohne
|
||||
Anmeldung und Storno-Link
|
||||
- [ ] Seed-Skript (3 Hersteller, 10 Sets, 4 in Sammlung, 1 Wunschliste)
|
||||
- [ ] Unit-Tests (Besitzstatus, Bulk-Add inkl. Dubletten, Reservierung,
|
||||
- [x] Seed-Skript (3 Hersteller, 10 Sets, 4 in Sammlung, 1 Wunschliste)
|
||||
- [x] Unit-Tests (Besitzstatus, Bulk-Add inkl. Dubletten, Reservierung,
|
||||
Token-Erzeugung) und Playwright-Smoke-Test
|
||||
- [ ] Docker: Multi-Stage-Build, Compose-Stack, Healthcheck, Entrypoint
|
||||
- [ ] Dokumentation: README, QUICKSTART, bebilderte Anleitung,
|
||||
- [x] Docker: Multi-Stage-Build, Compose-Stack, Healthcheck, Entrypoint
|
||||
- [x] Dokumentation: README, QUICKSTART, bebilderte Anleitung,
|
||||
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.
|
||||
- [ ] 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"
|
||||
- [ ] Fehlteile je Sammlungseintrag pflegen
|
||||
- [ ] Einladungslinks durch den Admin
|
||||
- [ ] Export/Import: gesamte Datenbank als JSON, Dateien als ZIP,
|
||||
- [x] Fehlteile je Sammlungseintrag pflegen
|
||||
- [x] Einladungslinks durch den Admin
|
||||
- [x] Export/Import: gesamte Datenbank als JSON, Dateien als ZIP,
|
||||
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
|
||||
- [ ] Preisbeobachtung und Wertentwicklung (manuell gepflegt,
|
||||
optional BrickLink-Preisfuehrer)
|
||||
- [ ] E-Mail-Erinnerung (SMTP), wenn ein Wunschlisten-Termin naeher
|
||||
- [x] Preisbeobachtung und Wertentwicklung (manuell gepflegt).
|
||||
Der BrickLink-Preisfuehrer war als optional vermerkt und wurde
|
||||
bewusst weggelassen: er verlangt OAuth1 mit einer pro Nutzer
|
||||
registrierten Anwendung - viel Apparat fuer eine Zahl, die sich in
|
||||
zehn Sekunden eintippen laesst. Die Datenstruktur ist offen dafuer.
|
||||
- [x] E-Mail-Erinnerung (SMTP), wenn ein Wunschlisten-Termin naeher
|
||||
rueckt und Eintraege unreserviert sind
|
||||
- [ ] Dublettenpruefung beim Anlegen von Sets
|
||||
- [x] Dublettenpruefung beim Anlegen von Sets
|
||||
|
||||
## Design und Sicherheit (v0.4.0, abgeschlossen am 25.10.2026)
|
||||
|
||||
- [x] Abhängigkeiten mit bekannten Sicherheitslücken aktualisiert
|
||||
|
||||
- [x] Designauswahl pro Gerät, sechs Designs, Standard „Klemmbaustein"
|
||||
- [x] Platzhalter für Sets ohne Bild, neues Logo, Schriften lokal
|
||||
- [x] Startseite als Überblick mit Steinreihe und nächstem Termin
|
||||
- [ ] Filter im Katalog als Reiter statt Auswahlfelder
|
||||
- [ ] Scan-Button mittig in der unteren Navigation
|
||||
|
||||
## Danach
|
||||
|
||||
Phase 1 bis 3 sind umgesetzt. Was noch offen ist, steht hier - ohne Termin
|
||||
und ohne Anspruch auf Vollstaendigkeit:
|
||||
|
||||
- [ ] BrickLink-Preisfuehrer anbinden, falls der Aufwand sich doch lohnt
|
||||
- [ ] Mehrere Fotos je Sammlungseintrag (bisher nur je Set)
|
||||
- [ ] Anleitungen im PDF-Viewer durchsuchen
|
||||
- [ ] Statistik ueber die Zeit: was kam wann dazu
|
||||
|
||||
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 187 KiB |
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 445 KiB |
|
After Width: | Height: | Size: 331 KiB |
|
After Width: | Height: | Size: 371 KiB |
|
After Width: | Height: | Size: 490 KiB |
|
After Width: | Height: | Size: 350 KiB |
|
After Width: | Height: | Size: 331 KiB |
|
After Width: | Height: | Size: 125 KiB |
|
After Width: | Height: | Size: 497 KiB |
|
After Width: | Height: | Size: 234 KiB |
|
After Width: | Height: | Size: 426 KiB |
|
After Width: | Height: | Size: 248 KiB |
|
After Width: | Height: | Size: 498 KiB |
|
After Width: | Height: | Size: 302 KiB |
|
After Width: | Height: | Size: 383 KiB |
|
After Width: | Height: | Size: 245 KiB |
|
After Width: | Height: | Size: 259 KiB |
|
After Width: | Height: | Size: 310 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 380 KiB |
|
After Width: | Height: | Size: 345 KiB |
|
After Width: | Height: | Size: 345 KiB |
|
After Width: | Height: | Size: 154 KiB |
|
After Width: | Height: | Size: 451 KiB |
|
After Width: | Height: | Size: 271 KiB |
@@ -1,21 +1,21 @@
|
||||
CREATE TABLE `account` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`accountId` text NOT NULL,
|
||||
`providerId` text NOT NULL,
|
||||
`userId` text NOT NULL,
|
||||
`accessToken` text,
|
||||
`refreshToken` text,
|
||||
`idToken` text,
|
||||
`accessTokenExpiresAt` integer,
|
||||
`refreshTokenExpiresAt` integer,
|
||||
`account_id` text NOT NULL,
|
||||
`provider_id` text NOT NULL,
|
||||
`user_id` text NOT NULL,
|
||||
`access_token` text,
|
||||
`refresh_token` text,
|
||||
`id_token` text,
|
||||
`access_token_expires_at` integer,
|
||||
`refresh_token_expires_at` integer,
|
||||
`scope` text,
|
||||
`password` text,
|
||||
`createdAt` integer NOT NULL,
|
||||
`updatedAt` integer NOT NULL,
|
||||
FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `account_user_idx` ON `account` (`userId`);--> statement-breakpoint
|
||||
CREATE INDEX `account_user_idx` ON `account` (`user_id`);--> statement-breakpoint
|
||||
CREATE TABLE `invite` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`token_hash` text NOT NULL,
|
||||
@@ -34,50 +34,50 @@ CREATE UNIQUE INDEX `invite_token_hash_unique` ON `invite` (`token_hash`);--> st
|
||||
CREATE TABLE `passkey` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`name` text,
|
||||
`publicKey` text NOT NULL,
|
||||
`userId` text NOT NULL,
|
||||
`credentialID` text NOT NULL,
|
||||
`public_key` text NOT NULL,
|
||||
`user_id` text NOT NULL,
|
||||
`credential_id` text NOT NULL,
|
||||
`counter` integer NOT NULL,
|
||||
`deviceType` text NOT NULL,
|
||||
`backedUp` integer NOT NULL,
|
||||
`device_type` text NOT NULL,
|
||||
`backed_up` integer NOT NULL,
|
||||
`transports` text,
|
||||
`aaguid` text,
|
||||
`createdAt` integer,
|
||||
FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
`created_at` integer,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `passkey_credentialID_unique` ON `passkey` (`credentialID`);--> statement-breakpoint
|
||||
CREATE INDEX `passkey_user_idx` ON `passkey` (`userId`);--> statement-breakpoint
|
||||
CREATE TABLE `rateLimit` (
|
||||
CREATE UNIQUE INDEX `passkey_credential_id_unique` ON `passkey` (`credential_id`);--> statement-breakpoint
|
||||
CREATE INDEX `passkey_user_idx` ON `passkey` (`user_id`);--> statement-breakpoint
|
||||
CREATE TABLE `rate_limit` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`key` text,
|
||||
`count` integer,
|
||||
`lastRequest` integer
|
||||
`last_request` integer
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `session` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`token` text NOT NULL,
|
||||
`userId` text NOT NULL,
|
||||
`expiresAt` integer NOT NULL,
|
||||
`ipAddress` text,
|
||||
`userAgent` text,
|
||||
`createdAt` integer NOT NULL,
|
||||
`updatedAt` integer NOT NULL,
|
||||
FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
`user_id` text NOT NULL,
|
||||
`expires_at` integer NOT NULL,
|
||||
`ip_address` text,
|
||||
`user_agent` text,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `session_token_unique` ON `session` (`token`);--> statement-breakpoint
|
||||
CREATE INDEX `session_user_idx` ON `session` (`userId`);--> statement-breakpoint
|
||||
CREATE INDEX `session_user_idx` ON `session` (`user_id`);--> statement-breakpoint
|
||||
CREATE TABLE `user` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`email` text NOT NULL,
|
||||
`emailVerified` integer DEFAULT false NOT NULL,
|
||||
`email_verified` integer DEFAULT false NOT NULL,
|
||||
`image` text,
|
||||
`role` text DEFAULT 'member' NOT NULL,
|
||||
`createdAt` integer NOT NULL,
|
||||
`updatedAt` integer NOT NULL
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `user_email_unique` ON `user` (`email`);--> statement-breakpoint
|
||||
@@ -85,9 +85,9 @@ CREATE TABLE `verification` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`identifier` text NOT NULL,
|
||||
`value` text NOT NULL,
|
||||
`expiresAt` integer NOT NULL,
|
||||
`createdAt` integer NOT NULL,
|
||||
`updatedAt` integer NOT NULL
|
||||
`expires_at` integer NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `verification_identifier_idx` ON `verification` (`identifier`);--> statement-breakpoint
|
||||
@@ -0,0 +1,28 @@
|
||||
CREATE TABLE `collector` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`color` text DEFAULT 'blue' NOT NULL,
|
||||
`birthday` integer,
|
||||
`notes` text,
|
||||
`sort_order` integer DEFAULT 0 NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `collector_sort_idx` ON `collector` (`sort_order`,`name`);--> statement-breakpoint
|
||||
ALTER TABLE `collection_item` ADD `collector_id` text REFERENCES collector(id);--> statement-breakpoint
|
||||
CREATE INDEX `collection_item_collector_idx` ON `collection_item` (`collector_id`);--> statement-breakpoint
|
||||
ALTER TABLE `wishlist` ADD `collector_id` text REFERENCES collector(id);--> statement-breakpoint
|
||||
-- Von Hand ergaenzt: Bestandsdaten nachziehen.
|
||||
-- Vor dieser Migration gab es genau eine Sammlung. Sie bekommt einen Sammler,
|
||||
-- damit alles Vorhandene zugeordnet bleibt; umbenennen laesst er sich unter
|
||||
-- Verwaltung -> Sammler.
|
||||
INSERT INTO `collector` (`id`, `name`, `color`, `sort_order`, `created_at`, `updated_at`)
|
||||
SELECT 'c0000000-0000-4000-8000-000000000001', 'Sammlung', 'blue', 0, unixepoch(), unixepoch()
|
||||
WHERE NOT EXISTS (SELECT 1 FROM `collector`);--> statement-breakpoint
|
||||
UPDATE `collection_item`
|
||||
SET `collector_id` = (SELECT `id` FROM `collector` ORDER BY `sort_order`, `name` LIMIT 1)
|
||||
WHERE `collector_id` IS NULL;--> statement-breakpoint
|
||||
UPDATE `wishlist`
|
||||
SET `collector_id` = (SELECT `id` FROM `collector` ORDER BY `sort_order`, `name` LIMIT 1)
|
||||
WHERE `collector_id` IS NULL;
|
||||
@@ -0,0 +1,14 @@
|
||||
CREATE TABLE `price_point` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`set_id` text NOT NULL,
|
||||
`price_cents` integer NOT NULL,
|
||||
`condition` text DEFAULT 'new' NOT NULL,
|
||||
`source` text,
|
||||
`observed_at` integer NOT NULL,
|
||||
`note` text,
|
||||
`created_at` integer NOT NULL,
|
||||
FOREIGN KEY (`set_id`) REFERENCES `set`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
CONSTRAINT "price_point_positive" CHECK("price_point"."price_cents" >= 0)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `price_point_set_idx` ON `price_point` (`set_id`,`observed_at`);
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `wishlist` ADD `last_reminder_at` integer;
|
||||
@@ -0,0 +1,5 @@
|
||||
CREATE TABLE `app_setting` (
|
||||
`key` text PRIMARY KEY NOT NULL,
|
||||
`value` text NOT NULL,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "5d6a1e8b-5b30-4c01-8044-1db2d04615e0",
|
||||
"id": "426c9bf5-ccbd-473a-a819-11f8b9252494",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"tables": {
|
||||
"account": {
|
||||
@@ -14,57 +14,57 @@
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"accountId": {
|
||||
"name": "accountId",
|
||||
"account_id": {
|
||||
"name": "account_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"providerId": {
|
||||
"name": "providerId",
|
||||
"provider_id": {
|
||||
"name": "provider_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"userId": {
|
||||
"name": "userId",
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"accessToken": {
|
||||
"name": "accessToken",
|
||||
"access_token": {
|
||||
"name": "access_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"refreshToken": {
|
||||
"name": "refreshToken",
|
||||
"refresh_token": {
|
||||
"name": "refresh_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"idToken": {
|
||||
"name": "idToken",
|
||||
"id_token": {
|
||||
"name": "id_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"accessTokenExpiresAt": {
|
||||
"name": "accessTokenExpiresAt",
|
||||
"access_token_expires_at": {
|
||||
"name": "access_token_expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"refreshTokenExpiresAt": {
|
||||
"name": "refreshTokenExpiresAt",
|
||||
"refresh_token_expires_at": {
|
||||
"name": "refresh_token_expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
@@ -84,15 +84,15 @@
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updatedAt": {
|
||||
"name": "updatedAt",
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
@@ -103,18 +103,18 @@
|
||||
"account_user_idx": {
|
||||
"name": "account_user_idx",
|
||||
"columns": [
|
||||
"userId"
|
||||
"user_id"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"account_userId_user_id_fk": {
|
||||
"name": "account_userId_user_id_fk",
|
||||
"account_user_id_user_id_fk": {
|
||||
"name": "account_user_id_user_id_fk",
|
||||
"tableFrom": "account",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"userId"
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
@@ -253,22 +253,22 @@
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"publicKey": {
|
||||
"name": "publicKey",
|
||||
"public_key": {
|
||||
"name": "public_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"userId": {
|
||||
"name": "userId",
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"credentialID": {
|
||||
"name": "credentialID",
|
||||
"credential_id": {
|
||||
"name": "credential_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
@@ -281,15 +281,15 @@
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"deviceType": {
|
||||
"name": "deviceType",
|
||||
"device_type": {
|
||||
"name": "device_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"backedUp": {
|
||||
"name": "backedUp",
|
||||
"backed_up": {
|
||||
"name": "backed_up",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
@@ -309,8 +309,8 @@
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
@@ -318,28 +318,28 @@
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"passkey_credentialID_unique": {
|
||||
"name": "passkey_credentialID_unique",
|
||||
"passkey_credential_id_unique": {
|
||||
"name": "passkey_credential_id_unique",
|
||||
"columns": [
|
||||
"credentialID"
|
||||
"credential_id"
|
||||
],
|
||||
"isUnique": true
|
||||
},
|
||||
"passkey_user_idx": {
|
||||
"name": "passkey_user_idx",
|
||||
"columns": [
|
||||
"userId"
|
||||
"user_id"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"passkey_userId_user_id_fk": {
|
||||
"name": "passkey_userId_user_id_fk",
|
||||
"passkey_user_id_user_id_fk": {
|
||||
"name": "passkey_user_id_user_id_fk",
|
||||
"tableFrom": "passkey",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"userId"
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
@@ -352,8 +352,8 @@
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"rateLimit": {
|
||||
"name": "rateLimit",
|
||||
"rate_limit": {
|
||||
"name": "rate_limit",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
@@ -376,8 +376,8 @@
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"lastRequest": {
|
||||
"name": "lastRequest",
|
||||
"last_request": {
|
||||
"name": "last_request",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
@@ -407,43 +407,43 @@
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"userId": {
|
||||
"name": "userId",
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"expiresAt": {
|
||||
"name": "expiresAt",
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"ipAddress": {
|
||||
"name": "ipAddress",
|
||||
"ip_address": {
|
||||
"name": "ip_address",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"userAgent": {
|
||||
"name": "userAgent",
|
||||
"user_agent": {
|
||||
"name": "user_agent",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updatedAt": {
|
||||
"name": "updatedAt",
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
@@ -461,18 +461,18 @@
|
||||
"session_user_idx": {
|
||||
"name": "session_user_idx",
|
||||
"columns": [
|
||||
"userId"
|
||||
"user_id"
|
||||
],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"session_userId_user_id_fk": {
|
||||
"name": "session_userId_user_id_fk",
|
||||
"session_user_id_user_id_fk": {
|
||||
"name": "session_user_id_user_id_fk",
|
||||
"tableFrom": "session",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"userId"
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
@@ -509,8 +509,8 @@
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"emailVerified": {
|
||||
"name": "emailVerified",
|
||||
"email_verified": {
|
||||
"name": "email_verified",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
@@ -532,15 +532,15 @@
|
||||
"autoincrement": false,
|
||||
"default": "'member'"
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updatedAt": {
|
||||
"name": "updatedAt",
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
@@ -585,22 +585,22 @@
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"expiresAt": {
|
||||
"name": "expiresAt",
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updatedAt": {
|
||||
"name": "updatedAt",
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
|
||||
@@ -5,8 +5,36 @@
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1788726280857,
|
||||
"tag": "0000_clean_sumo",
|
||||
"when": 1788726503038,
|
||||
"tag": "0000_rapid_gargoyle",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "6",
|
||||
"when": 1788763704015,
|
||||
"tag": "0001_lumpy_bill_hollister",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "6",
|
||||
"when": 1788765229376,
|
||||
"tag": "0002_aspiring_whiplash",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "6",
|
||||
"when": 1788765478965,
|
||||
"tag": "0003_powerful_sally_floyd",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "6",
|
||||
"when": 1792963951232,
|
||||
"tag": "0004_modern_agent_brand",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Captures the screenshots used by docs/ANLEITUNG.md.
|
||||
*
|
||||
* Reproducible on purpose: whenever the UI changes, `pnpm screenshots`
|
||||
* refreshes every picture in the manual from the same seeded data.
|
||||
*/
|
||||
const SHOT_DIR = path.resolve(process.cwd(), 'docs/bilder');
|
||||
|
||||
function shotPath(name: string) {
|
||||
fs.mkdirSync(SHOT_DIR, { recursive: true });
|
||||
return path.join(SHOT_DIR, `${name}.png`);
|
||||
}
|
||||
|
||||
async function shoot(page: Page, name: string) {
|
||||
// Let fonts and images settle so the picture is not half-rendered.
|
||||
await page.waitForLoadState('networkidle');
|
||||
// A fixed bottom bar would be painted where the first viewport ends, in the
|
||||
// middle of a full-page picture. In flow it lands at the end, where it belongs.
|
||||
await page.addStyleTag({ content: '.fixed.bottom-0 { position: static !important; }' });
|
||||
await page.screenshot({ path: shotPath(name), fullPage: true });
|
||||
}
|
||||
|
||||
/** Small wrapper so every navigation waits the same way. */
|
||||
async function visit(page: Page, url: string) {
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded' });
|
||||
}
|
||||
|
||||
test('Screenshots fuer die Anleitung', async ({ page, browser }) => {
|
||||
// --- 1. registration ------------------------------------------------------
|
||||
await visit(page, '/registrieren');
|
||||
await expect(page.getByText('Erstes Konto anlegen')).toBeVisible();
|
||||
await shoot(page, '01-registrierung');
|
||||
|
||||
await page.getByLabel('Name').fill('Florian');
|
||||
await page.getByLabel('E-Mail').fill('papa@bricktrack.local');
|
||||
await page.getByLabel('Passwort').fill('anleitung-passwort');
|
||||
await page.getByRole('button', { name: 'Konto anlegen' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Hallo Florian' })).toBeVisible();
|
||||
|
||||
// Example data needs the user to exist first (the wishlist has an owner).
|
||||
execFileSync('node', ['node_modules/tsx/dist/cli.mjs', 'scripts/seed.ts'], {
|
||||
stdio: 'inherit',
|
||||
env: { ...process.env, DATA_DIR: './data-shots' },
|
||||
});
|
||||
|
||||
// --- 2. dashboard ---------------------------------------------------------
|
||||
await visit(page, '/');
|
||||
await shoot(page, '02-start');
|
||||
|
||||
// The plus in the middle of the navigation, opened.
|
||||
await page.getByRole('button', { name: 'Neues Set' }).click();
|
||||
await expect(page.getByText('Was kommt dazu?')).toBeVisible();
|
||||
await page.waitForTimeout(400); // let the sheet finish sliding in
|
||||
await page.screenshot({ path: shotPath('26-plus-menue') });
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
await visit(page, '/mehr');
|
||||
await expect(page.getByText('Katalog pflegen')).toBeVisible();
|
||||
await shoot(page, '27-mehr');
|
||||
|
||||
// --- 3. master data -------------------------------------------------------
|
||||
await visit(page, '/verwaltung/hersteller');
|
||||
await expect(page.getByText('Lego', { exact: true })).toBeVisible();
|
||||
await shoot(page, '03-verwaltung-hersteller');
|
||||
|
||||
// --- 4. catalog -----------------------------------------------------------
|
||||
await visit(page, '/katalog');
|
||||
await expect(page.getByText('Familienhaus und Elektroauto').first()).toBeVisible();
|
||||
await shoot(page, '04-katalog');
|
||||
|
||||
// --- 5. missing sets, selection mode --------------------------------------
|
||||
await visit(page, '/katalog?besitz=fehlt');
|
||||
await expect(page.getByRole('heading', { name: 'Fehlende Sets' })).toBeVisible();
|
||||
await shoot(page, '05-fehlende-sets');
|
||||
|
||||
await page.getByRole('button', { name: 'Auswählen' }).click();
|
||||
await expect(page.getByText('Tippe die Sets an')).toBeVisible();
|
||||
// data-set-card, not aria-pressed: the collector switcher uses that too.
|
||||
const cards = page.locator('button[data-set-card]');
|
||||
await cards.nth(0).click();
|
||||
await cards.nth(1).click();
|
||||
await expect(page.getByText('2 ausgewählt')).toBeVisible();
|
||||
await shoot(page, '06-mehrfachauswahl');
|
||||
|
||||
await page.getByRole('button', { name: 'Zur Wunschliste hinzufügen' }).click();
|
||||
await expect(page.getByText('Sets auf die Wunschliste')).toBeVisible();
|
||||
await page.screenshot({ path: shotPath('07-wunschliste-dialog') });
|
||||
await page.getByRole('button', { name: 'Abbrechen' }).click();
|
||||
|
||||
// --- 6. set detail --------------------------------------------------------
|
||||
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 shoot(page, '08-set-detail');
|
||||
|
||||
// --- 7. collection --------------------------------------------------------
|
||||
await visit(page, '/sammlung');
|
||||
await expect(page.getByText('Wert nach UVP').first()).toBeVisible();
|
||||
await shoot(page, '09-sammlung');
|
||||
|
||||
// --- 8. wishlist ----------------------------------------------------------
|
||||
await visit(page, '/wunschlisten');
|
||||
await expect(page.getByText('Weihnachten 2026').first()).toBeVisible();
|
||||
await shoot(page, '10-wunschlisten');
|
||||
|
||||
const listHref = await page
|
||||
.getByRole('link', { name: /Weihnachten 2026/ })
|
||||
.getAttribute('href');
|
||||
await visit(page, listHref!);
|
||||
await expect(page.getByRole('heading', { name: 'Weihnachten 2026' })).toBeVisible();
|
||||
await shoot(page, '11-wunschliste-bearbeiten');
|
||||
|
||||
const shareUrl = await page.getByLabel('Link zum Teilen').inputValue();
|
||||
|
||||
// --- 9. public view, as a visitor without an account ----------------------
|
||||
const visitorContext = await browser.newContext({ locale: 'de-DE' });
|
||||
const visitor = await visitorContext.newPage();
|
||||
await visitor.setViewportSize({ width: 412, height: 915 });
|
||||
await visit(visitor, shareUrl);
|
||||
await expect(visitor.getByRole('heading', { name: 'Weihnachten 2026' })).toBeVisible();
|
||||
await shoot(visitor, '12-oeffentliche-wunschliste');
|
||||
|
||||
await visitor.getByRole('button', { name: 'Das schenke ich' }).first().click();
|
||||
await visitor.getByLabel('Dein Name').fill('Tante Anne');
|
||||
await visitor.screenshot({ path: shotPath('13-reservieren') });
|
||||
await visitor.getByRole('button', { name: 'Reservieren' }).click();
|
||||
await expect(visitor.getByText('Reservierung gespeichert')).toBeVisible();
|
||||
await visitor.getByRole('button', { name: 'Alles klar' }).click();
|
||||
// Otherwise the picture catches the dialog halfway through fading out.
|
||||
await expect(visitor.getByRole('dialog')).toBeHidden();
|
||||
await shoot(visitor, '14-reserviert');
|
||||
await visitorContext.close();
|
||||
|
||||
// --- 10. the owner sees the reservation -----------------------------------
|
||||
await page.reload();
|
||||
await shoot(page, '15-wunschliste-reserviert');
|
||||
|
||||
// --- 11. account and passkeys --------------------------------------------
|
||||
await visit(page, '/konto');
|
||||
await expect(page.getByText('Passkeys', { exact: true })).toBeVisible();
|
||||
await shoot(page, '16-konto-passkeys');
|
||||
|
||||
// The same start page in the dark design, then back to the default.
|
||||
await page.getByText('Nachtblau').click();
|
||||
await expect(page.locator('html')).toHaveAttribute('data-design', 'nacht');
|
||||
// The look switches at once; the cookie follows with the server action.
|
||||
await page.waitForLoadState('networkidle');
|
||||
await visit(page, '/');
|
||||
await expect(page.locator('html')).toHaveAttribute('data-design', 'nacht');
|
||||
await shoot(page, '25-design-nachtblau');
|
||||
await visit(page, '/konto');
|
||||
await page.getByText('Klemmbaustein').click();
|
||||
await expect(page.locator('html')).toHaveAttribute('data-design', 'klemmbaustein');
|
||||
|
||||
// --- 12. Lego import ------------------------------------------------------
|
||||
// The screenshot container runs with a placeholder API key so the manual
|
||||
// shows the form, not the "not configured" hint. Nothing is submitted.
|
||||
await visit(page, '/katalog/neu');
|
||||
await expect(page.getByText('Lego-Import')).toBeVisible();
|
||||
await shoot(page, '17-lego-import');
|
||||
|
||||
// --- 13. missing parts ----------------------------------------------------
|
||||
await visit(page, '/katalog?q=60398');
|
||||
await page.getByText('Familienhaus und Elektroauto').first().click();
|
||||
await expect(page.getByRole('heading', { name: 'Familienhaus und Elektroauto' })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Fehlteile' }).first().click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
await page.getByLabel('Teilenummer').fill('3001');
|
||||
await page.getByLabel('Farbe').fill('Rot');
|
||||
await page.getByRole('button', { name: 'Hinzufügen' }).click();
|
||||
await expect(page.getByText('3001')).toBeVisible();
|
||||
await page.screenshot({ path: shotPath('18-fehlteile') });
|
||||
await page.getByRole('button', { name: 'Fertig' }).click();
|
||||
|
||||
// --- 14. invitations ------------------------------------------------------
|
||||
await visit(page, '/verwaltung/einladungen');
|
||||
await expect(page.getByRole('button', { name: 'Einladung erstellen' })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Einladung erstellen' }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
await page.screenshot({ path: shotPath('19-einladungen') });
|
||||
await page.getByRole('button', { name: 'Abbrechen' }).click();
|
||||
|
||||
// --- 15. export and import ------------------------------------------------
|
||||
await visit(page, '/verwaltung/daten');
|
||||
await expect(page.getByText('Alles als ZIP')).toBeVisible();
|
||||
await shoot(page, '20-daten');
|
||||
|
||||
// --- 16. collectors -------------------------------------------------------
|
||||
await visit(page, '/verwaltung/sammler');
|
||||
await expect(page.getByText('Der Katalog ist gemeinsam')).toBeVisible();
|
||||
await shoot(page, '21-sammler');
|
||||
|
||||
// The switcher only exists because the seed created two collectors.
|
||||
await visit(page, '/katalog?besitz=fehlt');
|
||||
await expect(page.getByRole('radio', { name: /Max/ })).toBeVisible();
|
||||
await page.getByRole('radio', { name: /Max/ }).click();
|
||||
await expect(page.getByText('Sets, die Max noch nicht hat')).toBeVisible();
|
||||
await shoot(page, '22-sammler-filter');
|
||||
// Back to everybody, so the following pictures show the whole family.
|
||||
await page.getByRole('radio', { name: /Alle/ }).click();
|
||||
await expect(page.getByText('Sets, die noch nicht in der Sammlung sind')).toBeVisible();
|
||||
|
||||
// --- 17. prices -----------------------------------------------------------
|
||||
await visit(page, '/katalog?q=60398');
|
||||
await page.getByText('Familienhaus und Elektroauto').first().click();
|
||||
await expect(page.getByText('Preis und Wertentwicklung')).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Preis notieren' }).click();
|
||||
// Role, not label: the label text carries the required marker.
|
||||
await page.getByRole('textbox', { name: 'Preis', exact: true }).fill('79,99');
|
||||
await page.getByLabel('Wo').fill('Amazon');
|
||||
await page.getByRole('button', { name: 'Speichern' }).click();
|
||||
await expect(page.getByText('Amazon')).toBeVisible();
|
||||
await shoot(page, '23-preise');
|
||||
|
||||
// --- 18. reminders --------------------------------------------------------
|
||||
await visit(page, '/verwaltung/erinnerungen');
|
||||
await expect(page.getByText('Erinnerungen').first()).toBeVisible();
|
||||
await shoot(page, '24-erinnerungen');
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* The one path that has to work: register, put a set into the catalog, and
|
||||
* share it on a wishlist that a visitor can reserve from without an account.
|
||||
*/
|
||||
test('Registrieren, Set anlegen, Wunschliste teilen und reservieren', async ({ page, browser }) => {
|
||||
// --- first run: the very first visitor registers and becomes admin --------
|
||||
await page.goto('/');
|
||||
await expect(page).toHaveURL(/\/registrieren/);
|
||||
// Card titles are plain elements, not headings - hence getByText here.
|
||||
await expect(page.getByText('Erstes Konto anlegen')).toBeVisible();
|
||||
|
||||
await page.getByLabel('Name').fill('Florian');
|
||||
await page.getByLabel('E-Mail').fill('test@bricktrack.local');
|
||||
await page.getByLabel('Passwort').fill('smoketest-passwort');
|
||||
await page.getByRole('button', { name: 'Konto anlegen' }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Hallo Florian' })).toBeVisible();
|
||||
|
||||
// --- master data: a set needs a manufacturer -----------------------------
|
||||
await page.goto('/verwaltung/hersteller');
|
||||
await page.getByRole('button', { name: 'Hersteller anlegen' }).click();
|
||||
await page.getByLabel('Name').fill('Lego');
|
||||
await page.getByRole('button', { name: 'Speichern' }).click();
|
||||
await expect(page.getByText('Lego')).toBeVisible();
|
||||
|
||||
// --- catalog: create a set ------------------------------------------------
|
||||
await page.goto('/katalog/neu');
|
||||
await page.getByLabel('Setnummer').fill('60398');
|
||||
await page.getByRole('textbox', { name: 'Name', exact: true }).fill('Familienhaus und Elektroauto');
|
||||
await page.getByLabel('Jahr').fill('2023');
|
||||
await page.getByLabel('UVP').fill('49,99');
|
||||
await page.getByRole('button', { name: 'Set anlegen' }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Familienhaus und Elektroauto' })).toBeVisible();
|
||||
await expect(page.getByText('Fehlt noch')).toBeVisible();
|
||||
|
||||
// --- wishlist: put the set on a fresh list --------------------------------
|
||||
await page.getByRole('button', { name: 'Auf Wunschliste' }).click();
|
||||
await page.getByRole('combobox', { name: 'Wunschliste' }).selectOption('__neu__');
|
||||
await page.getByLabel('Titel der neuen Liste').fill('Weihnachten 2026');
|
||||
await page.getByRole('button', { name: 'Hinzufügen' }).click();
|
||||
|
||||
// Wait for the confirmation, not for the button label that is on screen anyway.
|
||||
await expect(page.getByText('Auf die Wunschliste gesetzt.')).toBeVisible();
|
||||
|
||||
// --- sharing: grab the public link ---------------------------------------
|
||||
await page.goto('/wunschlisten');
|
||||
// Navigate by href: a toast can still be on screen and would swallow the click.
|
||||
const listHref = await page
|
||||
.getByRole('link', { name: /Weihnachten 2026/ })
|
||||
.getAttribute('href');
|
||||
expect(listHref).toMatch(/^\/wunschlisten\//);
|
||||
await page.goto(listHref!);
|
||||
await expect(page.getByRole('heading', { name: 'Weihnachten 2026' })).toBeVisible();
|
||||
|
||||
const shareUrl = await page.getByLabel('Link zum Teilen').inputValue();
|
||||
expect(shareUrl).toContain('/w/');
|
||||
|
||||
// --- the visitor: no account, no session ---------------------------------
|
||||
const visitorContext = await browser.newContext();
|
||||
const visitor = await visitorContext.newPage();
|
||||
await visitor.goto(shareUrl);
|
||||
|
||||
await expect(visitor.getByRole('heading', { name: 'Weihnachten 2026' })).toBeVisible();
|
||||
await expect(visitor.getByText('Familienhaus und Elektroauto')).toBeVisible();
|
||||
|
||||
await visitor.getByRole('button', { name: 'Das schenke ich' }).click();
|
||||
await visitor.getByLabel('Dein Name').fill('Tante Anne');
|
||||
await visitor.getByRole('button', { name: 'Reservieren' }).click();
|
||||
|
||||
await expect(visitor.getByText('Reservierung gespeichert')).toBeVisible();
|
||||
await visitor.getByRole('button', { name: 'Alles klar' }).click();
|
||||
await expect(visitor.getByText('Das schenkst du')).toBeVisible();
|
||||
|
||||
// --- back on the owner side: the reservation shows up ---------------------
|
||||
await page.reload();
|
||||
await expect(page.getByText('Reserviert von Tante Anne')).toBeVisible();
|
||||
|
||||
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 }) => {
|
||||
await page.goto('/sammlung');
|
||||
await expect(page).toHaveURL(/\/anmelden|\/registrieren/);
|
||||
});
|
||||
|
||||
test('Ein unbekannter Share-Token liefert 404', async ({ page }) => {
|
||||
const response = await page.goto('/w/dieser-token-existiert-nicht');
|
||||
expect(response?.status()).toBe(404);
|
||||
});
|
||||
@@ -11,6 +11,17 @@ const compat = new FlatCompat({
|
||||
|
||||
const eslintConfig = [
|
||||
...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: [
|
||||
"node_modules/**",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bricktrack",
|
||||
"version": "0.1.0",
|
||||
"version": "0.4.0",
|
||||
"private": true,
|
||||
"description": "Self-hosted Verwaltung fuer Spielzeug-Set-Sammlungen (Lego, Playmobil, Cobi, ...)",
|
||||
"packageManager": "pnpm@10.34.5",
|
||||
@@ -11,7 +11,7 @@
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "tsx scripts/migrate.ts",
|
||||
@@ -19,45 +19,60 @@
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"screenshots": "playwright test --config=playwright.screenshots.config.ts"
|
||||
"test:e2e": "node scripts/e2e-prepare.mjs && playwright test",
|
||||
"screenshots": "node scripts/screenshots-prepare.mjs && playwright test --config=playwright.screenshots.config.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@better-auth/passkey": "^1.7.3",
|
||||
"better-auth": "^1.7.3",
|
||||
"@better-auth/passkey": "^1.7.12",
|
||||
"@fontsource-variable/archivo": "^5.3.0",
|
||||
"@fontsource-variable/bricolage-grotesque": "^5.3.0",
|
||||
"@fontsource-variable/fredoka": "^5.3.0",
|
||||
"@fontsource-variable/inter": "^5.3.0",
|
||||
"@fontsource-variable/nunito": "^5.3.0",
|
||||
"@fontsource-variable/outfit": "^5.3.0",
|
||||
"@zxing/browser": "^0.2.1",
|
||||
"@zxing/library": "^0.23.0",
|
||||
"better-auth": "^1.7.12",
|
||||
"better-sqlite3": "^13.0.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"cn": "^0.2.6",
|
||||
"date-fns": "^4.4.0",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"drizzle-orm": "^0.45.4",
|
||||
"fflate": "^0.8.3",
|
||||
"lucide-react": "^1.41.0",
|
||||
"next": "15.5.25",
|
||||
"next": "15.5.28",
|
||||
"next-themes": "^0.4.6",
|
||||
"nodemailer": "^10.2.1",
|
||||
"pdfjs-dist": "^6.3.289",
|
||||
"radix-ui": "^1.6.7",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"sharp": "^0.35.4",
|
||||
"server-only": "^0.0.1",
|
||||
"sharp": "^0.35.5",
|
||||
"sonner": "^2.0.8",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"zod": "^4.5.4"
|
||||
"zod": "^4.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@playwright/test": "^1.63.0",
|
||||
"@playwright/test": "^1.64.1",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/better-sqlite3": "^9.6.0",
|
||||
"@types/node": "^20",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"dotenv": "^17.4.2",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"drizzle-kit": "^0.31.11",
|
||||
"esbuild": "^0.25.12",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.5.25",
|
||||
"shadcn": "^4.21.0",
|
||||
"eslint-config-next": "15.5.28",
|
||||
"shadcn": "^4.22.1",
|
||||
"tailwindcss": "^4",
|
||||
"tsx": "^4.23.13",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "^5",
|
||||
"vitest": "^5.0.0"
|
||||
"vite": "^8.3.5",
|
||||
"vitest": "^5.0.5"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
@@ -65,6 +80,20 @@
|
||||
"esbuild",
|
||||
"sharp",
|
||||
"unrs-resolver"
|
||||
]
|
||||
],
|
||||
"overrides": {
|
||||
"postcss": "^8.5.31",
|
||||
"vite": "^8.2.4",
|
||||
"source-map-js": "^1.2.2",
|
||||
"brace-expansion@<2": "^1.1.21",
|
||||
"brace-expansion@>=4 <6": "^5.0.12",
|
||||
"@modelcontextprotocol/sdk": "^1.32.2",
|
||||
"undici@7": "^7.30.1",
|
||||
"proxy-addr": "^2.0.8",
|
||||
"body-parser@2": "^2.4.0",
|
||||
"fast-uri@3": "^3.1.9",
|
||||
"hono": "^4.13.11",
|
||||
"ip-address": "^10.7.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import path from 'node:path';
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Smoke test setup.
|
||||
*
|
||||
* Runs against the production container with its own data directory
|
||||
* (./data-e2e), so a test never touches the real database and never depends on
|
||||
* the dev server compiling fast enough.
|
||||
*/
|
||||
const PORT = 3210;
|
||||
const BASE_URL = `http://localhost:${PORT}`;
|
||||
|
||||
// Docker wants forward slashes, also on Windows.
|
||||
const dataDir = path.resolve(process.cwd(), 'data-e2e').replace(/\\/g, '/');
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
// The screenshot run has its own config, its own server and its own data
|
||||
// directory - it must never join the smoke run.
|
||||
testMatch: 'smoke.spec.ts',
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
retries: 0,
|
||||
timeout: 90_000,
|
||||
expect: { timeout: 15_000 },
|
||||
reporter: process.env.CI ? 'github' : 'list',
|
||||
use: {
|
||||
baseURL: BASE_URL,
|
||||
trace: 'retain-on-failure',
|
||||
locale: 'de-DE',
|
||||
timezoneId: 'Europe/Berlin',
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'mobile',
|
||||
// The app is built for phones first, so the smoke test uses one.
|
||||
use: { ...devices['Pixel 7'] },
|
||||
},
|
||||
],
|
||||
webServer: {
|
||||
command: [
|
||||
'docker run --rm --name bricktrack-e2e',
|
||||
`-p ${PORT}:3000`,
|
||||
`-e BASE_URL=${BASE_URL}`,
|
||||
'-e BETTER_AUTH_SECRET=playwright-smoke-test-secret-0123456789abcdef',
|
||||
'-e TZ=Europe/Berlin',
|
||||
'-e MAX_UPLOAD_MB=25',
|
||||
`-v "${dataDir}:/data"`,
|
||||
'bricktrack:e2e',
|
||||
].join(' '),
|
||||
url: `${BASE_URL}/api/health`,
|
||||
reuseExistingServer: false,
|
||||
timeout: 180_000,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import path from 'node:path';
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Produces the screenshots for docs/ANLEITUNG.md.
|
||||
*
|
||||
* Runs against the production container, not the dev server: the dev server
|
||||
* compiles routes on demand, which made the capture run flaky, and the manual
|
||||
* should show exactly what a user gets after `docker compose up`.
|
||||
*
|
||||
* pnpm screenshots
|
||||
*/
|
||||
const PORT = 3220;
|
||||
const BASE_URL = `http://localhost:${PORT}`;
|
||||
|
||||
// Docker wants forward slashes, also on Windows.
|
||||
const dataDir = path.resolve(process.cwd(), 'data-shots').replace(/\\/g, '/');
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
testMatch: 'screenshots.spec.ts',
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
// The run registers a user, so a second attempt would start from a state
|
||||
// that no longer matches the script.
|
||||
retries: 0,
|
||||
timeout: 300_000,
|
||||
expect: { timeout: 30_000 },
|
||||
reporter: 'list',
|
||||
use: {
|
||||
baseURL: BASE_URL,
|
||||
locale: 'de-DE',
|
||||
timezoneId: 'Europe/Berlin',
|
||||
// A phone, because that is how the app is actually used.
|
||||
...devices['Pixel 7'],
|
||||
},
|
||||
webServer: {
|
||||
command: [
|
||||
'docker run --rm --name bricktrack-shots',
|
||||
`-p ${PORT}:3000`,
|
||||
`-e BASE_URL=${BASE_URL}`,
|
||||
'-e BETTER_AUTH_SECRET=screenshot-run-secret-0123456789abcdefgh',
|
||||
'-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"`,
|
||||
'bricktrack:shots',
|
||||
].join(' '),
|
||||
url: `${BASE_URL}/api/health`,
|
||||
reuseExistingServer: false,
|
||||
timeout: 180_000,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
},
|
||||
});
|
||||
|
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)');
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Prepares the screenshot run.
|
||||
*
|
||||
* The pictures are taken against the production image rather than the dev
|
||||
* server: the dev server recompiles routes on demand, which made the capture
|
||||
* run flaky, and the manual should show what people actually run.
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const dataDir = path.resolve(process.cwd(), 'data-shots');
|
||||
|
||||
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(`[screenshots] ${dataDir} zurueckgesetzt.`);
|
||||
|
||||
// A container left behind by an aborted run would hold the port.
|
||||
try {
|
||||
execFileSync('docker', ['rm', '-f', 'bricktrack-shots'], { stdio: 'ignore' });
|
||||
} catch {
|
||||
// Nothing to remove - the normal case.
|
||||
}
|
||||
|
||||
console.log('[screenshots] Image wird gebaut (bricktrack:shots) …');
|
||||
execFileSync('docker', ['build', '-t', 'bricktrack:shots', '.'], { stdio: 'inherit' });
|
||||
console.log('[screenshots] Image ist bereit.');
|
||||
@@ -0,0 +1,480 @@
|
||||
/**
|
||||
* Example data so a fresh instance is not an empty page.
|
||||
*
|
||||
* Safe to run more than once: everything is matched by its natural key
|
||||
* (manufacturer slug, set number) and only inserted when missing.
|
||||
*
|
||||
* Run with `pnpm db:seed` after `pnpm db:migrate`.
|
||||
*/
|
||||
import path from 'node:path';
|
||||
import Database from 'better-sqlite3';
|
||||
import { config as loadEnv } from 'dotenv';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
||||
import * as schema from '../src/db/schema';
|
||||
|
||||
loadEnv({ path: '.env', quiet: true });
|
||||
|
||||
const dataDir = path.resolve(process.env.DATA_DIR ?? './data');
|
||||
const sqlite = new Database(path.join(dataDir, 'db', 'bricktrack.db'));
|
||||
sqlite.pragma('foreign_keys = ON');
|
||||
const db = drizzle(sqlite, { schema });
|
||||
|
||||
const now = new Date();
|
||||
const id = () => crypto.randomUUID();
|
||||
|
||||
type SeedSet = {
|
||||
manufacturer: string;
|
||||
theme: string;
|
||||
setNumber: string;
|
||||
name: string;
|
||||
year: number;
|
||||
pieceCount: number;
|
||||
minifigCount: number | null;
|
||||
retailPriceCents: number;
|
||||
ageRange: string;
|
||||
/** Copies owned - absent means the set is only in the catalog. */
|
||||
owned?: {
|
||||
status: 'owned' | 'built' | 'disassembled' | 'lent_out' | 'sold';
|
||||
completeness: 'complete' | 'missing_parts' | 'unknown';
|
||||
location: string;
|
||||
collector: string;
|
||||
giftedBy?: string;
|
||||
};
|
||||
};
|
||||
|
||||
const MANUFACTURERS = [
|
||||
{ name: 'Lego', slug: 'lego', website: 'https://www.lego.com/de-de' },
|
||||
{ name: 'Playmobil', slug: 'playmobil', website: 'https://www.playmobil.de' },
|
||||
{ name: 'Cobi', slug: 'cobi', website: 'https://cobi.de' },
|
||||
];
|
||||
|
||||
const THEMES: Array<{ manufacturer: string; name: string }> = [
|
||||
{ manufacturer: 'Lego', name: 'City' },
|
||||
{ manufacturer: 'Lego', name: 'Technic' },
|
||||
{ manufacturer: 'Lego', name: 'Ninjago' },
|
||||
{ manufacturer: 'Playmobil', name: 'Novelmore' },
|
||||
{ manufacturer: 'Playmobil', name: 'City Action' },
|
||||
{ manufacturer: 'Cobi', name: 'Historical Collection' },
|
||||
];
|
||||
|
||||
const LOCATIONS = ['Regal Kinderzimmer', 'Kiste Keller 3', 'Vitrine Wohnzimmer'];
|
||||
|
||||
// Two collectors, so the shared catalog and the separate collections are
|
||||
// visible right away. With only one, the whole concept stays hidden in the UI.
|
||||
const COLLECTORS = [
|
||||
{ name: 'Max', color: 'blue' as const },
|
||||
{ name: 'Mia', color: 'rose' as const },
|
||||
];
|
||||
|
||||
const SETS: SeedSet[] = [
|
||||
{
|
||||
manufacturer: 'Lego',
|
||||
theme: 'City',
|
||||
setNumber: '60398',
|
||||
name: 'Familienhaus und Elektroauto',
|
||||
year: 2023,
|
||||
pieceCount: 462,
|
||||
minifigCount: 4,
|
||||
retailPriceCents: 4999,
|
||||
ageRange: '6-12',
|
||||
owned: {
|
||||
status: 'built',
|
||||
completeness: 'complete',
|
||||
location: 'Regal Kinderzimmer',
|
||||
collector: 'Max',
|
||||
},
|
||||
},
|
||||
{
|
||||
manufacturer: 'Lego',
|
||||
theme: 'City',
|
||||
setNumber: '60321',
|
||||
name: 'Feuerwehreinsatz',
|
||||
year: 2022,
|
||||
pieceCount: 766,
|
||||
minifigCount: 6,
|
||||
retailPriceCents: 7999,
|
||||
ageRange: '6-12',
|
||||
owned: {
|
||||
status: 'owned',
|
||||
completeness: 'missing_parts',
|
||||
location: 'Kiste Keller 3',
|
||||
collector: 'Max',
|
||||
giftedBy: 'Oma Helga',
|
||||
},
|
||||
},
|
||||
{
|
||||
manufacturer: 'Lego',
|
||||
theme: 'Technic',
|
||||
setNumber: '42147',
|
||||
name: 'Kipplaster',
|
||||
year: 2022,
|
||||
pieceCount: 177,
|
||||
minifigCount: null,
|
||||
retailPriceCents: 999,
|
||||
ageRange: '7-14',
|
||||
owned: {
|
||||
status: 'disassembled',
|
||||
completeness: 'unknown',
|
||||
location: 'Kiste Keller 3',
|
||||
collector: 'Mia',
|
||||
},
|
||||
},
|
||||
{
|
||||
manufacturer: 'Lego',
|
||||
theme: 'Technic',
|
||||
setNumber: '42172',
|
||||
name: 'McLaren P1',
|
||||
year: 2024,
|
||||
pieceCount: 3893,
|
||||
minifigCount: null,
|
||||
retailPriceCents: 44999,
|
||||
ageRange: '18+',
|
||||
},
|
||||
{
|
||||
manufacturer: 'Lego',
|
||||
theme: 'Ninjago',
|
||||
setNumber: '71427',
|
||||
name: 'Lloyds Elementardrachen',
|
||||
year: 2023,
|
||||
pieceCount: 685,
|
||||
minifigCount: 4,
|
||||
retailPriceCents: 5999,
|
||||
ageRange: '8-14',
|
||||
},
|
||||
{
|
||||
manufacturer: 'Lego',
|
||||
theme: 'Ninjago',
|
||||
setNumber: '71799',
|
||||
name: 'Ninjago City Märkte',
|
||||
year: 2023,
|
||||
pieceCount: 6163,
|
||||
minifigCount: 21,
|
||||
retailPriceCents: 39999,
|
||||
ageRange: '18+',
|
||||
},
|
||||
{
|
||||
manufacturer: 'Playmobil',
|
||||
theme: 'Novelmore',
|
||||
setNumber: '70392',
|
||||
name: 'Novelmore Festung',
|
||||
year: 2020,
|
||||
pieceCount: 293,
|
||||
minifigCount: 5,
|
||||
retailPriceCents: 12999,
|
||||
ageRange: '4-10',
|
||||
owned: {
|
||||
status: 'built',
|
||||
completeness: 'complete',
|
||||
location: 'Regal Kinderzimmer',
|
||||
collector: 'Mia',
|
||||
},
|
||||
},
|
||||
{
|
||||
manufacturer: 'Playmobil',
|
||||
theme: 'City Action',
|
||||
setNumber: '70936',
|
||||
name: 'Feuerwehr-Fahrzeug Ultimate Rescue Truck',
|
||||
year: 2021,
|
||||
pieceCount: 178,
|
||||
minifigCount: 2,
|
||||
retailPriceCents: 8999,
|
||||
ageRange: '5-10',
|
||||
},
|
||||
{
|
||||
manufacturer: 'Cobi',
|
||||
theme: 'Historical Collection',
|
||||
setNumber: '2-30500',
|
||||
name: 'Ford Mustang GT',
|
||||
year: 2022,
|
||||
pieceCount: 250,
|
||||
minifigCount: null,
|
||||
retailPriceCents: 2999,
|
||||
ageRange: '8+',
|
||||
},
|
||||
{
|
||||
manufacturer: 'Cobi',
|
||||
theme: 'Historical Collection',
|
||||
setNumber: '2-30501',
|
||||
name: 'Feuerwehrauto Magirus',
|
||||
year: 2023,
|
||||
pieceCount: 400,
|
||||
minifigCount: 1,
|
||||
retailPriceCents: 3999,
|
||||
ageRange: '8+',
|
||||
},
|
||||
];
|
||||
|
||||
function seedManufacturers() {
|
||||
const byName = new Map<string, string>();
|
||||
|
||||
for (const entry of MANUFACTURERS) {
|
||||
const existing = db
|
||||
.select({ id: schema.manufacturer.id })
|
||||
.from(schema.manufacturer)
|
||||
.where(eq(schema.manufacturer.slug, entry.slug))
|
||||
.get();
|
||||
|
||||
if (existing) {
|
||||
byName.set(entry.name, existing.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
const newId = id();
|
||||
db.insert(schema.manufacturer)
|
||||
.values({ id: newId, ...entry, createdAt: now, updatedAt: now })
|
||||
.run();
|
||||
byName.set(entry.name, newId);
|
||||
}
|
||||
|
||||
return byName;
|
||||
}
|
||||
|
||||
function seedThemes(manufacturers: Map<string, string>) {
|
||||
const byKey = new Map<string, string>();
|
||||
|
||||
for (const entry of THEMES) {
|
||||
const manufacturerId = manufacturers.get(entry.manufacturer);
|
||||
if (!manufacturerId) continue;
|
||||
|
||||
const existing = db
|
||||
.select({ id: schema.theme.id, name: schema.theme.name })
|
||||
.from(schema.theme)
|
||||
.where(eq(schema.theme.manufacturerId, manufacturerId))
|
||||
.all()
|
||||
.find((row) => row.name === entry.name);
|
||||
|
||||
if (existing) {
|
||||
byKey.set(`${entry.manufacturer}/${entry.name}`, existing.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
const newId = id();
|
||||
db.insert(schema.theme)
|
||||
.values({
|
||||
id: newId,
|
||||
manufacturerId,
|
||||
name: entry.name,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.run();
|
||||
byKey.set(`${entry.manufacturer}/${entry.name}`, newId);
|
||||
}
|
||||
|
||||
return byKey;
|
||||
}
|
||||
|
||||
function seedCollectors() {
|
||||
const byName = new Map<string, string>();
|
||||
|
||||
COLLECTORS.forEach((entry, index) => {
|
||||
const all = db
|
||||
.select({ id: schema.collector.id, name: schema.collector.name })
|
||||
.from(schema.collector)
|
||||
.all();
|
||||
|
||||
const existing = all.find((row) => row.name === entry.name);
|
||||
if (existing) {
|
||||
byName.set(entry.name, existing.id);
|
||||
return;
|
||||
}
|
||||
|
||||
// A fresh install already has the collector the migration created. Rather
|
||||
// than adding a third one next to it, the seed takes it over.
|
||||
const untouchedDefault = all.find(
|
||||
(row) =>
|
||||
row.name === 'Sammlung' &&
|
||||
!db
|
||||
.select({ id: schema.collectionItem.id })
|
||||
.from(schema.collectionItem)
|
||||
.where(eq(schema.collectionItem.collectorId, row.id))
|
||||
.get(),
|
||||
);
|
||||
|
||||
if (untouchedDefault) {
|
||||
db.update(schema.collector)
|
||||
.set({ name: entry.name, color: entry.color, sortOrder: index, updatedAt: now })
|
||||
.where(eq(schema.collector.id, untouchedDefault.id))
|
||||
.run();
|
||||
byName.set(entry.name, untouchedDefault.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const newId = id();
|
||||
db.insert(schema.collector)
|
||||
.values({
|
||||
id: newId,
|
||||
name: entry.name,
|
||||
color: entry.color,
|
||||
sortOrder: index,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.run();
|
||||
byName.set(entry.name, newId);
|
||||
});
|
||||
|
||||
return byName;
|
||||
}
|
||||
|
||||
function seedLocations() {
|
||||
const byName = new Map<string, string>();
|
||||
|
||||
for (const name of LOCATIONS) {
|
||||
const existing = db
|
||||
.select({ id: schema.location.id })
|
||||
.from(schema.location)
|
||||
.where(eq(schema.location.name, name))
|
||||
.get();
|
||||
|
||||
if (existing) {
|
||||
byName.set(name, existing.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
const newId = id();
|
||||
db.insert(schema.location)
|
||||
.values({ id: newId, name, createdAt: now, updatedAt: now })
|
||||
.run();
|
||||
byName.set(name, newId);
|
||||
}
|
||||
|
||||
return byName;
|
||||
}
|
||||
|
||||
function seedSets(
|
||||
manufacturers: Map<string, string>,
|
||||
themes: Map<string, string>,
|
||||
locations: Map<string, string>,
|
||||
collectors: Map<string, string>,
|
||||
) {
|
||||
const setIds = new Map<string, string>();
|
||||
|
||||
for (const entry of SETS) {
|
||||
const manufacturerId = manufacturers.get(entry.manufacturer);
|
||||
if (!manufacturerId) continue;
|
||||
|
||||
const existing = db
|
||||
.select({ id: schema.set.id, setNumber: schema.set.setNumber })
|
||||
.from(schema.set)
|
||||
.where(eq(schema.set.manufacturerId, manufacturerId))
|
||||
.all()
|
||||
.find((row) => row.setNumber === entry.setNumber);
|
||||
|
||||
if (existing) {
|
||||
setIds.set(entry.setNumber, existing.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
const newId = id();
|
||||
db.insert(schema.set)
|
||||
.values({
|
||||
id: newId,
|
||||
manufacturerId,
|
||||
themeId: themes.get(`${entry.manufacturer}/${entry.theme}`) ?? null,
|
||||
setNumber: entry.setNumber,
|
||||
name: entry.name,
|
||||
year: entry.year,
|
||||
pieceCount: entry.pieceCount,
|
||||
minifigCount: entry.minifigCount,
|
||||
ageRange: entry.ageRange,
|
||||
retailPriceCents: entry.retailPriceCents,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.run();
|
||||
setIds.set(entry.setNumber, newId);
|
||||
|
||||
if (entry.owned) {
|
||||
db.insert(schema.collectionItem)
|
||||
.values({
|
||||
id: id(),
|
||||
setId: newId,
|
||||
status: entry.owned.status,
|
||||
completeness: entry.owned.completeness,
|
||||
condition: 'good',
|
||||
locationId: locations.get(entry.owned.location) ?? null,
|
||||
collectorId: collectors.get(entry.owned.collector) ?? null,
|
||||
acquiredAt: new Date(entry.year, 11, 24),
|
||||
acquiredPriceCents: entry.retailPriceCents,
|
||||
giftedBy: entry.owned.giftedBy ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.run();
|
||||
}
|
||||
}
|
||||
|
||||
return setIds;
|
||||
}
|
||||
|
||||
function seedWishlist(setIds: Map<string, string>, collectors: Map<string, string>) {
|
||||
const owner = db.select({ id: schema.user.id }).from(schema.user).get();
|
||||
if (!owner) {
|
||||
console.log('[seed] Kein Nutzer vorhanden - die Beispiel-Wunschliste wird uebersprungen.');
|
||||
console.log('[seed] Registriere dich zuerst in der App und starte den Seed danach erneut.');
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = db
|
||||
.select({ id: schema.wishlist.id })
|
||||
.from(schema.wishlist)
|
||||
.all()
|
||||
.find((row) => row.id.length > 0);
|
||||
if (existing) {
|
||||
console.log('[seed] Es gibt bereits eine Wunschliste - unveraendert gelassen.');
|
||||
return;
|
||||
}
|
||||
|
||||
const wishlistId = id();
|
||||
db.insert(schema.wishlist)
|
||||
.values({
|
||||
id: wishlistId,
|
||||
ownerUserId: owner.id,
|
||||
collectorId: collectors.get('Max') ?? null,
|
||||
title: 'Weihnachten 2026',
|
||||
occasion: 'christmas',
|
||||
eventDate: new Date(2026, 11, 24),
|
||||
shareToken: Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url'),
|
||||
isPublic: true,
|
||||
surpriseMode: false,
|
||||
note: 'Bitte keine Doppelungen - was reserviert ist, steht schon fest.',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.run();
|
||||
|
||||
const wanted = ['71427', '42172', '70936'];
|
||||
wanted.forEach((setNumber, index) => {
|
||||
const setId = setIds.get(setNumber);
|
||||
if (!setId) return;
|
||||
|
||||
db.insert(schema.wishlistItem)
|
||||
.values({
|
||||
id: id(),
|
||||
wishlistId,
|
||||
setId,
|
||||
priority: index === 0 ? 1 : 2,
|
||||
sortOrder: index,
|
||||
createdAt: now,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.run();
|
||||
});
|
||||
}
|
||||
|
||||
console.log('[seed] Beispieldaten werden eingespielt …');
|
||||
|
||||
const manufacturers = seedManufacturers();
|
||||
const themes = seedThemes(manufacturers);
|
||||
const locations = seedLocations();
|
||||
const collectors = seedCollectors();
|
||||
const setIds = seedSets(manufacturers, themes, locations, collectors);
|
||||
seedWishlist(setIds, collectors);
|
||||
|
||||
console.log(
|
||||
`[seed] Fertig: ${collectors.size} Sammler, ${manufacturers.size} Hersteller, ` +
|
||||
`${themes.size} Themen, ${setIds.size} Sets.`,
|
||||
);
|
||||
sqlite.close();
|
||||
@@ -0,0 +1,38 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { cookies } from 'next/headers';
|
||||
import { z } from 'zod';
|
||||
import { env } from '@/env';
|
||||
import { actionError, actionOk, type ActionResult } from '@/lib/action-result';
|
||||
import { COLLECTOR_COOKIE } from '@/lib/collector-choice';
|
||||
import { resolveCollectorId } from '@/lib/queries/collectors';
|
||||
import { requireUser } from '@/lib/session';
|
||||
|
||||
const schema = z.object({ id: z.string().max(64) });
|
||||
|
||||
/** Remembers the collector chosen in the header; an empty id means "alle". */
|
||||
export async function chooseCollectorAction(input: unknown): Promise<ActionResult> {
|
||||
await requireUser();
|
||||
|
||||
const parsed = schema.safeParse(input);
|
||||
if (!parsed.success) return actionError('Ungültige Auswahl.');
|
||||
|
||||
const store = await cookies();
|
||||
if (parsed.data.id === '') {
|
||||
store.delete(COLLECTOR_COOKIE);
|
||||
} else {
|
||||
const id = resolveCollectorId(parsed.data.id);
|
||||
if (!id) return actionError('Diesen Sammler gibt es nicht mehr.');
|
||||
store.set(COLLECTOR_COOKIE, id, {
|
||||
httpOnly: true,
|
||||
secure: env.isProduction,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
maxAge: 60 * 60 * 24 * 365,
|
||||
});
|
||||
}
|
||||
|
||||
revalidatePath('/', 'layout');
|
||||
return actionOk();
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { SetForm } from '@/components/katalog/set-form';
|
||||
import { getSetById } from '@/lib/queries/catalog';
|
||||
import { listManufacturers, listThemes } from '@/lib/queries/master-data';
|
||||
import { requireUser } from '@/lib/session';
|
||||
|
||||
export const metadata: Metadata = { title: 'Set bearbeiten' };
|
||||
|
||||
export default async function SetBearbeitenPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
await requireUser();
|
||||
const { id } = await params;
|
||||
|
||||
const set = getSetById(id);
|
||||
if (!set) notFound();
|
||||
|
||||
const manufacturers = listManufacturers().map((row) => ({ id: row.id, name: row.name }));
|
||||
const themes = listThemes().map((row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
manufacturerId: row.manufacturerId,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Set bearbeiten</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{set.setNumber} · {set.name}
|
||||
</p>
|
||||
</div>
|
||||
<SetForm
|
||||
manufacturers={manufacturers}
|
||||
themes={themes}
|
||||
set={{
|
||||
id: set.id,
|
||||
manufacturerId: set.manufacturerId,
|
||||
themeId: set.themeId,
|
||||
setNumber: set.setNumber,
|
||||
name: set.name,
|
||||
year: set.year,
|
||||
pieceCount: set.pieceCount,
|
||||
minifigCount: set.minifigCount,
|
||||
ean: set.ean,
|
||||
ageRange: set.ageRange,
|
||||
imageUrl: set.imageUrl,
|
||||
imagePath: set.imagePath,
|
||||
retailPriceCents: set.retailPriceCents,
|
||||
notes: set.notes,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { Check, ExternalLink, Gift, Pencil } from 'lucide-react';
|
||||
import { SetPlaceholder } from '@/components/brick/set-placeholder';
|
||||
import { Studs } from '@/components/brick/studs';
|
||||
import { InstructionsSection } from '@/components/katalog/instructions-section';
|
||||
import { PhotosSection } from '@/components/katalog/photos-section';
|
||||
import { PriceSection } from '@/components/katalog/price-section';
|
||||
import { SetActions } from '@/components/katalog/set-actions';
|
||||
import { CollectionItemsSection } from '@/components/sammlung/collection-items-section';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { formatNumber } from '@/lib/format';
|
||||
import { setImageSrc } from '@/lib/images';
|
||||
import { formatEuro } from '@/lib/money';
|
||||
import {
|
||||
getSetById,
|
||||
listCollectionItemsForSet,
|
||||
listInstructionsForSet,
|
||||
listPhotosForSet,
|
||||
listPricePoints,
|
||||
} from '@/lib/queries/catalog';
|
||||
import { listMissingParts } from '@/lib/queries/collection';
|
||||
import { activeCollectorId } from '@/lib/collector-choice';
|
||||
import { listCollectors } from '@/lib/queries/collectors';
|
||||
import { listLocations } from '@/lib/queries/master-data';
|
||||
import { listWishlistsForSet, listWishlistTargets } from '@/lib/queries/wishlists';
|
||||
import { requireUser } from '@/lib/session';
|
||||
|
||||
type Props = {
|
||||
params: Promise<{ id: string }>;
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
};
|
||||
|
||||
export async function generateMetadata({ params }: Pick<Props, 'params'>): Promise<Metadata> {
|
||||
const { id } = await params;
|
||||
const set = getSetById(id);
|
||||
return { title: set ? `${set.setNumber} · ${set.name}` : 'Set' };
|
||||
}
|
||||
|
||||
export default async function SetDetailPage({ params, searchParams }: Props) {
|
||||
await requireUser();
|
||||
const { id } = await params;
|
||||
const query = await searchParams;
|
||||
|
||||
// Keeps the "vorhanden / fehlt" badge in step with the collector the user
|
||||
// was looking at in the catalog.
|
||||
const rawCollector = Array.isArray(query.sammler) ? query.sammler[0] : query.sammler;
|
||||
const collectorId = await activeCollectorId(rawCollector);
|
||||
const collectors = listCollectors();
|
||||
|
||||
const set = getSetById(id, collectorId);
|
||||
if (!set) notFound();
|
||||
|
||||
const image = setImageSrc(set);
|
||||
const owned = set.ownedCopies > 0;
|
||||
const locations = listLocations().map((row) => ({ id: row.id, name: row.name }));
|
||||
const locationNames = new Map(locations.map((row) => [row.id, row.name]));
|
||||
|
||||
const collectorsById = new Map(collectors.map((row) => [row.id, row]));
|
||||
const collectionItems = listCollectionItemsForSet(set.id).map((item) => ({
|
||||
...item,
|
||||
locationName: item.locationId ? (locationNames.get(item.locationId) ?? null) : null,
|
||||
setName: `${set.setNumber} · ${set.name}`,
|
||||
missingParts: listMissingParts(item.id),
|
||||
collectorName: item.collectorId ? (collectorsById.get(item.collectorId)?.name ?? null) : null,
|
||||
collectorColor: item.collectorId
|
||||
? (collectorsById.get(item.collectorId)?.color ?? null)
|
||||
: null,
|
||||
}));
|
||||
|
||||
const facts: Array<[string, string]> = [
|
||||
['Hersteller', set.manufacturerName],
|
||||
['Thema', set.themeName ?? '–'],
|
||||
['Setnummer', set.setNumber],
|
||||
['Jahr', set.year ? String(set.year) : '–'],
|
||||
['Teile', formatNumber(set.pieceCount)],
|
||||
['Minifiguren', formatNumber(set.minifigCount)],
|
||||
['Alter', set.ageRange ?? '–'],
|
||||
['EAN', set.ean ?? '–'],
|
||||
['UVP', formatEuro(set.retailPriceCents)],
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline" className="font-mono">
|
||||
{set.setNumber}
|
||||
</Badge>
|
||||
<Badge variant="secondary">{set.manufacturerName}</Badge>
|
||||
{owned ? (
|
||||
<Badge className="gap-1 bg-emerald-600 text-white hover:bg-emerald-600">
|
||||
<Check className="size-3" aria-hidden />
|
||||
{set.ownedCopies > 1 ? `${set.ownedCopies}× vorhanden` : 'Vorhanden'}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">Fehlt noch</Badge>
|
||||
)}
|
||||
{set.wishlistCount > 0 ? (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Gift className="size-3" aria-hidden />
|
||||
Auf Wunschliste
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<h1 className="mt-2 text-2xl font-semibold tracking-tight">{set.name}</h1>
|
||||
</div>
|
||||
<Button asChild variant="outline" size="icon-lg" aria-label="Set bearbeiten">
|
||||
<Link href={`/katalog/${set.id}/bearbeiten`}>
|
||||
<Pencil />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-[minmax(0,320px)_1fr]">
|
||||
<div className="relative self-start">
|
||||
<Studs count={4} />
|
||||
<div className="brick overflow-hidden rounded-xl bg-muted">
|
||||
{image ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={image} alt="" className="aspect-square w-full object-contain p-3" />
|
||||
) : (
|
||||
<SetPlaceholder
|
||||
setNumber={set.setNumber}
|
||||
manufacturerName={set.manufacturerName}
|
||||
size="lg"
|
||||
className="aspect-square"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<SetActions
|
||||
setId={set.id}
|
||||
setName={`${set.setNumber} · ${set.name}`}
|
||||
owned={owned}
|
||||
wishlists={listWishlistTargets()}
|
||||
collectors={collectors.map((row) => ({ id: row.id, name: row.name }))}
|
||||
/>
|
||||
|
||||
<dl className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm sm:grid-cols-3">
|
||||
{facts.map(([label, value]) => (
|
||||
<div key={label}>
|
||||
<dt className="text-xs text-muted-foreground">{label}</dt>
|
||||
<dd className="font-medium">{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
|
||||
{set.manufacturerWebsite ? (
|
||||
<Button asChild variant="link" size="sm" className="px-0">
|
||||
<a href={set.manufacturerWebsite} target="_blank" rel="noreferrer noopener">
|
||||
Zum Hersteller
|
||||
<ExternalLink />
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{set.notes ? (
|
||||
<p className="rounded-lg bg-muted p-3 text-sm whitespace-pre-line">{set.notes}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>In der Sammlung</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CollectionItemsSection
|
||||
items={collectionItems}
|
||||
locations={locations}
|
||||
collectors={collectors.map((row) => ({ id: row.id, name: row.name }))}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Preis und Wertentwicklung</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<PriceSection
|
||||
setId={set.id}
|
||||
prices={listPricePoints(set.id)}
|
||||
retailPriceCents={set.retailPriceCents}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Anleitungen</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<InstructionsSection setId={set.id} instructions={listInstructionsForSet(set.id)} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Fotos</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<PhotosSection setId={set.id} photos={listPhotosForSet(set.id)} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<WishlistMemberships setId={set.id} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WishlistMemberships({ setId }: { setId: string }) {
|
||||
const lists = listWishlistsForSet(setId);
|
||||
if (lists.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Auf diesen Wunschlisten</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap gap-2">
|
||||
{lists.map((list) => (
|
||||
<Button key={list.id} asChild variant="outline" size="sm">
|
||||
<Link href={`/wunschlisten/${list.id}`}>
|
||||
<Gift />
|
||||
{list.title}
|
||||
</Link>
|
||||
</Button>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { and, eq, ne } from 'drizzle-orm';
|
||||
import { db } from '@/db';
|
||||
import { instruction, photo, set } from '@/db/schema';
|
||||
import { actionData, actionError, actionOk, formToObject, parseForm, type ActionResult } from '@/lib/action-result';
|
||||
import { env } from '@/env';
|
||||
import { safeFetch, SafeFetchError } from '@/lib/safe-fetch';
|
||||
import { requireUser } from '@/lib/session';
|
||||
import { deleteStoredFile, storeImage, storeImageBytes, storePdf, UploadError } from '@/lib/uploads';
|
||||
import { optionalText, optionalUrl, setSchema } from '@/lib/validation';
|
||||
import { z } from 'zod';
|
||||
|
||||
function now() {
|
||||
return new Date();
|
||||
}
|
||||
|
||||
function revalidateSet(id?: string) {
|
||||
revalidatePath('/katalog');
|
||||
revalidatePath('/sammlung');
|
||||
if (id) revalidatePath(`/katalog/${id}`);
|
||||
}
|
||||
|
||||
async function cacheRemoteImage(url: string, setId: string): Promise<string | null> {
|
||||
try {
|
||||
const response = await safeFetch(url, {
|
||||
accept: 'image/webp,image/png,image/jpeg;q=0.9,*/*;q=0.5',
|
||||
maxBytes: env.MAX_UPLOAD_BYTES,
|
||||
});
|
||||
if (response.status !== 200) return null;
|
||||
return (await storeImageBytes(response.body, 'set-image', setId)).filePath;
|
||||
} catch (error) {
|
||||
if (error instanceof SafeFetchError || error instanceof UploadError) {
|
||||
console.error('[katalog] Bild konnte nicht übernommen werden', url, error.message);
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function fileFrom(formData: FormData, field: string): File | null {
|
||||
const value = formData.get(field);
|
||||
return value instanceof File && value.size > 0 ? value : null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sets
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function createSet(formData: FormData): Promise<ActionResult<{ id: string }>> {
|
||||
await requireUser();
|
||||
|
||||
const parsed = parseForm(setSchema, formToObject(formData));
|
||||
if (!parsed.ok) return parsed;
|
||||
|
||||
const clash = db
|
||||
.select({ id: set.id })
|
||||
.from(set)
|
||||
.where(
|
||||
and(
|
||||
eq(set.manufacturerId, parsed.data.manufacturerId),
|
||||
eq(set.setNumber, parsed.data.setNumber),
|
||||
),
|
||||
)
|
||||
.get();
|
||||
if (clash) {
|
||||
return actionError('Dieses Set gibt es beim gewählten Hersteller schon.');
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
let imagePath: string | null = null;
|
||||
|
||||
const image = fileFrom(formData, 'image');
|
||||
if (image) {
|
||||
try {
|
||||
imagePath = (await storeImage(image, 'set-image', id)).filePath;
|
||||
} catch (error) {
|
||||
if (error instanceof UploadError) return actionError(error.message);
|
||||
throw error;
|
||||
}
|
||||
} else if (formData.get('cacheImage') === 'on' && parsed.data.imageUrl) {
|
||||
// A picture found by the link import is kept locally, like the Lego
|
||||
// import does. If that fails, the URL stays as the fallback.
|
||||
imagePath = await cacheRemoteImage(parsed.data.imageUrl, id);
|
||||
}
|
||||
|
||||
db.insert(set)
|
||||
.values({
|
||||
id,
|
||||
...parsed.data,
|
||||
imageUrl: imagePath ? null : parsed.data.imageUrl,
|
||||
imagePath,
|
||||
createdAt: now(),
|
||||
updatedAt: now(),
|
||||
})
|
||||
.run();
|
||||
|
||||
revalidateSet(id);
|
||||
return actionData({ id });
|
||||
}
|
||||
|
||||
export async function updateSet(id: string, formData: FormData): Promise<ActionResult> {
|
||||
await requireUser();
|
||||
|
||||
const parsed = parseForm(setSchema, formToObject(formData));
|
||||
if (!parsed.ok) return parsed;
|
||||
|
||||
const existing = db.select().from(set).where(eq(set.id, id)).get();
|
||||
if (!existing) return actionError('Das Set gibt es nicht mehr.');
|
||||
|
||||
const clash = db
|
||||
.select({ id: set.id })
|
||||
.from(set)
|
||||
.where(
|
||||
and(
|
||||
eq(set.manufacturerId, parsed.data.manufacturerId),
|
||||
eq(set.setNumber, parsed.data.setNumber),
|
||||
ne(set.id, id),
|
||||
),
|
||||
)
|
||||
.get();
|
||||
if (clash) return actionError('Ein anderes Set des Herstellers hat diese Nummer bereits.');
|
||||
|
||||
let imagePath = existing.imagePath;
|
||||
const image = fileFrom(formData, 'image');
|
||||
if (image) {
|
||||
try {
|
||||
const stored = await storeImage(image, 'set-image', id);
|
||||
await deleteStoredFile(existing.imagePath);
|
||||
imagePath = stored.filePath;
|
||||
} catch (error) {
|
||||
if (error instanceof UploadError) return actionError(error.message);
|
||||
throw error;
|
||||
}
|
||||
} else if (formData.get('removeImage') === 'on') {
|
||||
await deleteStoredFile(existing.imagePath);
|
||||
imagePath = null;
|
||||
}
|
||||
|
||||
db.update(set)
|
||||
.set({ ...parsed.data, imagePath, updatedAt: now() })
|
||||
.where(eq(set.id, id))
|
||||
.run();
|
||||
|
||||
revalidateSet(id);
|
||||
return actionOk();
|
||||
}
|
||||
|
||||
export async function deleteSet(id: string): Promise<ActionResult> {
|
||||
await requireUser();
|
||||
|
||||
const existing = db.select().from(set).where(eq(set.id, id)).get();
|
||||
if (!existing) return actionOk();
|
||||
|
||||
const files = [
|
||||
existing.imagePath,
|
||||
...db
|
||||
.select({ path: photo.filePath, thumb: photo.thumbnailPath })
|
||||
.from(photo)
|
||||
.where(eq(photo.setId, id))
|
||||
.all()
|
||||
.flatMap((row) => [row.path, row.thumb]),
|
||||
...db
|
||||
.select({ path: instruction.filePath })
|
||||
.from(instruction)
|
||||
.where(eq(instruction.setId, id))
|
||||
.all()
|
||||
.map((row) => row.path),
|
||||
];
|
||||
|
||||
// Rows go first: if a file is already gone the delete still has to succeed.
|
||||
db.delete(set).where(eq(set.id, id)).run();
|
||||
await Promise.all(files.map((file) => deleteStoredFile(file)));
|
||||
|
||||
revalidateSet(id);
|
||||
return actionOk();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Instructions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const instructionUrlSchema = z.object({
|
||||
label: optionalText(80),
|
||||
url: optionalUrl,
|
||||
});
|
||||
|
||||
export async function addInstructionPdf(setId: string, formData: FormData): Promise<ActionResult> {
|
||||
await requireUser();
|
||||
|
||||
const file = fileFrom(formData, 'file');
|
||||
if (!file) return actionError('Bitte wähle eine PDF-Datei aus.');
|
||||
|
||||
const parsed = parseForm(z.object({ label: optionalText(80) }), formToObject(formData));
|
||||
if (!parsed.ok) return parsed;
|
||||
|
||||
try {
|
||||
const stored = await storePdf(file, setId);
|
||||
db.insert(instruction)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
setId,
|
||||
kind: 'pdf_upload',
|
||||
filePath: stored.filePath,
|
||||
fileSizeBytes: stored.fileSizeBytes,
|
||||
label: parsed.data.label,
|
||||
sortOrder: nextInstructionSortOrder(setId),
|
||||
createdAt: now(),
|
||||
})
|
||||
.run();
|
||||
} catch (error) {
|
||||
if (error instanceof UploadError) return actionError(error.message);
|
||||
throw error;
|
||||
}
|
||||
|
||||
revalidateSet(setId);
|
||||
return actionOk();
|
||||
}
|
||||
|
||||
export async function addInstructionUrl(setId: string, input: unknown): Promise<ActionResult> {
|
||||
await requireUser();
|
||||
|
||||
const parsed = parseForm(instructionUrlSchema, input);
|
||||
if (!parsed.ok) return parsed;
|
||||
if (!parsed.data.url) return actionError('Bitte gib die Adresse der Anleitung an.');
|
||||
|
||||
db.insert(instruction)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
setId,
|
||||
kind: 'external_url',
|
||||
url: parsed.data.url,
|
||||
label: parsed.data.label,
|
||||
sortOrder: nextInstructionSortOrder(setId),
|
||||
createdAt: now(),
|
||||
})
|
||||
.run();
|
||||
|
||||
revalidateSet(setId);
|
||||
return actionOk();
|
||||
}
|
||||
|
||||
export async function deleteInstruction(id: string): Promise<ActionResult> {
|
||||
await requireUser();
|
||||
|
||||
const existing = db.select().from(instruction).where(eq(instruction.id, id)).get();
|
||||
if (!existing) return actionOk();
|
||||
|
||||
db.delete(instruction).where(eq(instruction.id, id)).run();
|
||||
await deleteStoredFile(existing.filePath);
|
||||
|
||||
revalidateSet(existing.setId);
|
||||
return actionOk();
|
||||
}
|
||||
|
||||
function nextInstructionSortOrder(setId: string): number {
|
||||
const rows = db
|
||||
.select({ sortOrder: instruction.sortOrder })
|
||||
.from(instruction)
|
||||
.where(eq(instruction.setId, setId))
|
||||
.all();
|
||||
return rows.reduce((max, row) => Math.max(max, row.sortOrder), -1) + 1;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Photos
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function addSetPhoto(setId: string, formData: FormData): Promise<ActionResult> {
|
||||
await requireUser();
|
||||
|
||||
const file = fileFrom(formData, 'file');
|
||||
if (!file) return actionError('Bitte wähle ein Bild aus.');
|
||||
|
||||
const parsed = parseForm(z.object({ caption: optionalText(160) }), formToObject(formData));
|
||||
if (!parsed.ok) return parsed;
|
||||
|
||||
try {
|
||||
const stored = await storeImage(file, 'photo', setId);
|
||||
db.insert(photo)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
setId,
|
||||
filePath: stored.filePath,
|
||||
thumbnailPath: stored.thumbnailPath,
|
||||
caption: parsed.data.caption,
|
||||
sortOrder: 0,
|
||||
createdAt: now(),
|
||||
})
|
||||
.run();
|
||||
} catch (error) {
|
||||
if (error instanceof UploadError) return actionError(error.message);
|
||||
throw error;
|
||||
}
|
||||
|
||||
revalidateSet(setId);
|
||||
return actionOk();
|
||||
}
|
||||
|
||||
export async function deletePhoto(id: string): Promise<ActionResult> {
|
||||
await requireUser();
|
||||
|
||||
const existing = db.select().from(photo).where(eq(photo.id, id)).get();
|
||||
if (!existing) return actionOk();
|
||||
|
||||
db.delete(photo).where(eq(photo.id, id)).run();
|
||||
await Promise.all([
|
||||
deleteStoredFile(existing.filePath),
|
||||
deleteStoredFile(existing.thumbnailPath),
|
||||
]);
|
||||
|
||||
if (existing.setId) revalidateSet(existing.setId);
|
||||
return actionOk();
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
'use server';
|
||||
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import { db } from '@/db';
|
||||
import { manufacturer, set } from '@/db/schema';
|
||||
import { actionData, actionError, type ActionResult } from '@/lib/action-result';
|
||||
import {
|
||||
cleanSetName,
|
||||
hintsFromHtml,
|
||||
hintsFromUrl,
|
||||
mergeHints,
|
||||
sourceLabel,
|
||||
type LinkHints,
|
||||
} from '@/lib/link-import';
|
||||
import { fetchMinifigCount, fetchSet, isRebrickableEnabled, RebrickableError } from '@/lib/rebrickable';
|
||||
import { safeFetch, SafeFetchError } from '@/lib/safe-fetch';
|
||||
import { requireUser } from '@/lib/session';
|
||||
import { uniqueSlug } from '@/lib/slug';
|
||||
|
||||
/**
|
||||
* "Aus einem Link übernehmen": reads a set out of a shop, price-comparison or
|
||||
* database link and returns a draft for the form. Nothing is saved here except
|
||||
* a missing manufacturer - the form needs it to be selectable.
|
||||
*
|
||||
* Amazon, idealo and geizhals usually refuse requests from a server; then the
|
||||
* link itself (brand, number, words in the address) is the starting point,
|
||||
* and Rebrickable (Lego) or Merlins Bricks (BlueBrixx, Cobi) fill in the rest.
|
||||
*/
|
||||
|
||||
export type LinkPreview = {
|
||||
manufacturerId: string | null;
|
||||
manufacturerName: string | null;
|
||||
setNumber: string;
|
||||
name: string;
|
||||
year: number | null;
|
||||
pieceCount: number | null;
|
||||
minifigCount: number | null;
|
||||
ean: string;
|
||||
imageUrl: string;
|
||||
sourceUrl: string;
|
||||
/** Where the data came from, for the confirmation message. */
|
||||
sources: string[];
|
||||
/** The shop did not let us read the page; only the address was used. */
|
||||
pageBlocked: boolean;
|
||||
/** The set is already in the catalog. */
|
||||
existingSetId: string | null;
|
||||
};
|
||||
|
||||
const inputSchema = z.object({
|
||||
url: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, 'Bitte einen Link einfügen.')
|
||||
.max(2000, 'Der Link ist zu lang.')
|
||||
.url('Das ist kein gültiger Link.')
|
||||
.refine((value) => /^https?:\/\//i.test(value), 'Nur http- und https-Links.'),
|
||||
});
|
||||
|
||||
/** Manufacturer website for the ones we create on the fly. */
|
||||
const WEBSITES: Record<string, string> = {
|
||||
Lego: 'https://www.lego.com/de-de',
|
||||
Playmobil: 'https://www.playmobil.de',
|
||||
Cobi: 'https://cobi.pl',
|
||||
BlueBrixx: 'https://www.bluebrixx.com',
|
||||
'Mega Bloks': 'https://www.megabrands.com',
|
||||
};
|
||||
|
||||
/** Merlins Bricks keys its set pages by a brand prefix. */
|
||||
const MERLIN_PREFIX: Record<string, string> = { BlueBrixx: 'bb', Cobi: 'cobi' };
|
||||
|
||||
function key(value: string): string {
|
||||
return value.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
}
|
||||
|
||||
function ensureManufacturer(name: string): string {
|
||||
const rows = db
|
||||
.select({ id: manufacturer.id, name: manufacturer.name, slug: manufacturer.slug })
|
||||
.from(manufacturer)
|
||||
.all();
|
||||
|
||||
const match = rows.find((row) => key(row.name) === key(name) || row.slug === key(name));
|
||||
if (match) return match.id;
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const now = new Date();
|
||||
db.insert(manufacturer)
|
||||
.values({
|
||||
id,
|
||||
name,
|
||||
slug: uniqueSlug(name, rows.map((row) => row.slug)),
|
||||
website: WEBSITES[name] ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.run();
|
||||
return id;
|
||||
}
|
||||
|
||||
async function readPage(url: URL): Promise<{ hints: LinkHints | null; finalUrl: URL; blocked: boolean }> {
|
||||
const response = await safeFetch(url);
|
||||
const finalUrl = new URL(response.url);
|
||||
const isHtml = /html|xml/i.test(response.contentType);
|
||||
|
||||
if (response.status !== 200 || !isHtml) return { hints: null, finalUrl, blocked: true };
|
||||
return { hints: hintsFromHtml(response.body.toString('utf8'), finalUrl), finalUrl, blocked: false };
|
||||
}
|
||||
|
||||
async function fromRebrickable(setNumber: string): Promise<(LinkHints & { minifigCount?: number }) | null> {
|
||||
if (!isRebrickableEnabled()) return null;
|
||||
try {
|
||||
const remote = await fetchSet(setNumber);
|
||||
if (!remote) return null;
|
||||
const minifigs = await fetchMinifigCount(remote.setNumber);
|
||||
return {
|
||||
name: remote.name,
|
||||
year: remote.year ?? undefined,
|
||||
pieceCount: remote.pieceCount ?? undefined,
|
||||
imageUrl: remote.imageUrl ?? undefined,
|
||||
minifigCount: minifigs ?? undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof RebrickableError) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function fromMerlinsBricks(brand: string, setNumber: string): Promise<LinkHints | null> {
|
||||
const prefix = MERLIN_PREFIX[brand];
|
||||
if (!prefix || !/^[\w-]+$/.test(setNumber)) return null;
|
||||
try {
|
||||
const page = await readPage(new URL(`https://www.merlinsbricks.com/sets/${prefix}-${setNumber}/`));
|
||||
return page.hints;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function previewSetFromLink(input: unknown): Promise<ActionResult<LinkPreview>> {
|
||||
await requireUser();
|
||||
|
||||
const parsed = inputSchema.safeParse(input);
|
||||
if (!parsed.success) return actionError(parsed.error.issues[0]?.message ?? 'Ungültiger Link.');
|
||||
|
||||
const url = new URL(parsed.data.url);
|
||||
const sources: string[] = [];
|
||||
|
||||
// 1. The page itself, if it lets us in. A short link (amzn.eu/...) still
|
||||
// tells us where it leads, even when the target then refuses.
|
||||
let pageHints: LinkHints | null = null;
|
||||
let finalUrl = url;
|
||||
let pageBlocked = true;
|
||||
try {
|
||||
const page = await readPage(url);
|
||||
pageHints = page.hints;
|
||||
finalUrl = page.finalUrl;
|
||||
pageBlocked = page.blocked;
|
||||
} catch (error) {
|
||||
if (!(error instanceof SafeFetchError)) throw error;
|
||||
if (/intern/i.test(error.message)) return actionError(error.message);
|
||||
}
|
||||
if (pageHints) sources.push(sourceLabel(finalUrl));
|
||||
|
||||
// 2. The address.
|
||||
const addressHints = mergeHints(hintsFromUrl(finalUrl), hintsFromUrl(url));
|
||||
if (!pageHints || pageBlocked) sources.push(`Link (${sourceLabel(finalUrl)})`);
|
||||
|
||||
let hints = mergeHints(pageHints, addressHints);
|
||||
let minifigCount: number | null = null;
|
||||
|
||||
// 3. A set database for the brand.
|
||||
if (hints.manufacturer === 'Lego' && hints.setNumber) {
|
||||
const remote = await fromRebrickable(hints.setNumber);
|
||||
if (remote) {
|
||||
// The shop's German name beats Rebrickable's English one; Rebrickable's
|
||||
// facts and clean picture beat the shop's.
|
||||
hints = mergeHints({ name: pageHints?.name ?? addressHints.name, ean: hints.ean }, remote, hints);
|
||||
minifigCount = remote.minifigCount ?? null;
|
||||
sources.push('Rebrickable');
|
||||
}
|
||||
} else if (hints.manufacturer && hints.setNumber && !/merlinsbricks\./.test(finalUrl.hostname)) {
|
||||
const merlin = await fromMerlinsBricks(hints.manufacturer, hints.setNumber);
|
||||
if (merlin) {
|
||||
hints = mergeHints(hints, merlin);
|
||||
sources.push('Merlins Bricks');
|
||||
}
|
||||
}
|
||||
|
||||
if (!hints.name && !hints.setNumber) {
|
||||
return actionError(
|
||||
pageBlocked
|
||||
? `${sourceLabel(finalUrl)} lässt keine Abfrage zu, und der Link selbst verrät nichts über das Set. Versuch es mit einem anderen Link oder trag das Set von Hand ein.`
|
||||
: 'Auf der Seite stehen keine Produktdaten, mit denen sich etwas anfangen lässt.',
|
||||
);
|
||||
}
|
||||
|
||||
const manufacturerName = hints.manufacturer ?? null;
|
||||
const manufacturerId = manufacturerName ? ensureManufacturer(manufacturerName) : null;
|
||||
const setNumber = hints.setNumber ?? '';
|
||||
|
||||
const existing =
|
||||
manufacturerId && setNumber
|
||||
? db
|
||||
.select({ id: set.id })
|
||||
.from(set)
|
||||
.where(and(eq(set.manufacturerId, manufacturerId), eq(set.setNumber, setNumber)))
|
||||
.get()
|
||||
: undefined;
|
||||
|
||||
return actionData({
|
||||
manufacturerId,
|
||||
manufacturerName,
|
||||
setNumber,
|
||||
name: hints.name ? cleanSetName(hints.name, manufacturerName ?? undefined, setNumber) : '',
|
||||
year: hints.year ?? null,
|
||||
pieceCount: hints.pieceCount ?? null,
|
||||
minifigCount,
|
||||
ean: hints.ean ?? '',
|
||||
imageUrl: hints.imageUrl ?? '',
|
||||
sourceUrl: url.toString(),
|
||||
sources,
|
||||
pageBlocked,
|
||||
existingSetId: existing?.id ?? null,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { NewSetEntry } from '@/components/katalog/link-import';
|
||||
import { RebrickableImport } from '@/components/katalog/rebrickable-import';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { listManufacturers, listThemes } from '@/lib/queries/master-data';
|
||||
import { isRebrickableEnabled } from '@/lib/rebrickable';
|
||||
import { requireUser } from '@/lib/session';
|
||||
|
||||
export const metadata: Metadata = { title: 'Neues Set' };
|
||||
|
||||
type SearchParams = Promise<Record<string, string | string[] | undefined>>;
|
||||
|
||||
export default async function NeuesSetPage({ searchParams }: { searchParams: SearchParams }) {
|
||||
await requireUser();
|
||||
const params = await searchParams;
|
||||
const ean = typeof params.ean === 'string' ? params.ean : undefined;
|
||||
// Which way the plus menu chose: the matching field gets the cursor.
|
||||
const way = typeof params.weg === 'string' ? params.weg : undefined;
|
||||
|
||||
const manufacturers = listManufacturers().map((row) => ({ id: row.id, name: row.name }));
|
||||
const themes = listThemes().map((row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
manufacturerId: row.manufacturerId,
|
||||
}));
|
||||
|
||||
const importEnabled = isRebrickableEnabled();
|
||||
|
||||
// One layout for both cases: the link import may create the first
|
||||
// manufacturer, and its draft must survive the refresh that follows.
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Neues Set</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Ein Set im Katalog heißt noch nicht, dass es da ist – das legst du danach über „Zur
|
||||
Sammlung hinzufügen“ fest.
|
||||
</p>
|
||||
</div>
|
||||
{/* The Lego import creates its manufacturer itself, so it works even
|
||||
on a completely empty instance. */}
|
||||
<RebrickableImport enabled={importEnabled} autoFocus={way === 'lego'} />
|
||||
|
||||
<NewSetEntry
|
||||
manufacturers={manufacturers}
|
||||
themes={themes}
|
||||
defaultEan={ean}
|
||||
focus={way === 'link' || way === 'hand' ? way : undefined}
|
||||
>
|
||||
<p className="rounded-lg border border-dashed border-border p-6 text-sm text-muted-foreground">
|
||||
Für ein Set von Hand brauchst du zuerst einen Hersteller – Lego, Playmobil, Cobi oder
|
||||
was auch immer im Kinderzimmer steht.
|
||||
</p>
|
||||
<Button asChild size="lg">
|
||||
<Link href="/verwaltung/hersteller">Hersteller anlegen</Link>
|
||||
</Button>
|
||||
</NewSetEntry>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { BarcodeScannerButton } from '@/components/katalog/barcode-scanner';
|
||||
import { CatalogFilters } from '@/components/katalog/catalog-filters';
|
||||
import { CatalogGrid } from '@/components/katalog/catalog-grid';
|
||||
import { Pagination } from '@/components/katalog/pagination';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { pluralize } from '@/lib/format';
|
||||
import { OWNERSHIP_FILTER_LABELS, parseOwnershipFilter } from '@/lib/ownership';
|
||||
import { CATALOG_PAGE_SIZE, listSets, listSetYears } from '@/lib/queries/catalog';
|
||||
import { activeCollectorId } from '@/lib/collector-choice';
|
||||
import { listCollectors } from '@/lib/queries/collectors';
|
||||
import { listManufacturers, listThemes } from '@/lib/queries/master-data';
|
||||
import { listWishlistTargets } from '@/lib/queries/wishlists';
|
||||
import { requireUser } from '@/lib/session';
|
||||
|
||||
export const metadata: Metadata = { title: 'Katalog' };
|
||||
|
||||
type SearchParams = Promise<Record<string, string | string[] | undefined>>;
|
||||
|
||||
function single(value: string | string[] | undefined): string | undefined {
|
||||
const raw = Array.isArray(value) ? value[0] : value;
|
||||
return raw && raw.length > 0 ? raw : undefined;
|
||||
}
|
||||
|
||||
export default async function KatalogPage({ searchParams }: { searchParams: SearchParams }) {
|
||||
await requireUser();
|
||||
const params = await searchParams;
|
||||
|
||||
const ownership = parseOwnershipFilter(single(params.besitz));
|
||||
const collectors = listCollectors();
|
||||
const collectorId = await activeCollectorId(single(params.sammler));
|
||||
const activeCollector = collectors.find((row) => row.id === collectorId) ?? null;
|
||||
const yearParam = single(params.jahr);
|
||||
const page = Number(single(params.seite) ?? '1') || 1;
|
||||
|
||||
const filters = {
|
||||
q: single(params.q),
|
||||
manufacturerId: single(params.hersteller),
|
||||
themeId: single(params.thema),
|
||||
year: yearParam ? Number(yearParam) : undefined,
|
||||
ownership,
|
||||
collectorId,
|
||||
page,
|
||||
};
|
||||
|
||||
const { rows, total } = listSets(filters);
|
||||
const manufacturers = listManufacturers().map((row) => ({ id: row.id, name: row.name }));
|
||||
const themes = listThemes().map((row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
manufacturerId: row.manufacturerId,
|
||||
}));
|
||||
const wishlists = listWishlistTargets();
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
{ownership === 'fehlt' ? 'Fehlende Sets' : 'Katalog'}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{ownership === 'fehlt'
|
||||
? activeCollector
|
||||
? `Sets, die ${activeCollector.name} noch nicht hat.`
|
||||
: 'Sets, die noch nicht in der Sammlung sind.'
|
||||
: 'Alle bekannten Sets – unabhängig davon, ob sie schon da sind.'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<BarcodeScannerButton />
|
||||
<Button asChild size="lg">
|
||||
<Link href="/katalog/neu">
|
||||
<Plus />
|
||||
<span className="hidden sm:inline">Set anlegen</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CatalogFilters options={{ manufacturers, themes, years: listSetYears() }} />
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{pluralize(total, 'Set', 'Sets')}
|
||||
{ownership !== 'alle' ? ` · ${OWNERSHIP_FILTER_LABELS[ownership]}` : ''}
|
||||
</p>
|
||||
|
||||
<CatalogGrid sets={rows} wishlists={wishlists} collectorId={collectorId} />
|
||||
|
||||
<Pagination page={page} total={total} pageSize={CATALOG_PAGE_SIZE} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import { db } from '@/db';
|
||||
import { pricePoint } from '@/db/schema';
|
||||
import { actionError, actionOk, parseForm, type ActionResult } from '@/lib/action-result';
|
||||
import { requireUser } from '@/lib/session';
|
||||
import { optionalDate, optionalText, optionalPriceCents } from '@/lib/validation';
|
||||
|
||||
const schema = z.object({
|
||||
priceCents: optionalPriceCents,
|
||||
condition: z.enum(['new', 'used']).default('new'),
|
||||
source: optionalText(80),
|
||||
observedAt: optionalDate,
|
||||
note: optionalText(200),
|
||||
});
|
||||
|
||||
export async function addPricePoint(setId: string, input: unknown): Promise<ActionResult> {
|
||||
await requireUser();
|
||||
|
||||
const parsed = parseForm(schema, input);
|
||||
if (!parsed.ok) return parsed;
|
||||
if (parsed.data.priceCents === null) return actionError('Bitte gib einen Preis an.');
|
||||
|
||||
db.insert(pricePoint)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
setId,
|
||||
priceCents: parsed.data.priceCents,
|
||||
condition: parsed.data.condition,
|
||||
source: parsed.data.source,
|
||||
// No date given means "seen today".
|
||||
observedAt: parsed.data.observedAt ?? new Date(),
|
||||
note: parsed.data.note,
|
||||
createdAt: new Date(),
|
||||
})
|
||||
.run();
|
||||
|
||||
revalidatePath(`/katalog/${setId}`);
|
||||
revalidatePath('/sammlung');
|
||||
return actionOk();
|
||||
}
|
||||
|
||||
export async function deletePricePoint(id: string): Promise<ActionResult> {
|
||||
await requireUser();
|
||||
|
||||
const existing = db
|
||||
.select({ setId: pricePoint.setId })
|
||||
.from(pricePoint)
|
||||
.where(eq(pricePoint.id, id))
|
||||
.get();
|
||||
if (!existing) return actionOk();
|
||||
|
||||
db.delete(pricePoint).where(eq(pricePoint.id, id)).run();
|
||||
|
||||
revalidatePath(`/katalog/${existing.setId}`);
|
||||
revalidatePath('/sammlung');
|
||||
return actionOk();
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { cookies, headers } from 'next/headers';
|
||||
import { z } from 'zod';
|
||||
import { env } from '@/env';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { DESIGN_COOKIE, DESIGNS } from '@/lib/design';
|
||||
import { requireUser } from '@/lib/session';
|
||||
|
||||
const idSchema = z.object({ id: z.string().min(1) });
|
||||
const renameSchema = idSchema.extend({ name: z.string().trim().min(1).max(60) });
|
||||
const designSchema = z.object({ design: z.enum(DESIGNS) });
|
||||
|
||||
export type ActionResult = { ok: true } | { ok: false; error: string };
|
||||
|
||||
export async function deletePasskeyAction(input: unknown): Promise<ActionResult> {
|
||||
const parsed = idSchema.safeParse(input);
|
||||
if (!parsed.success) return { ok: false, error: 'Ungültige Anfrage.' };
|
||||
|
||||
try {
|
||||
await auth.api.deletePasskey({
|
||||
body: { id: parsed.data.id },
|
||||
headers: await headers(),
|
||||
});
|
||||
} catch {
|
||||
return { ok: false, error: 'Der Passkey konnte nicht gelöscht werden.' };
|
||||
}
|
||||
|
||||
revalidatePath('/konto');
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function renamePasskeyAction(input: unknown): Promise<ActionResult> {
|
||||
const parsed = renameSchema.safeParse(input);
|
||||
if (!parsed.success) return { ok: false, error: 'Bitte gib einen Namen an (max. 60 Zeichen).' };
|
||||
|
||||
try {
|
||||
await auth.api.updatePasskey({
|
||||
body: { id: parsed.data.id, name: parsed.data.name },
|
||||
headers: await headers(),
|
||||
});
|
||||
} catch {
|
||||
return { ok: false, error: 'Der Passkey konnte nicht umbenannt werden.' };
|
||||
}
|
||||
|
||||
revalidatePath('/konto');
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* The design is a per-device choice: a cookie, not a column on the user. That
|
||||
* way it also covers the login page and lets two children's tablets look
|
||||
* different.
|
||||
*/
|
||||
export async function setDesignAction(input: unknown): Promise<ActionResult> {
|
||||
await requireUser();
|
||||
|
||||
const parsed = designSchema.safeParse(input);
|
||||
if (!parsed.success) return { ok: false, error: 'Unbekanntes Design.' };
|
||||
|
||||
(await cookies()).set(DESIGN_COOKIE, parsed.data.design, {
|
||||
httpOnly: true,
|
||||
secure: env.isProduction,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
maxAge: 60 * 60 * 24 * 365,
|
||||
});
|
||||
|
||||
revalidatePath('/', 'layout');
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { cookies, headers } from 'next/headers';
|
||||
import { PasskeyManager, type PasskeyRow } from '@/components/auth/passkey-manager';
|
||||
import { DesignPicker } from '@/components/konto/design-picker';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { DESIGN_COOKIE, resolveDesign } from '@/lib/design';
|
||||
import { requireUser } from '@/lib/session';
|
||||
|
||||
export const metadata: Metadata = { title: 'Konto' };
|
||||
|
||||
export default async function AccountPage() {
|
||||
const user = await requireUser();
|
||||
|
||||
const passkeys = await auth.api.listPasskeys({ headers: await headers() });
|
||||
const rows: PasskeyRow[] = passkeys.map((passkey) => ({
|
||||
id: passkey.id,
|
||||
name: passkey.name ?? null,
|
||||
createdAt: passkey.createdAt ? new Date(passkey.createdAt) : null,
|
||||
}));
|
||||
const design = resolveDesign((await cookies()).get(DESIGN_COOKIE)?.value);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Konto</h1>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Profil</CardTitle>
|
||||
<CardDescription>Diese Angaben stammen aus deiner Registrierung.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-muted-foreground">Name</span>
|
||||
<span className="font-medium">{user.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-muted-foreground">E-Mail</span>
|
||||
<span className="truncate font-medium">{user.email}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-muted-foreground">Rolle</span>
|
||||
<Badge variant={user.role === 'admin' ? 'default' : 'secondary'}>
|
||||
{user.role === 'admin' ? 'Administrator' : 'Mitglied'}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Design</CardTitle>
|
||||
<CardDescription>
|
||||
Gilt für dieses Gerät. Öffentliche Wunschlisten sehen Gäste im Standard-Design.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DesignPicker current={design} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Passkeys</CardTitle>
|
||||
<CardDescription>
|
||||
Passkeys sind an diese Domain gebunden und funktionieren nur über HTTPS. Du kannst
|
||||
beliebig viele Geräte hinterlegen.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<PasskeyManager passkeys={rows} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { AppHeader } from '@/components/layout/app-header';
|
||||
import { BottomNav } from '@/components/layout/bottom-nav';
|
||||
import { storedCollectorId } from '@/lib/collector-choice';
|
||||
import { listCollectors } from '@/lib/queries/collectors';
|
||||
import { isRebrickableEnabled } from '@/lib/rebrickable';
|
||||
import { ensureReminderScheduler } from '@/lib/reminder-job';
|
||||
import { requireUser } from '@/lib/session';
|
||||
|
||||
export default async function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
// Second entry point besides the healthcheck, in case someone turns that
|
||||
// off. The call is idempotent.
|
||||
ensureReminderScheduler();
|
||||
|
||||
const user = await requireUser();
|
||||
const collectors = listCollectors().map((row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
color: row.color,
|
||||
}));
|
||||
const legoImport = isRebrickableEnabled();
|
||||
|
||||
return (
|
||||
<div className="flex min-h-dvh flex-col">
|
||||
<AppHeader
|
||||
user={user}
|
||||
collectors={collectors}
|
||||
storedCollectorId={(await storedCollectorId()) ?? null}
|
||||
legoImport={legoImport}
|
||||
/>
|
||||
{/* Bottom padding keeps the mobile nav from covering the last row. */}
|
||||
<main className="mx-auto w-full max-w-5xl flex-1 px-4 pt-4 pb-28 md:pb-10">{children}</main>
|
||||
<BottomNav legoImport={legoImport} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { cn } from 'cn';
|
||||
import { Studs } from '@/components/brick/studs';
|
||||
import { MORE_GROUPS, moreItemsFor } from '@/components/layout/more-items';
|
||||
import { SignOutButton } from '@/components/layout/sign-out-button';
|
||||
import { requireUser } from '@/lib/session';
|
||||
|
||||
export const metadata: Metadata = { title: 'Mehr' };
|
||||
|
||||
const GROUP_TONES: Record<string, string> = {
|
||||
ich: 'bg-tone-1',
|
||||
familie: 'bg-tone-2',
|
||||
katalog: 'bg-tone-3',
|
||||
daten: 'bg-tone-4',
|
||||
};
|
||||
|
||||
export default async function MehrPage() {
|
||||
const user = await requireUser();
|
||||
const items = moreItemsFor(user.role);
|
||||
|
||||
return (
|
||||
<div className="space-y-7">
|
||||
<div className="flex items-center gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{user.name}</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{user.email} · {user.role === 'admin' ? 'Administrator' : 'Mitglied'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{MORE_GROUPS.map((group) => {
|
||||
const entries = items.filter((item) => item.group === group.id);
|
||||
if (entries.length === 0) return null;
|
||||
return (
|
||||
<section key={group.id} className="space-y-4">
|
||||
<h2 className="text-xs font-bold tracking-widest text-muted-foreground uppercase">
|
||||
{group.label}
|
||||
</h2>
|
||||
<ul className="grid grid-cols-2 gap-x-3 gap-y-5 sm:grid-cols-3">
|
||||
{entries.map((item) => (
|
||||
<li key={item.href} className="relative">
|
||||
<Studs studClassName={GROUP_TONES[group.id]} />
|
||||
<Link
|
||||
href={item.href}
|
||||
className={cn(
|
||||
'brick flex h-full flex-col gap-1 rounded-xl p-3.5 text-tone-ink outline-none focus-visible:ring-3 focus-visible:ring-ring/50',
|
||||
GROUP_TONES[group.id],
|
||||
)}
|
||||
>
|
||||
<item.icon className="size-6" aria-hidden />
|
||||
<span className="font-bold">{item.label}</span>
|
||||
<span className="text-xs leading-snug opacity-80">{item.description}</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
|
||||
<SignOutButton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import Link from 'next/link';
|
||||
import { ChevronRight, Gift, PackageSearch, Plus } from 'lucide-react';
|
||||
import { cn } from 'cn';
|
||||
import { Studs } from '@/components/brick/studs';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { brickRow } from '@/lib/brick-row';
|
||||
import { activeCollectorId } from '@/lib/collector-choice';
|
||||
import { collectorDotClass } from '@/lib/collector-colors';
|
||||
import { formatDate, formatNumber, pluralize } from '@/lib/format';
|
||||
import { formatEuro } from '@/lib/money';
|
||||
import { countMissingSets, getCollectionStats } from '@/lib/queries/collection';
|
||||
import { countItemsPerCollector, listCollectors } from '@/lib/queries/collectors';
|
||||
import { listWishlists } from '@/lib/queries/wishlists';
|
||||
import { daysUntil } from '@/lib/reminders';
|
||||
import { requireUser } from '@/lib/session';
|
||||
|
||||
function daysLabel(days: number): string {
|
||||
if (days === 0) return 'heute';
|
||||
if (days === 1) return 'morgen';
|
||||
return `in ${days} Tagen`;
|
||||
}
|
||||
|
||||
type SearchParams = Promise<Record<string, string | string[] | undefined>>;
|
||||
|
||||
export default async function DashboardPage({ searchParams }: { searchParams: SearchParams }) {
|
||||
const user = await requireUser();
|
||||
const params = await searchParams;
|
||||
const rawCollector = Array.isArray(params.sammler) ? params.sammler[0] : params.sammler;
|
||||
|
||||
// With a child chosen in the header, the start page is about that child.
|
||||
const collectorId = await activeCollectorId(rawCollector);
|
||||
const stats = getCollectionStats(collectorId);
|
||||
const missing = countMissingSets(collectorId);
|
||||
const totalSets = stats.setCount + missing;
|
||||
|
||||
const collectors = listCollectors();
|
||||
const activeCollector = collectors.find((entry) => entry.id === collectorId) ?? null;
|
||||
const perCollector = countItemsPerCollector();
|
||||
const showCollectors = collectors.length > 1 && !activeCollector;
|
||||
const bricks = brickRow(
|
||||
stats.setCount,
|
||||
totalSets,
|
||||
activeCollector
|
||||
? [{ key: activeCollector.id, count: 1 }]
|
||||
: showCollectors
|
||||
? collectors.map((entry) => ({ key: entry.id, count: perCollector.get(entry.id) ?? 0 }))
|
||||
: [],
|
||||
);
|
||||
const colorById = new Map(collectors.map((entry) => [entry.id, entry.color]));
|
||||
|
||||
// The next date someone could bring a present for.
|
||||
const now = new Date();
|
||||
const nextEvent =
|
||||
listWishlists(user.id)
|
||||
.filter((list) => list.eventDate && daysUntil(list.eventDate, now) >= 0)
|
||||
.sort((a, b) => a.eventDate!.getTime() - b.eventDate!.getTime())[0] ?? null;
|
||||
|
||||
return (
|
||||
<div className="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">
|
||||
{activeCollector ? `Sammlung von ${activeCollector.name}` : 'Unsere Sammlung'}
|
||||
</h2>
|
||||
<p className="text-sm font-semibold text-muted-foreground">
|
||||
{formatNumber(stats.setCount)} von {pluralize(totalSets, 'Set', 'Sets')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ol
|
||||
aria-label={`${formatNumber(stats.setCount)} von ${formatNumber(totalSets)} Sets vorhanden`}
|
||||
className="mt-5 grid grid-cols-10 items-end gap-1.5"
|
||||
>
|
||||
{bricks.map((key, index) => (
|
||||
<li key={index} aria-hidden className="relative">
|
||||
{key === null ? (
|
||||
<span className="block h-7 rounded-[6px] border-2 border-dashed border-muted-foreground/50" />
|
||||
) : (
|
||||
<>
|
||||
<span
|
||||
className={cn(
|
||||
'stud mx-auto mb-[calc(var(--edge-w)*-1)] !h-1.5 !w-3 !rounded-t-[4px] [display:var(--studs)]',
|
||||
key ? collectorDotClass(colorById.get(key)) : 'bg-secondary',
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'block h-7 rounded-[6px] border-[length:var(--edge-w)] border-edge',
|
||||
key ? collectorDotClass(colorById.get(key)) : 'bg-secondary',
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
{showCollectors ? (
|
||||
<ul className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs font-semibold">
|
||||
{collectors.map((entry) => (
|
||||
<li key={entry.id} className="flex items-center gap-1.5">
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'size-3 rounded-[3px] border-[length:var(--edge-w)] border-edge',
|
||||
collectorDotClass(entry.color),
|
||||
)}
|
||||
/>
|
||||
{entry.name}
|
||||
</li>
|
||||
))}
|
||||
<li className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<span
|
||||
aria-hidden
|
||||
className="size-3 rounded-[3px] border-2 border-dashed border-muted-foreground/50"
|
||||
/>
|
||||
fehlt noch
|
||||
</li>
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid grid-cols-2 gap-3">
|
||||
<div className="relative">
|
||||
<Studs studClassName="bg-secondary" />
|
||||
<div className="brick h-full rounded-xl bg-secondary p-4 text-secondary-foreground">
|
||||
<p className="text-2xl font-bold tabular-nums [font-family:var(--font-display)]">
|
||||
{formatNumber(stats.pieceCount)}
|
||||
</p>
|
||||
<p className="text-sm font-semibold">Teile verbaut</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Studs studClassName="bg-owned" />
|
||||
<div className="brick h-full rounded-xl bg-owned p-4 text-owned-foreground">
|
||||
<p className="text-2xl font-bold tabular-nums [font-family:var(--font-display)]">
|
||||
{formatEuro(stats.currentValueCents)}
|
||||
</p>
|
||||
<p className="text-sm font-semibold">
|
||||
{stats.currentValueCents === stats.retailValueCents ? 'Wert nach UVP' : 'Aktueller Wert'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{nextEvent?.eventDate ? (
|
||||
<Link href={`/wunschlisten/${nextEvent.id}`} className="relative block rounded-xl">
|
||||
<Studs count={4} studClassName="bg-primary" />
|
||||
<div className="brick flex items-center gap-4 rounded-xl bg-primary p-4 text-primary-foreground">
|
||||
<div className="flex size-14 shrink-0 flex-col items-center justify-center rounded-lg border-[length:var(--edge-w)] border-edge bg-card text-card-foreground">
|
||||
<span className="text-xl leading-none font-bold [font-family:var(--font-display)]">
|
||||
{formatDate(nextEvent.eventDate).slice(0, 5)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-lg font-semibold [font-family:var(--font-display)]">
|
||||
{nextEvent.title}
|
||||
</p>
|
||||
<p className="text-sm font-semibold">
|
||||
{daysLabel(daysUntil(nextEvent.eventDate, now))} ·{' '}
|
||||
{nextEvent.openCount === 1
|
||||
? '1 Wunsch noch offen'
|
||||
: `${formatNumber(nextEvent.openCount)} Wünsche noch offen`}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRight className="size-5 shrink-0" aria-hidden />
|
||||
</div>
|
||||
</Link>
|
||||
) : null}
|
||||
|
||||
<section className="flex flex-wrap gap-3">
|
||||
<Button asChild variant="outline" size="lg">
|
||||
<Link href="/katalog?besitz=fehlt">
|
||||
<PackageSearch aria-hidden />
|
||||
{missing === 1 ? '1 Set fehlt noch' : `${formatNumber(missing)} Sets fehlen noch`}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="lg">
|
||||
<Link href="/wunschlisten">
|
||||
<Gift aria-hidden />
|
||||
Wunschlisten
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild size="lg">
|
||||
<Link href="/katalog/neu">
|
||||
<Plus aria-hidden />
|
||||
Set anlegen
|
||||
</Link>
|
||||
</Button>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { db } from '@/db';
|
||||
import { collectionItem, missingPart, set } from '@/db/schema';
|
||||
import {
|
||||
actionData,
|
||||
actionError,
|
||||
actionOk,
|
||||
parseForm,
|
||||
type ActionResult,
|
||||
} from '@/lib/action-result';
|
||||
import { defaultCollectorId } from '@/lib/queries/collectors';
|
||||
import { reconcileFulfilledWishlistItems } from '@/lib/queries/wishlists';
|
||||
import { requireUser } from '@/lib/session';
|
||||
import { collectionItemSchema, missingPartSchema } from '@/lib/validation';
|
||||
import type { Condition } from '@/db/types';
|
||||
|
||||
function now() {
|
||||
return new Date();
|
||||
}
|
||||
|
||||
function revalidateCollection(setId?: string) {
|
||||
revalidatePath('/sammlung');
|
||||
revalidatePath('/katalog');
|
||||
revalidatePath('/wunschlisten');
|
||||
revalidatePath('/');
|
||||
if (setId) revalidatePath(`/katalog/${setId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick add from the set detail page: one tap, sensible defaults, details can
|
||||
* be filled in later.
|
||||
*/
|
||||
export async function addSetToCollection(
|
||||
setId: string,
|
||||
collectorId?: string,
|
||||
): Promise<ActionResult<{ id: string }>> {
|
||||
await requireUser();
|
||||
|
||||
const exists = db.select({ id: set.id }).from(set).where(eq(set.id, setId)).get();
|
||||
if (!exists) return actionError('Das Set gibt es nicht mehr.');
|
||||
|
||||
// With a single collector nobody is asked - the copy simply belongs to them.
|
||||
const owner = collectorId ?? defaultCollectorId();
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
db.insert(collectionItem)
|
||||
.values({
|
||||
id,
|
||||
setId,
|
||||
collectorId: owner,
|
||||
status: 'owned',
|
||||
completeness: 'unknown',
|
||||
createdAt: now(),
|
||||
updatedAt: now(),
|
||||
})
|
||||
.run();
|
||||
|
||||
// A wish that just came true is marked as fulfilled right away.
|
||||
reconcileFulfilledWishlistItems();
|
||||
|
||||
revalidateCollection(setId);
|
||||
return actionData({ id });
|
||||
}
|
||||
|
||||
export async function updateCollectionItem(id: string, input: unknown): Promise<ActionResult> {
|
||||
await requireUser();
|
||||
|
||||
const parsed = parseForm(collectionItemSchema, input);
|
||||
if (!parsed.ok) return parsed;
|
||||
|
||||
const existing = db.select().from(collectionItem).where(eq(collectionItem.id, id)).get();
|
||||
if (!existing) return actionError('Dieser Sammlungseintrag gibt es nicht mehr.');
|
||||
|
||||
db.update(collectionItem)
|
||||
.set({
|
||||
collectorId: parsed.data.collectorId ?? existing.collectorId,
|
||||
status: parsed.data.status,
|
||||
completeness: parsed.data.completeness,
|
||||
condition: parsed.data.condition as Condition | null,
|
||||
locationId: parsed.data.locationId,
|
||||
acquiredAt: parsed.data.acquiredAt,
|
||||
acquiredPriceCents: parsed.data.acquiredPriceCents,
|
||||
acquiredFrom: parsed.data.acquiredFrom,
|
||||
giftedBy: parsed.data.giftedBy,
|
||||
lentOutTo: parsed.data.lentOutTo,
|
||||
notes: parsed.data.notes,
|
||||
updatedAt: now(),
|
||||
})
|
||||
.where(eq(collectionItem.id, id))
|
||||
.run();
|
||||
|
||||
// Selling the last copy puts the set back into "fehlt" - and back onto the
|
||||
// wishlists as an open wish.
|
||||
reconcileFulfilledWishlistItems();
|
||||
|
||||
revalidateCollection(existing.setId);
|
||||
return actionOk();
|
||||
}
|
||||
|
||||
export async function deleteCollectionItem(id: string): Promise<ActionResult> {
|
||||
await requireUser();
|
||||
|
||||
const existing = db.select().from(collectionItem).where(eq(collectionItem.id, id)).get();
|
||||
if (!existing) return actionOk();
|
||||
|
||||
db.delete(collectionItem).where(eq(collectionItem.id, id)).run();
|
||||
reconcileFulfilledWishlistItems();
|
||||
|
||||
revalidateCollection(existing.setId);
|
||||
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();
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { Boxes, Euro, Layers, PackageSearch } from 'lucide-react';
|
||||
import { SetPlaceholder } from '@/components/brick/set-placeholder';
|
||||
import { Studs } from '@/components/brick/studs';
|
||||
import { CollectionFilters } from '@/components/sammlung/collection-filters';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { formatDate, formatNumber, pluralize } from '@/lib/format';
|
||||
import { collectorBadgeClass, collectorDotClass } from '@/lib/collector-colors';
|
||||
import { setImageSrc } from '@/lib/images';
|
||||
import { completenessLabel, statusLabel } from '@/lib/labels';
|
||||
import { formatEuro } from '@/lib/money';
|
||||
import {
|
||||
countMissingSets,
|
||||
countOpenMissingParts,
|
||||
getCollectionStats,
|
||||
listCollectionItems,
|
||||
type CollectionFilters as Filters,
|
||||
} from '@/lib/queries/collection';
|
||||
import { activeCollectorId } from '@/lib/collector-choice';
|
||||
import { listCollectors } from '@/lib/queries/collectors';
|
||||
import { listLocations, listManufacturers } from '@/lib/queries/master-data';
|
||||
import { requireUser } from '@/lib/session';
|
||||
import type { CollectionStatus, Completeness } from '@/db/types';
|
||||
|
||||
export const metadata: Metadata = { title: 'Sammlung' };
|
||||
|
||||
type SearchParams = Promise<Record<string, string | string[] | undefined>>;
|
||||
|
||||
function single(value: string | string[] | undefined): string | undefined {
|
||||
const raw = Array.isArray(value) ? value[0] : value;
|
||||
return raw && raw.length > 0 ? raw : undefined;
|
||||
}
|
||||
|
||||
export default async function SammlungPage({ searchParams }: { searchParams: SearchParams }) {
|
||||
await requireUser();
|
||||
const params = await searchParams;
|
||||
|
||||
const collectors = listCollectors();
|
||||
const collectorId = await activeCollectorId(single(params.sammler));
|
||||
const activeCollector = collectors.find((row) => row.id === collectorId) ?? null;
|
||||
|
||||
const filters: Filters = {
|
||||
q: single(params.q),
|
||||
status: single(params.status) as CollectionStatus | undefined,
|
||||
completeness: single(params.zustand) as Completeness | undefined,
|
||||
locationId: single(params.standort),
|
||||
manufacturerId: single(params.hersteller),
|
||||
collectorId,
|
||||
};
|
||||
|
||||
const items = listCollectionItems(filters);
|
||||
const openParts = countOpenMissingParts(items.map((item) => item.id));
|
||||
const stats = getCollectionStats(collectorId);
|
||||
const missing = countMissingSets(collectorId);
|
||||
|
||||
const tiles = [
|
||||
{ icon: Boxes, label: 'Sets', value: formatNumber(stats.setCount) },
|
||||
{ icon: Layers, label: 'Teile gesamt', value: formatNumber(stats.pieceCount) },
|
||||
{
|
||||
icon: Euro,
|
||||
// Notierte Preise gehen vor, sonst die UVP.
|
||||
label: stats.currentValueCents === stats.retailValueCents ? 'Wert nach UVP' : 'Aktueller Wert',
|
||||
value: formatEuro(stats.currentValueCents),
|
||||
},
|
||||
{ icon: PackageSearch, label: 'Fehlt noch', value: formatNumber(missing) },
|
||||
] as const;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Sammlung</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{activeCollector
|
||||
? `Alles, was ${activeCollector.name} tatsächlich hat.`
|
||||
: 'Alles, was tatsächlich da ist.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
{tiles.map((tile) => (
|
||||
<Card key={tile.label}>
|
||||
<CardContent className="space-y-1 p-4">
|
||||
<tile.icon className="size-4 text-muted-foreground" aria-hidden />
|
||||
<p className="text-xl font-semibold tabular-nums">{tile.value}</p>
|
||||
<p className="text-xs text-muted-foreground">{tile.label}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{stats.byManufacturer.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{stats.byManufacturer.map((row) => (
|
||||
<Badge key={row.name} variant="secondary">
|
||||
{row.name}: {formatNumber(row.count)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<CollectionFilters
|
||||
locations={listLocations().map((row) => ({ id: row.id, name: row.name }))}
|
||||
manufacturers={listManufacturers().map((row) => ({ id: row.id, name: row.name }))}
|
||||
/>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{pluralize(items.length, 'Eintrag', 'Einträge')}
|
||||
</p>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<div className="space-y-3 rounded-lg border border-dashed border-border p-8 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Hier ist noch nichts. Sets kommen über den Katalog in die Sammlung.
|
||||
</p>
|
||||
<Link href="/katalog" className="text-sm underline underline-offset-4">
|
||||
Zum Katalog
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-5 pt-2">
|
||||
{items.map((item) => {
|
||||
const image = setImageSrc(item);
|
||||
return (
|
||||
<li key={item.id} className="relative">
|
||||
{/* The studs carry the collector's colour once there is more than one. */}
|
||||
<Studs
|
||||
studClassName={
|
||||
collectors.length > 1 ? collectorDotClass(item.collectorColor) : undefined
|
||||
}
|
||||
/>
|
||||
<Link
|
||||
href={`/katalog/${item.setId}`}
|
||||
className="brick flex items-center gap-3 rounded-xl bg-card p-3 transition-colors hover:bg-muted/50"
|
||||
>
|
||||
<div className="size-16 shrink-0 overflow-hidden rounded-lg border-[length:max(1px,var(--edge-w))] border-edge bg-muted">
|
||||
{image ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={image} alt="" loading="lazy" className="size-full object-contain" />
|
||||
) : (
|
||||
<SetPlaceholder
|
||||
setNumber={item.setNumber}
|
||||
manufacturerName={item.manufacturerName}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{item.setName}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{[item.setNumber, item.manufacturerName, item.locationName]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</p>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1">
|
||||
{/* Green like "Vorhanden" in the catalog; red stays for actions. */}
|
||||
<Badge
|
||||
variant={item.status === 'sold' ? 'outline' : 'default'}
|
||||
className={item.status === 'sold' ? undefined : 'bg-owned text-owned-foreground'}
|
||||
>
|
||||
{statusLabel(item.status)}
|
||||
</Badge>
|
||||
{item.completeness !== 'unknown' ? (
|
||||
<Badge variant="secondary">{completenessLabel(item.completeness)}</Badge>
|
||||
) : null}
|
||||
{collectors.length > 1 && item.collectorName ? (
|
||||
<Badge className={collectorBadgeClass(item.collectorColor)}>
|
||||
{item.collectorName}
|
||||
</Badge>
|
||||
) : null}
|
||||
{openParts.get(item.id) ? (
|
||||
<Badge variant="outline">{openParts.get(item.id)} Fehlteile</Badge>
|
||||
) : null}
|
||||
{item.acquiredAt ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
seit {formatDate(item.acquiredAt)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { and, eq, ne } from 'drizzle-orm';
|
||||
import { db } from '@/db';
|
||||
import { location, manufacturer, set, theme } from '@/db/schema';
|
||||
import { actionError, actionOk, parseForm, type ActionResult } from '@/lib/action-result';
|
||||
import { requireUser } from '@/lib/session';
|
||||
import { uniqueSlug } from '@/lib/slug';
|
||||
import { locationSchema, manufacturerSchema, themeSchema } from '@/lib/validation';
|
||||
|
||||
function now() {
|
||||
return new Date();
|
||||
}
|
||||
|
||||
function revalidateMasterData() {
|
||||
revalidatePath('/verwaltung/hersteller');
|
||||
revalidatePath('/verwaltung/themen');
|
||||
revalidatePath('/verwaltung/standorte');
|
||||
revalidatePath('/katalog');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Manufacturers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function createManufacturer(input: unknown): Promise<ActionResult> {
|
||||
await requireUser();
|
||||
const parsed = parseForm(manufacturerSchema, input);
|
||||
if (!parsed.ok) return parsed;
|
||||
|
||||
const taken = db.select({ slug: manufacturer.slug }).from(manufacturer).all();
|
||||
const exists = db
|
||||
.select({ id: manufacturer.id })
|
||||
.from(manufacturer)
|
||||
.where(eq(manufacturer.name, parsed.data.name))
|
||||
.get();
|
||||
if (exists) return actionError('Diesen Hersteller gibt es schon.');
|
||||
|
||||
db.insert(manufacturer)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
name: parsed.data.name,
|
||||
slug: uniqueSlug(
|
||||
parsed.data.name,
|
||||
taken.map((row) => row.slug),
|
||||
),
|
||||
website: parsed.data.website,
|
||||
createdAt: now(),
|
||||
updatedAt: now(),
|
||||
})
|
||||
.run();
|
||||
|
||||
revalidateMasterData();
|
||||
return actionOk();
|
||||
}
|
||||
|
||||
export async function updateManufacturer(id: string, input: unknown): Promise<ActionResult> {
|
||||
await requireUser();
|
||||
const parsed = parseForm(manufacturerSchema, input);
|
||||
if (!parsed.ok) return parsed;
|
||||
|
||||
const clash = db
|
||||
.select({ id: manufacturer.id })
|
||||
.from(manufacturer)
|
||||
.where(and(eq(manufacturer.name, parsed.data.name), ne(manufacturer.id, id)))
|
||||
.get();
|
||||
if (clash) return actionError('Ein anderer Hersteller heißt bereits so.');
|
||||
|
||||
db.update(manufacturer)
|
||||
.set({ name: parsed.data.name, website: parsed.data.website, updatedAt: now() })
|
||||
.where(eq(manufacturer.id, id))
|
||||
.run();
|
||||
|
||||
revalidateMasterData();
|
||||
return actionOk();
|
||||
}
|
||||
|
||||
export async function deleteManufacturer(id: string): Promise<ActionResult> {
|
||||
await requireUser();
|
||||
|
||||
const used = db.select({ id: set.id }).from(set).where(eq(set.manufacturerId, id)).get();
|
||||
if (used) {
|
||||
return actionError(
|
||||
'Der Hersteller wird noch von mindestens einem Set verwendet und kann deshalb nicht gelöscht werden.',
|
||||
);
|
||||
}
|
||||
|
||||
db.delete(manufacturer).where(eq(manufacturer.id, id)).run();
|
||||
revalidateMasterData();
|
||||
return actionOk();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Themes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function createTheme(input: unknown): Promise<ActionResult> {
|
||||
await requireUser();
|
||||
const parsed = parseForm(themeSchema, input);
|
||||
if (!parsed.ok) return parsed;
|
||||
|
||||
const exists = db
|
||||
.select({ id: theme.id })
|
||||
.from(theme)
|
||||
.where(
|
||||
and(eq(theme.manufacturerId, parsed.data.manufacturerId), eq(theme.name, parsed.data.name)),
|
||||
)
|
||||
.get();
|
||||
if (exists) return actionError('Dieses Thema gibt es beim Hersteller schon.');
|
||||
|
||||
db.insert(theme)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
manufacturerId: parsed.data.manufacturerId,
|
||||
name: parsed.data.name,
|
||||
parentThemeId: parsed.data.parentThemeId,
|
||||
createdAt: now(),
|
||||
updatedAt: now(),
|
||||
})
|
||||
.run();
|
||||
|
||||
revalidateMasterData();
|
||||
return actionOk();
|
||||
}
|
||||
|
||||
export async function updateTheme(id: string, input: unknown): Promise<ActionResult> {
|
||||
await requireUser();
|
||||
const parsed = parseForm(themeSchema, input);
|
||||
if (!parsed.ok) return parsed;
|
||||
|
||||
if (parsed.data.parentThemeId === id) {
|
||||
return actionError('Ein Thema kann nicht sein eigenes Oberthema sein.');
|
||||
}
|
||||
|
||||
const clash = db
|
||||
.select({ id: theme.id })
|
||||
.from(theme)
|
||||
.where(
|
||||
and(
|
||||
eq(theme.manufacturerId, parsed.data.manufacturerId),
|
||||
eq(theme.name, parsed.data.name),
|
||||
ne(theme.id, id),
|
||||
),
|
||||
)
|
||||
.get();
|
||||
if (clash) return actionError('Ein anderes Thema dieses Herstellers heißt bereits so.');
|
||||
|
||||
db.update(theme)
|
||||
.set({
|
||||
manufacturerId: parsed.data.manufacturerId,
|
||||
name: parsed.data.name,
|
||||
parentThemeId: parsed.data.parentThemeId,
|
||||
updatedAt: now(),
|
||||
})
|
||||
.where(eq(theme.id, id))
|
||||
.run();
|
||||
|
||||
revalidateMasterData();
|
||||
return actionOk();
|
||||
}
|
||||
|
||||
export async function deleteTheme(id: string): Promise<ActionResult> {
|
||||
await requireUser();
|
||||
|
||||
// Sets keep existing without a theme (ON DELETE SET NULL), but silently
|
||||
// unthemeing a dozen sets would be a surprise - so we ask for a decision.
|
||||
const used = db.select({ id: set.id }).from(set).where(eq(set.themeId, id)).get();
|
||||
if (used) {
|
||||
return actionError(
|
||||
'Dem Thema sind noch Sets zugeordnet. Ordne sie zuerst um, dann lässt sich das Thema löschen.',
|
||||
);
|
||||
}
|
||||
|
||||
db.delete(theme).where(eq(theme.id, id)).run();
|
||||
revalidateMasterData();
|
||||
return actionOk();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Locations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function createLocation(input: unknown): Promise<ActionResult> {
|
||||
await requireUser();
|
||||
const parsed = parseForm(locationSchema, input);
|
||||
if (!parsed.ok) return parsed;
|
||||
|
||||
db.insert(location)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
name: parsed.data.name,
|
||||
parentLocationId: parsed.data.parentLocationId,
|
||||
createdAt: now(),
|
||||
updatedAt: now(),
|
||||
})
|
||||
.run();
|
||||
|
||||
revalidatePath('/verwaltung/standorte');
|
||||
revalidatePath('/sammlung');
|
||||
return actionOk();
|
||||
}
|
||||
|
||||
export async function updateLocation(id: string, input: unknown): Promise<ActionResult> {
|
||||
await requireUser();
|
||||
const parsed = parseForm(locationSchema, input);
|
||||
if (!parsed.ok) return parsed;
|
||||
|
||||
if (parsed.data.parentLocationId === id) {
|
||||
return actionError('Ein Standort kann nicht in sich selbst liegen.');
|
||||
}
|
||||
|
||||
db.update(location)
|
||||
.set({
|
||||
name: parsed.data.name,
|
||||
parentLocationId: parsed.data.parentLocationId,
|
||||
updatedAt: now(),
|
||||
})
|
||||
.where(eq(location.id, id))
|
||||
.run();
|
||||
|
||||
revalidatePath('/verwaltung/standorte');
|
||||
revalidatePath('/sammlung');
|
||||
return actionOk();
|
||||
}
|
||||
|
||||
export async function deleteLocation(id: string): Promise<ActionResult> {
|
||||
await requireUser();
|
||||
|
||||
// Collection items only lose their location (ON DELETE SET NULL).
|
||||
db.delete(location).where(eq(location.id, id)).run();
|
||||
|
||||
revalidatePath('/verwaltung/standorte');
|
||||
revalidatePath('/sammlung');
|
||||
return actionOk();
|
||||
}
|
||||
@@ -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,88 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import {
|
||||
actionData,
|
||||
actionError,
|
||||
actionOk,
|
||||
formToObject,
|
||||
parseForm,
|
||||
type ActionResult,
|
||||
} from '@/lib/action-result';
|
||||
import { env } from '@/env';
|
||||
import {
|
||||
deleteMailSettings,
|
||||
getMailConfig,
|
||||
isMailConfigured,
|
||||
saveMailSettings,
|
||||
} from '@/lib/mail-settings';
|
||||
import { sendMail, verifyMailConnection } from '@/lib/mailer';
|
||||
import { mailSettingsSchema } from '@/lib/validation';
|
||||
import { runReminderJob, type ReminderRun } from '@/lib/reminder-job';
|
||||
import { requireAdmin } from '@/lib/session';
|
||||
|
||||
export async function testMailConnection(): Promise<ActionResult> {
|
||||
await requireAdmin();
|
||||
|
||||
const result = await verifyMailConnection();
|
||||
return result.ok ? { ok: true } : actionError(result.error);
|
||||
}
|
||||
|
||||
export async function sendTestMail(): Promise<ActionResult> {
|
||||
const admin = await requireAdmin();
|
||||
|
||||
if (!isMailConfigured()) {
|
||||
return actionError('Es ist kein SMTP-Server konfiguriert.');
|
||||
}
|
||||
|
||||
const result = await sendMail({
|
||||
to: admin.email,
|
||||
subject: 'Bricktrack: Testnachricht',
|
||||
text: [
|
||||
'Das hat geklappt.',
|
||||
'',
|
||||
'Wenn diese Nachricht ankommt, funktionieren auch die Erinnerungen zu',
|
||||
'Wunschlisten-Terminen.',
|
||||
'',
|
||||
env.BASE_URL,
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
return result.ok ? { ok: true } : actionError(result.error);
|
||||
}
|
||||
|
||||
/** Runs the check immediately instead of waiting for the next hour. */
|
||||
export async function runRemindersNow(): Promise<ActionResult<ReminderRun>> {
|
||||
await requireAdmin();
|
||||
|
||||
if (!isMailConfigured()) {
|
||||
return actionError('Es ist kein SMTP-Server konfiguriert.');
|
||||
}
|
||||
|
||||
const run = await runReminderJob();
|
||||
revalidatePath('/verwaltung/erinnerungen');
|
||||
return actionData(run);
|
||||
}
|
||||
|
||||
/** Stores the SMTP server set up on the reminders page. */
|
||||
export async function saveMailSettingsAction(formData: FormData): Promise<ActionResult> {
|
||||
await requireAdmin();
|
||||
|
||||
if (getMailConfig()?.source === 'env') {
|
||||
return actionError('Der Mailserver kommt aus der .env. Änderungen bitte dort vornehmen.');
|
||||
}
|
||||
|
||||
const parsed = parseForm(mailSettingsSchema, formToObject(formData));
|
||||
if (!parsed.ok) return parsed;
|
||||
|
||||
saveMailSettings(parsed.data);
|
||||
revalidatePath('/verwaltung/erinnerungen');
|
||||
return actionOk();
|
||||
}
|
||||
|
||||
export async function deleteMailSettingsAction(): Promise<ActionResult> {
|
||||
await requireAdmin();
|
||||
deleteMailSettings();
|
||||
revalidatePath('/verwaltung/erinnerungen');
|
||||
return actionOk();
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
import { db } from '@/db';
|
||||
import { wishlist } from '@/db/schema';
|
||||
import { env } from '@/env';
|
||||
import { MailSettingsForm } from '@/components/verwaltung/mail-settings-form';
|
||||
import {
|
||||
ReminderSettings,
|
||||
type ReminderListRow,
|
||||
} from '@/components/verwaltung/reminder-settings';
|
||||
import { getMailSettingsView } from '@/lib/mail-settings';
|
||||
import { daysUntil } from '@/lib/reminders';
|
||||
import { requireAdmin } from '@/lib/session';
|
||||
|
||||
export const metadata: Metadata = { title: 'Erinnerungen' };
|
||||
|
||||
export default async function ErinnerungenPage() {
|
||||
const admin = await requireAdmin();
|
||||
|
||||
const openItems = sql<number>`(
|
||||
select count(*) from wishlist_item wi
|
||||
where wi.wishlist_id = ${wishlist.id}
|
||||
and wi.reserved_at is null
|
||||
and wi.fulfilled_at is null
|
||||
)`.mapWith(Number);
|
||||
|
||||
const now = new Date();
|
||||
const lists: ReminderListRow[] = db
|
||||
.select({
|
||||
id: wishlist.id,
|
||||
title: wishlist.title,
|
||||
eventDate: wishlist.eventDate,
|
||||
lastReminderAt: wishlist.lastReminderAt,
|
||||
openItemCount: openItems,
|
||||
})
|
||||
.from(wishlist)
|
||||
.where(eq(wishlist.ownerUserId, admin.id))
|
||||
.all()
|
||||
.filter((row) => row.eventDate !== null)
|
||||
.map((row) => ({
|
||||
...row,
|
||||
daysLeft: row.eventDate ? daysUntil(row.eventDate, now) : null,
|
||||
}))
|
||||
.sort((a, b) => (a.daysLeft ?? 0) - (b.daysLeft ?? 0));
|
||||
|
||||
const mail = getMailSettingsView();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<ReminderSettings
|
||||
configured={mail.source !== null}
|
||||
daysBefore={env.REMINDER_DAYS_BEFORE}
|
||||
lists={lists}
|
||||
/>
|
||||
<MailSettingsForm view={mail} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { ManufacturerList, type ManufacturerRow } from '@/components/verwaltung/manufacturer-list';
|
||||
import { pluralize } from '@/lib/format';
|
||||
import { listManufacturers } from '@/lib/queries/master-data';
|
||||
|
||||
export const metadata: Metadata = { title: 'Hersteller' };
|
||||
|
||||
export default function HerstellerPage() {
|
||||
const rows: ManufacturerRow[] = listManufacturers().map((row) => ({
|
||||
id: row.id,
|
||||
title: row.name,
|
||||
subtitle: [pluralize(row.setCount, 'Set', 'Sets'), row.website]
|
||||
.filter(Boolean)
|
||||
.join(' · '),
|
||||
website: row.website,
|
||||
setCount: row.setCount,
|
||||
}));
|
||||
|
||||
return <ManufacturerList manufacturers={rows} />;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { VerwaltungHeader } from '@/components/verwaltung/verwaltung-header';
|
||||
import { requireUser } from '@/lib/session';
|
||||
|
||||
export const metadata: Metadata = { title: 'Verwaltung' };
|
||||
|
||||
export default async function VerwaltungLayout({ children }: { children: React.ReactNode }) {
|
||||
await requireUser();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<VerwaltungHeader />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { asc, eq, sql } from 'drizzle-orm';
|
||||
import { db } from '@/db';
|
||||
import { collectionItem, collector, wishlist } from '@/db/schema';
|
||||
import { actionError, actionOk, parseForm, type ActionResult } from '@/lib/action-result';
|
||||
import { requireUser } from '@/lib/session';
|
||||
import { collectorSchema } from '@/lib/validation';
|
||||
|
||||
function now() {
|
||||
return new Date();
|
||||
}
|
||||
|
||||
function revalidateEverything() {
|
||||
// A collector shows up in the catalog, the collection and the wishlists.
|
||||
revalidatePath('/', 'layout');
|
||||
}
|
||||
|
||||
export async function createCollector(input: unknown): Promise<ActionResult> {
|
||||
await requireUser();
|
||||
|
||||
const parsed = parseForm(collectorSchema, input);
|
||||
if (!parsed.ok) return parsed;
|
||||
|
||||
const [row] = db
|
||||
.select({ next: sql<number>`coalesce(max(${collector.sortOrder}), -1) + 1`.mapWith(Number) })
|
||||
.from(collector)
|
||||
.all();
|
||||
|
||||
db.insert(collector)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
name: parsed.data.name,
|
||||
color: parsed.data.color,
|
||||
birthday: parsed.data.birthday,
|
||||
notes: parsed.data.notes,
|
||||
sortOrder: row?.next ?? 0,
|
||||
createdAt: now(),
|
||||
updatedAt: now(),
|
||||
})
|
||||
.run();
|
||||
|
||||
revalidateEverything();
|
||||
return actionOk();
|
||||
}
|
||||
|
||||
export async function updateCollector(id: string, input: unknown): Promise<ActionResult> {
|
||||
await requireUser();
|
||||
|
||||
const parsed = parseForm(collectorSchema, input);
|
||||
if (!parsed.ok) return parsed;
|
||||
|
||||
db.update(collector)
|
||||
.set({
|
||||
name: parsed.data.name,
|
||||
color: parsed.data.color,
|
||||
birthday: parsed.data.birthday,
|
||||
notes: parsed.data.notes,
|
||||
updatedAt: now(),
|
||||
})
|
||||
.where(eq(collector.id, id))
|
||||
.run();
|
||||
|
||||
revalidateEverything();
|
||||
return actionOk();
|
||||
}
|
||||
|
||||
/**
|
||||
* Deleting a collector would take their whole collection with it, so it is
|
||||
* only allowed while nothing is attached - and never for the last one.
|
||||
*/
|
||||
export async function deleteCollector(id: string): Promise<ActionResult> {
|
||||
await requireUser();
|
||||
|
||||
const remaining = db.select({ id: collector.id }).from(collector).all();
|
||||
if (remaining.length <= 1) {
|
||||
return actionError('Der letzte Sammler kann nicht gelöscht werden.');
|
||||
}
|
||||
|
||||
const owned = db
|
||||
.select({ id: collectionItem.id })
|
||||
.from(collectionItem)
|
||||
.where(eq(collectionItem.collectorId, id))
|
||||
.get();
|
||||
if (owned) {
|
||||
return actionError(
|
||||
'Diesem Sammler sind noch Sets zugeordnet. Ordne sie erst jemand anderem zu, dann lässt er sich löschen.',
|
||||
);
|
||||
}
|
||||
|
||||
const lists = db
|
||||
.select({ id: wishlist.id })
|
||||
.from(wishlist)
|
||||
.where(eq(wishlist.collectorId, id))
|
||||
.get();
|
||||
if (lists) {
|
||||
return actionError(
|
||||
'Zu diesem Sammler gibt es noch Wunschlisten. Ordne sie erst um oder lösch sie.',
|
||||
);
|
||||
}
|
||||
|
||||
db.delete(collector).where(eq(collector.id, id)).run();
|
||||
|
||||
revalidateEverything();
|
||||
return actionOk();
|
||||
}
|
||||
|
||||
/** Moves every owned copy of one collector over to another. */
|
||||
export async function reassignCollectionItems(
|
||||
fromCollectorId: string,
|
||||
toCollectorId: string,
|
||||
): Promise<ActionResult> {
|
||||
await requireUser();
|
||||
|
||||
if (fromCollectorId === toCollectorId) return actionOk();
|
||||
|
||||
const target = db
|
||||
.select({ id: collector.id })
|
||||
.from(collector)
|
||||
.where(eq(collector.id, toCollectorId))
|
||||
.get();
|
||||
if (!target) return actionError('Diesen Sammler gibt es nicht.');
|
||||
|
||||
db.update(collectionItem)
|
||||
.set({ collectorId: toCollectorId, updatedAt: now() })
|
||||
.where(eq(collectionItem.collectorId, fromCollectorId))
|
||||
.run();
|
||||
|
||||
revalidateEverything();
|
||||
return actionOk();
|
||||
}
|
||||
|
||||
/** Reorders the collectors; the first one is the default for new entries. */
|
||||
export async function reorderCollectors(orderedIds: string[]): Promise<ActionResult> {
|
||||
await requireUser();
|
||||
|
||||
db.transaction((tx) => {
|
||||
orderedIds.forEach((id, index) => {
|
||||
tx.update(collector)
|
||||
.set({ sortOrder: index, updatedAt: now() })
|
||||
.where(eq(collector.id, id))
|
||||
.run();
|
||||
});
|
||||
});
|
||||
|
||||
revalidateEverything();
|
||||
return actionOk();
|
||||
}
|
||||
|
||||
/** Every collector, ordered - for the selects in the forms. */
|
||||
export async function listCollectorOptions(): Promise<Array<{ id: string; name: string }>> {
|
||||
await requireUser();
|
||||
|
||||
return db
|
||||
.select({ id: collector.id, name: collector.name })
|
||||
.from(collector)
|
||||
.orderBy(asc(collector.sortOrder), asc(collector.name))
|
||||
.all();
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { CollectorList, type CollectorRowItem } from '@/components/verwaltung/collector-list';
|
||||
import { formatDate, pluralize } from '@/lib/format';
|
||||
import { COLLECTOR_COLOR_LABELS } from '@/lib/collector-colors';
|
||||
import { listCollectors } from '@/lib/queries/collectors';
|
||||
import { requireUser } from '@/lib/session';
|
||||
|
||||
export const metadata: Metadata = { title: 'Sammler' };
|
||||
|
||||
export default async function SammlerPage() {
|
||||
await requireUser();
|
||||
|
||||
const collectors = listCollectors();
|
||||
const rows: CollectorRowItem[] = collectors.map((row) => ({
|
||||
id: row.id,
|
||||
title: row.name,
|
||||
subtitle: [
|
||||
pluralize(row.itemCount, 'Set', 'Sets'),
|
||||
COLLECTOR_COLOR_LABELS[row.color],
|
||||
row.birthday ? `geboren am ${formatDate(row.birthday)}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · '),
|
||||
color: row.color,
|
||||
birthday: row.birthday,
|
||||
notes: row.notes,
|
||||
itemCount: row.itemCount,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="rounded-lg border border-dashed border-border p-4 text-sm text-muted-foreground">
|
||||
Der Katalog ist gemeinsam: Ein Set existiert einmal, unabhängig davon, wer es hat.
|
||||
Sammlung und Wunschlisten gehören dagegen je einem Sammler. Solange es nur einen gibt,
|
||||
taucht die Unterscheidung nirgends in der App auf – sie erscheint erst mit dem zweiten.
|
||||
</p>
|
||||
<CollectorList collectors={rows} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { LocationList, type LocationRow } from '@/components/verwaltung/location-list';
|
||||
import { listLocations } from '@/lib/queries/master-data';
|
||||
|
||||
export const metadata: Metadata = { title: 'Standorte' };
|
||||
|
||||
export default function StandortePage() {
|
||||
const rows: LocationRow[] = listLocations().map((row) => ({
|
||||
id: row.id,
|
||||
title: row.name,
|
||||
subtitle: row.parentName ? `in ${row.parentName}` : null,
|
||||
parentLocationId: row.parentLocationId,
|
||||
}));
|
||||
|
||||
return <LocationList locations={rows} />;
|
||||
}
|
||||