diff --git a/CHANGELOG.md b/CHANGELOG.md index 6346bc2..0eecc79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,14 +7,86 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/). ## [Unreleased] +## [0.1.0] - 2026-09-07 + +Erste lauffaehige Fassung: Phase 1 vollstaendig, im Docker-Stack verifiziert. + ### 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. +**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. -[Unreleased]: https://git.wilde-server.de/florian.wilde/bricktrack/compare/main...HEAD +**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.1.0...HEAD +[0.1.0]: https://git.wilde-server.de/florian.wilde/bricktrack/releases/tag/v0.1.0 diff --git a/docs/ANLEITUNG.md b/docs/ANLEITUNG.md new file mode 100644 index 0000000..5578bc2 --- /dev/null +++ b/docs/ANLEITUNG.md @@ -0,0 +1,322 @@ +# 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. [Antworten auf die zwei wichtigen Fragen](#11-die-zwei-fragen) + +--- + +## 1. Das erste Konto + +![Registrierung](bilder/01-registrierung.png) + +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 + +![Startseite](bilder/02-start.png) + +Die Startseite ist die Abzweigung zu den vier Bereichen: + +| | | +|---|---| +| **Katalog** | Alle bekannten Sets – egal ob vorhanden oder nicht | +| **Fehlende Sets** | Der direkte Weg zu allem, was noch fehlt | +| **Sammlung** | Was tatsächlich da ist | +| **Wunschlisten** | Zum Teilen mit Familie und Freunden | + +Unten am Bildschirmrand liegt die Navigation – auf dem Handy mit dem Daumen +erreichbar. + +--- + +## 3. Stammdaten anlegen + +![Hersteller verwalten](bilder/03-verwaltung-hersteller.png) + +Bevor du Sets anlegst, brauchst du mindestens einen **Hersteller**. Zu finden +über das Konto-Menü oben rechts → **Verwaltung**. + +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 + +![Katalog](bilder/04-katalog.png) + +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 + +Mit **„Set anlegen"** oben rechts 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? + +![Fehlende Sets](bilder/05-fehlende-sets.png) + +**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 + +![Mehrfachauswahl](bilder/06-mehrfachauswahl.png) + +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"**. + +![Zieldialog](bilder/07-wunschliste-dialog.png) + +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 + +![Set-Detailseite](bilder/08-set-detail.png) + +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 + +![Sammlung](bilder/09-sammlung.png) + +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 + +![Wunschlisten](bilder/10-wunschlisten.png) + +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. + +![Wunschliste bearbeiten](bilder/11-wunschliste-bearbeiten.png) + +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 + +![Öffentliche Wunschliste](bilder/12-oeffentliche-wunschliste.png) + +Kein Konto, keine Anmeldung, keine App. Nur die Liste, mit Bild, Setnummer, +Preis, Priorität und – falls hinterlegt – einem Link zum Shop des Herstellers. + +![Reservieren](bilder/13-reservieren.png) + +Ein Tipp auf **„Das schenke ich"**, Name eintragen, fertig. + +![Reserviert](bilder/14-reserviert.png) + +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. + +![Reservierung in der Übersicht](bilder/15-wunschliste-reserviert.png) + +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 + +![Konto und Passkeys](bilder/16-konto-passkeys.png) + +Unter **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. 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. + +Im Laden vor dem Regal: Katalog öffnen, Setnummer von der Packung eintippen. +Steht ein grüner Haken dran, habt ihr es schon. + +--- + +## 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. diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index f296602..2769344 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -1,32 +1,98 @@ # 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** + +--- ## 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. + +### 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. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 3ca7b74..af1460c 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -2,36 +2,36 @@ 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) diff --git a/docs/bilder/01-registrierung.png b/docs/bilder/01-registrierung.png new file mode 100644 index 0000000..7788f17 Binary files /dev/null and b/docs/bilder/01-registrierung.png differ diff --git a/docs/bilder/02-start.png b/docs/bilder/02-start.png new file mode 100644 index 0000000..441cab0 Binary files /dev/null and b/docs/bilder/02-start.png differ diff --git a/docs/bilder/03-verwaltung-hersteller.png b/docs/bilder/03-verwaltung-hersteller.png new file mode 100644 index 0000000..d64c892 Binary files /dev/null and b/docs/bilder/03-verwaltung-hersteller.png differ diff --git a/docs/bilder/04-katalog.png b/docs/bilder/04-katalog.png new file mode 100644 index 0000000..f9a3021 Binary files /dev/null and b/docs/bilder/04-katalog.png differ diff --git a/docs/bilder/05-fehlende-sets.png b/docs/bilder/05-fehlende-sets.png new file mode 100644 index 0000000..8c1a8e1 Binary files /dev/null and b/docs/bilder/05-fehlende-sets.png differ diff --git a/docs/bilder/06-mehrfachauswahl.png b/docs/bilder/06-mehrfachauswahl.png new file mode 100644 index 0000000..fa3787b Binary files /dev/null and b/docs/bilder/06-mehrfachauswahl.png differ diff --git a/docs/bilder/07-wunschliste-dialog.png b/docs/bilder/07-wunschliste-dialog.png new file mode 100644 index 0000000..518254e Binary files /dev/null and b/docs/bilder/07-wunschliste-dialog.png differ diff --git a/docs/bilder/08-set-detail.png b/docs/bilder/08-set-detail.png new file mode 100644 index 0000000..d33cc18 Binary files /dev/null and b/docs/bilder/08-set-detail.png differ diff --git a/docs/bilder/09-sammlung.png b/docs/bilder/09-sammlung.png new file mode 100644 index 0000000..57ff976 Binary files /dev/null and b/docs/bilder/09-sammlung.png differ diff --git a/docs/bilder/10-wunschlisten.png b/docs/bilder/10-wunschlisten.png new file mode 100644 index 0000000..b0b69fe Binary files /dev/null and b/docs/bilder/10-wunschlisten.png differ diff --git a/docs/bilder/11-wunschliste-bearbeiten.png b/docs/bilder/11-wunschliste-bearbeiten.png new file mode 100644 index 0000000..94816f6 Binary files /dev/null and b/docs/bilder/11-wunschliste-bearbeiten.png differ diff --git a/docs/bilder/12-oeffentliche-wunschliste.png b/docs/bilder/12-oeffentliche-wunschliste.png new file mode 100644 index 0000000..8a61b0a Binary files /dev/null and b/docs/bilder/12-oeffentliche-wunschliste.png differ diff --git a/docs/bilder/13-reservieren.png b/docs/bilder/13-reservieren.png new file mode 100644 index 0000000..5b08544 Binary files /dev/null and b/docs/bilder/13-reservieren.png differ diff --git a/docs/bilder/14-reserviert.png b/docs/bilder/14-reserviert.png new file mode 100644 index 0000000..5988793 Binary files /dev/null and b/docs/bilder/14-reserviert.png differ diff --git a/docs/bilder/15-wunschliste-reserviert.png b/docs/bilder/15-wunschliste-reserviert.png new file mode 100644 index 0000000..a4c315e Binary files /dev/null and b/docs/bilder/15-wunschliste-reserviert.png differ diff --git a/docs/bilder/16-konto-passkeys.png b/docs/bilder/16-konto-passkeys.png new file mode 100644 index 0000000..18ae769 Binary files /dev/null and b/docs/bilder/16-konto-passkeys.png differ diff --git a/e2e/screenshots-setup.ts b/e2e/screenshots-setup.ts deleted file mode 100644 index 5d06996..0000000 --- a/e2e/screenshots-setup.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { execFileSync } from 'node:child_process'; - -/** Migrates the screenshot database; the directory is wiped by the npm script. */ -export default function globalSetup() { - execFileSync('node', ['node_modules/tsx/dist/cli.mjs', 'scripts/migrate.ts'], { - stdio: 'inherit', - env: { ...process.env, DATA_DIR: './data-shots' }, - }); -} diff --git a/e2e/screenshots.spec.ts b/e2e/screenshots.spec.ts index 0151e12..8289d7e 100644 --- a/e2e/screenshots.spec.ts +++ b/e2e/screenshots.spec.ts @@ -22,9 +22,14 @@ async function shoot(page: Page, name: string) { 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 page.goto('/registrieren'); + await visit(page, '/registrieren'); await expect(page.getByText('Erstes Konto anlegen')).toBeVisible(); await shoot(page, '01-registrierung'); @@ -41,25 +46,26 @@ test('Screenshots fuer die Anleitung', async ({ page, browser }) => { }); // --- 2. dashboard --------------------------------------------------------- - await page.goto('/'); + await visit(page, '/'); await shoot(page, '02-start'); // --- 3. master data ------------------------------------------------------- - await page.goto('/verwaltung/hersteller'); - await expect(page.getByText('Lego')).toBeVisible(); + await visit(page, '/verwaltung/hersteller'); + await expect(page.getByText('Lego', { exact: true })).toBeVisible(); await shoot(page, '03-verwaltung-hersteller'); // --- 4. catalog ----------------------------------------------------------- - await page.goto('/katalog'); - await expect(page.getByText('Familienhaus und Elektroauto')).toBeVisible(); + await visit(page, '/katalog'); + await expect(page.getByText('Familienhaus und Elektroauto').first()).toBeVisible(); await shoot(page, '04-katalog'); // --- 5. missing sets, selection mode -------------------------------------- - await page.goto('/katalog?besitz=fehlt'); - await expect(page.getByText('Fehlende Sets')).toBeVisible(); + 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(); const cards = page.locator('button[aria-pressed]'); await cards.nth(0).click(); await cards.nth(1).click(); @@ -72,25 +78,25 @@ test('Screenshots fuer die Anleitung', async ({ page, browser }) => { await page.getByRole('button', { name: 'Abbrechen' }).click(); // --- 6. set detail -------------------------------------------------------- - await page.goto('/katalog?q=60398'); + 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 page.goto('/sammlung'); - await expect(page.getByText('Wert nach UVP')).toBeVisible(); + await visit(page, '/sammlung'); + await expect(page.getByText('Wert nach UVP').first()).toBeVisible(); await shoot(page, '09-sammlung'); // --- 8. wishlist ---------------------------------------------------------- - await page.goto('/wunschlisten'); - await expect(page.getByText('Weihnachten 2026')).toBeVisible(); + 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 page.goto(listHref!); + await visit(page, listHref!); await expect(page.getByRole('heading', { name: 'Weihnachten 2026' })).toBeVisible(); await shoot(page, '11-wunschliste-bearbeiten'); @@ -100,7 +106,7 @@ test('Screenshots fuer die Anleitung', async ({ page, browser }) => { const visitorContext = await browser.newContext({ locale: 'de-DE' }); const visitor = await visitorContext.newPage(); await visitor.setViewportSize({ width: 412, height: 915 }); - await visitor.goto(shareUrl); + await visit(visitor, shareUrl); await expect(visitor.getByRole('heading', { name: 'Weihnachten 2026' })).toBeVisible(); await shoot(visitor, '12-oeffentliche-wunschliste'); @@ -118,7 +124,7 @@ test('Screenshots fuer die Anleitung', async ({ page, browser }) => { await shoot(page, '15-wunschliste-reserviert'); // --- 11. account and passkeys -------------------------------------------- - await page.goto('/konto'); - await expect(page.getByText('Passkeys')).toBeVisible(); + await visit(page, '/konto'); + await expect(page.getByText('Passkeys', { exact: true })).toBeVisible(); await shoot(page, '16-konto-passkeys'); }); diff --git a/package.json b/package.json index 484159d..49f9650 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "test": "vitest run", "test:watch": "vitest", "test:e2e": "node scripts/reset-e2e.mjs && playwright test", - "screenshots": "node scripts/reset-shots.mjs && playwright test --config=playwright.screenshots.config.ts" + "screenshots": "node scripts/screenshots-prepare.mjs && playwright test --config=playwright.screenshots.config.ts" }, "dependencies": { "@better-auth/passkey": "^1.7.3", diff --git a/playwright.screenshots.config.ts b/playwright.screenshots.config.ts index c8b96b5..f921090 100644 --- a/playwright.screenshots.config.ts +++ b/playwright.screenshots.config.ts @@ -1,23 +1,31 @@ +import path from 'node:path'; import { defineConfig, devices } from '@playwright/test'; /** * Produces the screenshots for docs/ANLEITUNG.md. * - * Kept apart from the smoke test on purpose: this run needs seeded example - * data and writes files into the repository, which a test never should. + * 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', - globalSetup: './e2e/screenshots-setup.ts', fullyParallel: false, workers: 1, - timeout: 240_000, + // 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, @@ -27,18 +35,19 @@ export default defineConfig({ ...devices['Pixel 7'], }, webServer: { - command: `pnpm exec next dev -p ${PORT}`, + 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', + `-v "${dataDir}:/data"`, + 'bricktrack:shots', + ].join(' '), url: `${BASE_URL}/api/health`, reuseExistingServer: false, timeout: 180_000, stdout: 'pipe', stderr: 'pipe', - env: { - NODE_ENV: 'development', - DATA_DIR: './data-shots', - BASE_URL, - BETTER_AUTH_SECRET: 'screenshot-run-secret-0123456789abcdefgh', - MAX_UPLOAD_MB: '25', - }, }, }); diff --git a/scripts/reset-shots.mjs b/scripts/reset-shots.mjs deleted file mode 100644 index 796c49d..0000000 --- a/scripts/reset-shots.mjs +++ /dev/null @@ -1,7 +0,0 @@ -/** Wipes the screenshot data directory before `pnpm screenshots`. */ -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 }); -console.log(`[screenshots] ${dataDir} zurueckgesetzt.`); diff --git a/scripts/screenshots-prepare.mjs b/scripts/screenshots-prepare.mjs new file mode 100644 index 0000000..69ef5ef --- /dev/null +++ b/scripts/screenshots-prepare.mjs @@ -0,0 +1,21 @@ +/** + * 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.`); + +console.log('[screenshots] Image wird gebaut (bricktrack:shots) …'); +execFileSync('docker', ['build', '-t', 'bricktrack:shots', '.'], { stdio: 'inherit' }); +console.log('[screenshots] Image ist bereit.'); diff --git a/src/components/katalog/catalog-grid.tsx b/src/components/katalog/catalog-grid.tsx index cf58680..7c4fcfb 100644 --- a/src/components/katalog/catalog-grid.tsx +++ b/src/components/katalog/catalog-grid.tsx @@ -159,7 +159,13 @@ export function CatalogGrid({ sets, wishlists, defaultWishlistId, emptyState }: )} -
+
{sets.map((item) => { const owned = item.ownedCopies > 0; const isSelected = selected.has(item.id);