docs: readme, schnellstart, bebilderte anleitung und changelog 0.1.0
QUICKSTART fuehrt von null zur laufenden Instanz und enthaelt die vollstaendige Nginx-Proxy-Manager-Konfiguration - inklusive der beiden Stellen, an denen es sonst klemmt: client_max_body_size fuer grosse Anleitungs-PDFs und X-Forwarded-For fuer das Rate-Limit. Die Anleitung ist bebildert. Die 16 Screenshots entstehen automatisiert mit `pnpm screenshots` gegen das Produktions-Image im Container, nicht gegen den Dev-Server: der brach unter Last reproduzierbar mit ChunkLoadError ab, und die Bilder zeigen so ohnehin genau das, was nach `docker compose up` zu sehen ist. Nebenbei behoben: in der Mehrfachauswahl verdeckte die Aktionsleiste die letzte Kachelreihe. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@@ -7,14 +7,86 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.1.0] - 2026-09-07
|
||||||
|
|
||||||
|
Erste lauffaehige Fassung: Phase 1 vollstaendig, im Docker-Stack verifiziert.
|
||||||
|
|
||||||
### Hinzugefuegt
|
### Hinzugefuegt
|
||||||
|
|
||||||
- Projekt-Scaffold: Next.js 15 (App Router, TypeScript strict),
|
**Anmeldung**
|
||||||
Tailwind CSS 4, shadcn/ui, pnpm.
|
- Registrierung des ersten Nutzers, der damit Administrator wird; danach ist
|
||||||
- Vollstaendiges Datenmodell in Drizzle ORM mit initialer Migration:
|
die Registrierung geschlossen.
|
||||||
Katalog, Besitz, Wunschlisten und die Auth-Tabellen von better-auth.
|
- Anmeldung per E-Mail und Passwort oder per Passkey (WebAuthn), inklusive
|
||||||
- SQLite-Anbindung mit WAL-Modus, aktivierten Fremdschluesseln und
|
Conditional UI, wo der Browser sie unterstuetzt.
|
||||||
Migrations-Runner (`pnpm db:migrate`).
|
- Passkey-Verwaltung unter "Konto": beliebig viele Geraete, umbenennen,
|
||||||
- Projektdokumentation: CLAUDE.md, Roadmap, Changelog, Devlog.
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|

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

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

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

|
||||||
|
|
||||||
|
**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 **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.
|
||||||
@@ -1,32 +1,98 @@
|
|||||||
# Devlog
|
# Devlog
|
||||||
|
|
||||||
Zeiterfassung und Nachvollziehbarkeit der Entwicklung. Eine Zeile je
|
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 |
|
| 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: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 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: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: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: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:26 | 22:34 | 0:08 | Dokumentation | CLAUDE.md (Stack, Konventionen, Datenmodell, Sicherheitsregeln), Roadmap, Changelog, Devlog | `folgt` |
|
| 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
|
## Notizen und Entscheidungen
|
||||||
|
|
||||||
|
### Umgebung
|
||||||
|
|
||||||
- **pnpm**: `corepack enable` scheitert auf diesem Rechner an fehlenden
|
- **pnpm**: `corepack enable` scheitert auf diesem Rechner an fehlenden
|
||||||
Schreibrechten in `C:\Program Files\nodejs`. Stattdessen
|
Schreibrechten in `C:\Program Files\nodejs`. Stattdessen
|
||||||
`npm install -g pnpm@10`; pnpm liegt unter
|
`npm install -g pnpm@10`; pnpm liegt unter `%APPDATA%\npm` und ist im PATH.
|
||||||
`%APPDATA%\npm\pnpm.ps1` und ist im PATH.
|
- **Port 3000** ist auf dem Entwicklungsrechner von einer anderen Anwendung
|
||||||
- **shadcn-CLI**: Die aktuelle Version (4.21) arbeitet mit Presets
|
belegt. Der Dev-Server läuft deshalb lokal auf 3100, die Testläufe auf
|
||||||
(`-p nova`) und Base-Libraries (`-b radix`) statt der frueheren
|
3210 und 3220.
|
||||||
Style-Auswahl. Sie zieht `cn` und `radix-ui` als Pakete; `clsx` und
|
- **`pnpm build` unter Windows** scheitert mit `EPERM … symlink`: Der
|
||||||
`tailwind-merge` werden dadurch nicht mehr direkt gebraucht.
|
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 1.7.3**: Das Passkey-Plugin liegt nicht mehr unter
|
||||||
`better-auth/plugins/passkey`, sondern im eigenen Paket
|
`better-auth/plugins/passkey`, sondern im eigenen Paket
|
||||||
`@better-auth/passkey` (Exports `.` und `./client`). Das erwartete
|
`@better-auth/passkey`. Der Drizzle-Adapter löst Modelle über die
|
||||||
Schema deckt sich exakt mit der Tabelle `passkey`.
|
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.
|
||||||
|
|||||||
@@ -2,36 +2,36 @@
|
|||||||
|
|
||||||
Legende: `[ ]` offen · `[~]` in Arbeit · `[x]` fertig
|
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] Projekt-Scaffold (Next 15, Tailwind 4, shadcn/ui, pnpm)
|
||||||
- [x] Datenmodell in Drizzle, initiale Migration
|
- [x] Datenmodell in Drizzle, initiale Migration
|
||||||
- [x] Projektdokumentation (CLAUDE.md, Roadmap, Changelog, Devlog)
|
- [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),
|
Passwort, Passkey anlegen und damit einloggen (Conditional UI),
|
||||||
Passkey-Verwaltung unter "Konto", Rate-Limit auf Login
|
Passkey-Verwaltung unter "Konto", Rate-Limit auf Login
|
||||||
- [ ] Mobile-first Layout-Shell mit Bottom-Navigation
|
- [x] Mobile-first Layout-Shell mit Bottom-Navigation
|
||||||
- [ ] Stammdaten: Hersteller, Themen (mit Unterthemen), Standorte
|
- [x] Stammdaten: Hersteller, Themen (mit Unterthemen), Standorte
|
||||||
- [ ] Set-Katalog: Kachelliste, Suche, Filter (Hersteller, Thema, Jahr,
|
- [x] Set-Katalog: Kachelliste, Suche, Filter (Hersteller, Thema, Jahr,
|
||||||
Besitzstatus), Anlegen und Bearbeiten
|
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)
|
serverseitige Dateinamen, Thumbnails)
|
||||||
- [ ] Set-Detailseite: Metadaten, Bild, Anleitungen mit pdf.js-Viewer,
|
- [x] Set-Detailseite: Metadaten, Bild, Anleitungen mit pdf.js-Viewer,
|
||||||
Fotos, Sammlungseintraege
|
Fotos, Sammlungseintraege
|
||||||
- [ ] Sammlung: Uebersicht, Filter, Statistik-Kacheln
|
- [x] Sammlung: Uebersicht, Filter, Statistik-Kacheln
|
||||||
- [ ] Fehlende Sets (`/katalog?besitz=fehlt`) mit Mehrfachauswahl und
|
- [x] Fehlende Sets (`/katalog?besitz=fehlt`) mit Mehrfachauswahl und
|
||||||
Bulk-Aktion "Zur Wunschliste hinzufuegen"
|
Bulk-Aktion "Zur Wunschliste hinzufuegen"
|
||||||
- [ ] Wunschlisten: Verwaltung, Prioritaet, Sortierung, Teilen,
|
- [x] Wunschlisten: Verwaltung, Prioritaet, Sortierung, Teilen,
|
||||||
Ueberraschungsmodus, automatisches "erfuellt"
|
Ueberraschungsmodus, automatisches "erfuellt"
|
||||||
- [ ] Oeffentliche Wunschliste `/w/[shareToken]` mit Reservierung ohne
|
- [x] Oeffentliche Wunschliste `/w/[shareToken]` mit Reservierung ohne
|
||||||
Anmeldung und Storno-Link
|
Anmeldung und Storno-Link
|
||||||
- [ ] Seed-Skript (3 Hersteller, 10 Sets, 4 in Sammlung, 1 Wunschliste)
|
- [x] Seed-Skript (3 Hersteller, 10 Sets, 4 in Sammlung, 1 Wunschliste)
|
||||||
- [ ] Unit-Tests (Besitzstatus, Bulk-Add inkl. Dubletten, Reservierung,
|
- [x] Unit-Tests (Besitzstatus, Bulk-Add inkl. Dubletten, Reservierung,
|
||||||
Token-Erzeugung) und Playwright-Smoke-Test
|
Token-Erzeugung) und Playwright-Smoke-Test
|
||||||
- [ ] Docker: Multi-Stage-Build, Compose-Stack, Healthcheck, Entrypoint
|
- [x] Docker: Multi-Stage-Build, Compose-Stack, Healthcheck, Entrypoint
|
||||||
- [ ] Dokumentation: README, QUICKSTART, bebilderte Anleitung,
|
- [x] Dokumentation: README, QUICKSTART, bebilderte Anleitung,
|
||||||
update.sh, Nginx-Proxy-Manager-Konfiguration
|
update.sh, Nginx-Proxy-Manager-Konfiguration
|
||||||
|
|
||||||
## Phase 2 - Komfort (Ziel: v0.2.0)
|
## Phase 2 - Komfort (Ziel: v0.2.0)
|
||||||
|
|||||||
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 285 KiB |
|
After Width: | Height: | Size: 202 KiB |
|
After Width: | Height: | Size: 212 KiB |
|
After Width: | Height: | Size: 252 KiB |
|
After Width: | Height: | Size: 183 KiB |
|
After Width: | Height: | Size: 184 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 292 KiB |
|
After Width: | Height: | Size: 155 KiB |
|
After Width: | Height: | Size: 244 KiB |
|
After Width: | Height: | Size: 187 KiB |
|
After Width: | Height: | Size: 294 KiB |
|
After Width: | Height: | Size: 112 KiB |
@@ -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' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -22,9 +22,14 @@ async function shoot(page: Page, name: string) {
|
|||||||
await page.screenshot({ path: shotPath(name), fullPage: true });
|
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 }) => {
|
test('Screenshots fuer die Anleitung', async ({ page, browser }) => {
|
||||||
// --- 1. registration ------------------------------------------------------
|
// --- 1. registration ------------------------------------------------------
|
||||||
await page.goto('/registrieren');
|
await visit(page, '/registrieren');
|
||||||
await expect(page.getByText('Erstes Konto anlegen')).toBeVisible();
|
await expect(page.getByText('Erstes Konto anlegen')).toBeVisible();
|
||||||
await shoot(page, '01-registrierung');
|
await shoot(page, '01-registrierung');
|
||||||
|
|
||||||
@@ -41,25 +46,26 @@ test('Screenshots fuer die Anleitung', async ({ page, browser }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// --- 2. dashboard ---------------------------------------------------------
|
// --- 2. dashboard ---------------------------------------------------------
|
||||||
await page.goto('/');
|
await visit(page, '/');
|
||||||
await shoot(page, '02-start');
|
await shoot(page, '02-start');
|
||||||
|
|
||||||
// --- 3. master data -------------------------------------------------------
|
// --- 3. master data -------------------------------------------------------
|
||||||
await page.goto('/verwaltung/hersteller');
|
await visit(page, '/verwaltung/hersteller');
|
||||||
await expect(page.getByText('Lego')).toBeVisible();
|
await expect(page.getByText('Lego', { exact: true })).toBeVisible();
|
||||||
await shoot(page, '03-verwaltung-hersteller');
|
await shoot(page, '03-verwaltung-hersteller');
|
||||||
|
|
||||||
// --- 4. catalog -----------------------------------------------------------
|
// --- 4. catalog -----------------------------------------------------------
|
||||||
await page.goto('/katalog');
|
await visit(page, '/katalog');
|
||||||
await expect(page.getByText('Familienhaus und Elektroauto')).toBeVisible();
|
await expect(page.getByText('Familienhaus und Elektroauto').first()).toBeVisible();
|
||||||
await shoot(page, '04-katalog');
|
await shoot(page, '04-katalog');
|
||||||
|
|
||||||
// --- 5. missing sets, selection mode --------------------------------------
|
// --- 5. missing sets, selection mode --------------------------------------
|
||||||
await page.goto('/katalog?besitz=fehlt');
|
await visit(page, '/katalog?besitz=fehlt');
|
||||||
await expect(page.getByText('Fehlende Sets')).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Fehlende Sets' })).toBeVisible();
|
||||||
await shoot(page, '05-fehlende-sets');
|
await shoot(page, '05-fehlende-sets');
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Auswählen' }).click();
|
await page.getByRole('button', { name: 'Auswählen' }).click();
|
||||||
|
await expect(page.getByText('Tippe die Sets an')).toBeVisible();
|
||||||
const cards = page.locator('button[aria-pressed]');
|
const cards = page.locator('button[aria-pressed]');
|
||||||
await cards.nth(0).click();
|
await cards.nth(0).click();
|
||||||
await cards.nth(1).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();
|
await page.getByRole('button', { name: 'Abbrechen' }).click();
|
||||||
|
|
||||||
// --- 6. set detail --------------------------------------------------------
|
// --- 6. set detail --------------------------------------------------------
|
||||||
await page.goto('/katalog?q=60398');
|
await visit(page, '/katalog?q=60398');
|
||||||
await page.getByText('Familienhaus und Elektroauto').first().click();
|
await page.getByText('Familienhaus und Elektroauto').first().click();
|
||||||
await expect(page.getByRole('heading', { name: 'Familienhaus und Elektroauto' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Familienhaus und Elektroauto' })).toBeVisible();
|
||||||
await shoot(page, '08-set-detail');
|
await shoot(page, '08-set-detail');
|
||||||
|
|
||||||
// --- 7. collection --------------------------------------------------------
|
// --- 7. collection --------------------------------------------------------
|
||||||
await page.goto('/sammlung');
|
await visit(page, '/sammlung');
|
||||||
await expect(page.getByText('Wert nach UVP')).toBeVisible();
|
await expect(page.getByText('Wert nach UVP').first()).toBeVisible();
|
||||||
await shoot(page, '09-sammlung');
|
await shoot(page, '09-sammlung');
|
||||||
|
|
||||||
// --- 8. wishlist ----------------------------------------------------------
|
// --- 8. wishlist ----------------------------------------------------------
|
||||||
await page.goto('/wunschlisten');
|
await visit(page, '/wunschlisten');
|
||||||
await expect(page.getByText('Weihnachten 2026')).toBeVisible();
|
await expect(page.getByText('Weihnachten 2026').first()).toBeVisible();
|
||||||
await shoot(page, '10-wunschlisten');
|
await shoot(page, '10-wunschlisten');
|
||||||
|
|
||||||
const listHref = await page
|
const listHref = await page
|
||||||
.getByRole('link', { name: /Weihnachten 2026/ })
|
.getByRole('link', { name: /Weihnachten 2026/ })
|
||||||
.getAttribute('href');
|
.getAttribute('href');
|
||||||
await page.goto(listHref!);
|
await visit(page, listHref!);
|
||||||
await expect(page.getByRole('heading', { name: 'Weihnachten 2026' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Weihnachten 2026' })).toBeVisible();
|
||||||
await shoot(page, '11-wunschliste-bearbeiten');
|
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 visitorContext = await browser.newContext({ locale: 'de-DE' });
|
||||||
const visitor = await visitorContext.newPage();
|
const visitor = await visitorContext.newPage();
|
||||||
await visitor.setViewportSize({ width: 412, height: 915 });
|
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 expect(visitor.getByRole('heading', { name: 'Weihnachten 2026' })).toBeVisible();
|
||||||
await shoot(visitor, '12-oeffentliche-wunschliste');
|
await shoot(visitor, '12-oeffentliche-wunschliste');
|
||||||
|
|
||||||
@@ -118,7 +124,7 @@ test('Screenshots fuer die Anleitung', async ({ page, browser }) => {
|
|||||||
await shoot(page, '15-wunschliste-reserviert');
|
await shoot(page, '15-wunschliste-reserviert');
|
||||||
|
|
||||||
// --- 11. account and passkeys --------------------------------------------
|
// --- 11. account and passkeys --------------------------------------------
|
||||||
await page.goto('/konto');
|
await visit(page, '/konto');
|
||||||
await expect(page.getByText('Passkeys')).toBeVisible();
|
await expect(page.getByText('Passkeys', { exact: true })).toBeVisible();
|
||||||
await shoot(page, '16-konto-passkeys');
|
await shoot(page, '16-konto-passkeys');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:e2e": "node scripts/reset-e2e.mjs && playwright test",
|
"test:e2e": "node scripts/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": {
|
"dependencies": {
|
||||||
"@better-auth/passkey": "^1.7.3",
|
"@better-auth/passkey": "^1.7.3",
|
||||||
|
|||||||
@@ -1,23 +1,31 @@
|
|||||||
|
import path from 'node:path';
|
||||||
import { defineConfig, devices } from '@playwright/test';
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Produces the screenshots for docs/ANLEITUNG.md.
|
* Produces the screenshots for docs/ANLEITUNG.md.
|
||||||
*
|
*
|
||||||
* Kept apart from the smoke test on purpose: this run needs seeded example
|
* Runs against the production container, not the dev server: the dev server
|
||||||
* data and writes files into the repository, which a test never should.
|
* 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
|
* pnpm screenshots
|
||||||
*/
|
*/
|
||||||
const PORT = 3220;
|
const PORT = 3220;
|
||||||
const BASE_URL = `http://localhost:${PORT}`;
|
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({
|
export default defineConfig({
|
||||||
testDir: './e2e',
|
testDir: './e2e',
|
||||||
testMatch: 'screenshots.spec.ts',
|
testMatch: 'screenshots.spec.ts',
|
||||||
globalSetup: './e2e/screenshots-setup.ts',
|
|
||||||
fullyParallel: false,
|
fullyParallel: false,
|
||||||
workers: 1,
|
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',
|
reporter: 'list',
|
||||||
use: {
|
use: {
|
||||||
baseURL: BASE_URL,
|
baseURL: BASE_URL,
|
||||||
@@ -27,18 +35,19 @@ export default defineConfig({
|
|||||||
...devices['Pixel 7'],
|
...devices['Pixel 7'],
|
||||||
},
|
},
|
||||||
webServer: {
|
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`,
|
url: `${BASE_URL}/api/health`,
|
||||||
reuseExistingServer: false,
|
reuseExistingServer: false,
|
||||||
timeout: 180_000,
|
timeout: 180_000,
|
||||||
stdout: 'pipe',
|
stdout: 'pipe',
|
||||||
stderr: 'pipe',
|
stderr: 'pipe',
|
||||||
env: {
|
|
||||||
NODE_ENV: 'development',
|
|
||||||
DATA_DIR: './data-shots',
|
|
||||||
BASE_URL,
|
|
||||||
BETTER_AUTH_SECRET: 'screenshot-run-secret-0123456789abcdefgh',
|
|
||||||
MAX_UPLOAD_MB: '25',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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.`);
|
|
||||||
@@ -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.');
|
||||||
@@ -159,7 +159,13 @@ export function CatalogGrid({ sets, wishlists, defaultWishlistId, emptyState }:
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
<div
|
||||||
|
className={cn(
|
||||||
|
'grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4',
|
||||||
|
// Room for the fixed action bar, so the last row stays reachable.
|
||||||
|
selecting && 'pb-20',
|
||||||
|
)}
|
||||||
|
>
|
||||||
{sets.map((item) => {
|
{sets.map((item) => {
|
||||||
const owned = item.ownedCopies > 0;
|
const owned = item.ownedCopies > 0;
|
||||||
const isSelected = selected.has(item.id);
|
const isSelected = selected.has(item.id);
|
||||||
|
|||||||