Merge: Phase 1 - MVP (v0.1.0)

Katalog, Sammlung und Wunschlisten inklusive oeffentlicher Ansicht,
Anmeldung mit Passwort und Passkeys, Upload-Pipeline und der komplette
Docker-Stack. Im Container verifiziert: Migrationen laufen beim Start,
Healthcheck greift, Registrierung des ersten Nutzers macht ihn zum
Administrator.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
unknown
2026-09-07 06:47:46 +02:00
co-authored by Claude Opus 5
136 changed files with 11448 additions and 1049 deletions
+20
View File
@@ -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
+6
View File
@@ -21,6 +21,12 @@ MAX_UPLOAD_MB=25
# Zeitzone des Containers.
TZ=Europe/Berlin
# 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 (ab Phase 2): Rebrickable-API-Key fuer den Lego-Import.
# Ohne Key funktioniert alles manuell weiter.
# REBRICKABLE_API_KEY=
+17
View File
@@ -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
+4
View File
@@ -19,6 +19,10 @@ next-env.d.ts
# runtime data (database, uploads) - never in git, this is the backup target
/data
/data-e2e
/data-shots
/data-docker
/backups
# production
*.tsbuildinfo
+80 -8
View File
@@ -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
+3 -3
View File
@@ -33,9 +33,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`),
+86
View File
@@ -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"]
+211
View File
@@ -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 **Konto → Passkeys → Passkey hinzufügen**. Ab dann meldest du dich per
Fingerabdruck, Gesichtserkennung oder Geräte-PIN an. Für jedes Gerät ein
eigener Passkey – Handy, Tablet, Laptop.
## 8. Loslegen
1. **Verwaltung → Hersteller**: Lego, Playmobil, Cobi … anlegen.
2. **Verwaltung → Themen**: „City", „Technic", „Novelmore" …
3. **Verwaltung → Standorte**: „Regal Kinderzimmer", „Kiste Keller 3" …
4. **Katalog → Set anlegen**: Setnummer, Name, Bild.
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 |
+214 -21
View File
@@ -1,36 +1,229 @@
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 |
| **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 | – | Ab Version 0.2.0: Lego-Import. Ohne Key läuft alles manuell. |
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 ein eigenes `./data-e2e` |
| `pnpm db:generate` | SQL-Migration aus dem Drizzle-Schema erzeugen |
| `pnpm db:migrate` | Migrationen anwenden |
| `pnpm db:studio` | Drizzle Studio |
> **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`.
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.
+47
View File
@@ -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"
+30
View File
@@ -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 "$@"
+322
View File
@@ -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.
+82 -16
View File
@@ -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.
+16 -16
View File
@@ -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)
Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 285 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 294 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 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
+76 -76
View File
@@ -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,
+2 -2
View File
@@ -5,8 +5,8 @@
{
"idx": 0,
"version": "6",
"when": 1788726280857,
"tag": "0000_clean_sumo",
"when": 1788726503038,
"tag": "0000_rapid_gargoyle",
"breakpoints": true
}
]
+13
View File
@@ -0,0 +1,13 @@
import { execFileSync } from 'node:child_process';
/**
* Migrates the smoke-test database. The directory itself is wiped earlier by
* scripts/reset-e2e.mjs, because Playwright starts its web server before this
* hook runs and the open database file would block the delete on Windows.
*/
export default function globalSetup() {
execFileSync('node', ['node_modules/tsx/dist/cli.mjs', 'scripts/migrate.ts'], {
stdio: 'inherit',
env: { ...process.env, DATA_DIR: './data-e2e' },
});
}
+130
View File
@@ -0,0 +1,130 @@
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');
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');
// --- 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();
const cards = page.locator('button[aria-pressed]');
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();
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');
});
+92
View File
@@ -0,0 +1,92 @@
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('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);
});
+73 -70
View File
@@ -1,70 +1,73 @@
{
"name": "bricktrack",
"version": "0.1.0",
"private": true,
"description": "Self-hosted Verwaltung fuer Spielzeug-Set-Sammlungen (Lego, Playmobil, Cobi, ...)",
"packageManager": "pnpm@10.34.5",
"engines": {
"node": ">=22"
},
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"typecheck": "tsc --noEmit",
"db:generate": "drizzle-kit generate",
"db:migrate": "tsx scripts/migrate.ts",
"db:seed": "tsx scripts/seed.ts",
"db:studio": "drizzle-kit studio",
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test",
"screenshots": "playwright test --config=playwright.screenshots.config.ts"
},
"dependencies": {
"@better-auth/passkey": "^1.7.3",
"better-auth": "^1.7.3",
"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",
"lucide-react": "^1.41.0",
"next": "15.5.25",
"next-themes": "^0.4.6",
"radix-ui": "^1.6.7",
"react": "19.1.0",
"react-dom": "19.1.0",
"sharp": "^0.35.4",
"sonner": "^2.0.8",
"tw-animate-css": "^1.4.0",
"zod": "^4.5.4"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@playwright/test": "^1.63.0",
"@tailwindcss/postcss": "^4",
"@types/better-sqlite3": "^9.6.0",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"dotenv": "^17.4.2",
"drizzle-kit": "^0.31.10",
"eslint": "^9",
"eslint-config-next": "15.5.25",
"shadcn": "^4.21.0",
"tailwindcss": "^4",
"tsx": "^4.23.13",
"typescript": "^5",
"vitest": "^5.0.0"
},
"pnpm": {
"onlyBuiltDependencies": [
"better-sqlite3",
"esbuild",
"sharp",
"unrs-resolver"
]
}
}
{
"name": "bricktrack",
"version": "0.1.0",
"private": true,
"description": "Self-hosted Verwaltung fuer Spielzeug-Set-Sammlungen (Lego, Playmobil, Cobi, ...)",
"packageManager": "pnpm@10.34.5",
"engines": {
"node": ">=22"
},
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"db:generate": "drizzle-kit generate",
"db:migrate": "tsx scripts/migrate.ts",
"db:seed": "tsx scripts/seed.ts",
"db:studio": "drizzle-kit studio",
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "node scripts/reset-e2e.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-sqlite3": "^13.0.3",
"class-variance-authority": "^0.7.1",
"cn": "^0.2.6",
"date-fns": "^4.4.0",
"drizzle-orm": "^0.45.2",
"lucide-react": "^1.41.0",
"next": "15.5.25",
"next-themes": "^0.4.6",
"pdfjs-dist": "^6.3.289",
"radix-ui": "^1.6.7",
"react": "19.1.0",
"react-dom": "19.1.0",
"server-only": "^0.0.1",
"sharp": "^0.35.4",
"sonner": "^2.0.8",
"tw-animate-css": "^1.4.0",
"zod": "^4.5.4"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@playwright/test": "^1.63.0",
"@tailwindcss/postcss": "^4",
"@types/better-sqlite3": "^9.6.0",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"dotenv": "^17.4.2",
"drizzle-kit": "^0.31.10",
"esbuild": "^0.25.12",
"eslint": "^9",
"eslint-config-next": "15.5.25",
"shadcn": "^4.21.0",
"tailwindcss": "^4",
"tsx": "^4.23.13",
"typescript": "^5",
"vitest": "^5.0.0"
},
"pnpm": {
"onlyBuiltDependencies": [
"better-sqlite3",
"esbuild",
"sharp",
"unrs-resolver"
]
}
}
+55
View File
@@ -0,0 +1,55 @@
import { defineConfig, devices } from '@playwright/test';
/**
* Smoke test setup.
*
* Runs against its own data directory (./data-e2e) so a test never touches the
* real database. The global setup wipes and migrates it before each run.
*/
const PORT = 3210;
const BASE_URL = `http://localhost:${PORT}`;
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',
globalSetup: './e2e/global-setup.ts',
fullyParallel: false,
workers: 1,
retries: 0,
// Generous: the dev server compiles each route on first visit.
timeout: 240_000,
// A first navigation to a route the dev server has not compiled yet can
// take well over ten seconds on a cold cache.
expect: { timeout: 30_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: `pnpm exec next dev -p ${PORT}`,
url: `${BASE_URL}/api/health`,
reuseExistingServer: false,
timeout: 180_000,
stdout: 'pipe',
stderr: 'pipe',
env: {
NODE_ENV: 'development',
DATA_DIR: './data-e2e',
BASE_URL,
BETTER_AUTH_SECRET: 'playwright-smoke-test-secret-0123456789abcdef',
MAX_UPLOAD_MB: '25',
},
},
});
+53
View File
@@ -0,0 +1,53 @@
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',
`-v "${dataDir}:/data"`,
'bricktrack:shots',
].join(' '),
url: `${BASE_URL}/api/health`,
reuseExistingServer: false,
timeout: 180_000,
stdout: 'pipe',
stderr: 'pipe',
},
});
+159 -14
View File
@@ -10,10 +10,10 @@ importers:
dependencies:
'@better-auth/passkey':
specifier: ^1.7.3
version: 1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.7.3(better-sqlite3@13.0.3)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3)(kysely@0.29.5))(next@15.5.25(@babel/core@7.29.7)(@playwright/test@1.63.0)(@types/node@20.19.43)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vitest@5.0.0(@types/node@20.19.43)(vite@8.2.2(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13))))(better-call@1.4.0(zod@4.5.4))(nanostores@1.5.3)
version: 1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.7.3(better-sqlite3@13.0.3)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3)(kysely@0.29.5))(next@15.5.25(@babel/core@7.29.7)(@playwright/test@1.63.0)(@types/node@20.19.43)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vitest@5.0.0(@types/node@20.19.43)(vite@8.2.2(@types/node@20.19.43)(esbuild@0.25.12)(jiti@2.7.0)(tsx@4.23.13))))(better-call@1.4.0(zod@4.5.4))(nanostores@1.5.3)
better-auth:
specifier: ^1.7.3
version: 1.7.3(better-sqlite3@13.0.3)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3)(kysely@0.29.5))(next@15.5.25(@babel/core@7.29.7)(@playwright/test@1.63.0)(@types/node@20.19.43)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vitest@5.0.0(@types/node@20.19.43)(vite@8.2.2(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)))
version: 1.7.3(better-sqlite3@13.0.3)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3)(kysely@0.29.5))(next@15.5.25(@babel/core@7.29.7)(@playwright/test@1.63.0)(@types/node@20.19.43)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vitest@5.0.0(@types/node@20.19.43)(vite@8.2.2(@types/node@20.19.43)(esbuild@0.25.12)(jiti@2.7.0)(tsx@4.23.13)))
better-sqlite3:
specifier: ^13.0.3
version: 13.0.3
@@ -38,6 +38,9 @@ importers:
next-themes:
specifier: ^0.4.6
version: 0.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
pdfjs-dist:
specifier: ^6.3.289
version: 6.3.289
radix-ui:
specifier: ^1.6.7
version: 1.6.7(@types/react-dom@19.2.7(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@@ -47,6 +50,9 @@ importers:
react-dom:
specifier: 19.1.0
version: 19.1.0(react@19.1.0)
server-only:
specifier: ^0.0.1
version: 0.0.1
sharp:
specifier: ^0.35.4
version: 0.35.4(@types/node@20.19.43)
@@ -87,6 +93,9 @@ importers:
drizzle-kit:
specifier: ^0.31.10
version: 0.31.10
esbuild:
specifier: ^0.25.12
version: 0.25.12
eslint:
specifier: ^9
version: 9.39.5(jiti@2.7.0)
@@ -107,7 +116,7 @@ importers:
version: 5.9.3
vitest:
specifier: ^5.0.0
version: 5.0.0(@types/node@20.19.43)(vite@8.2.2(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13))
version: 5.0.0(@types/node@20.19.43)(vite@8.2.2(@types/node@20.19.43)(esbuild@0.25.12)(jiti@2.7.0)(tsx@4.23.13))
packages:
@@ -1083,6 +1092,81 @@ packages:
'@cfworker/json-schema':
optional: true
'@napi-rs/canvas-android-arm64@1.0.8':
resolution: {integrity: sha512-5+nkh8i3gt6lqS/d2jTZ1xAn6tdgtB4Lf1mW6T0Qm5/rXNwBuV1sAEyLEWan5o9gJPU/GuvHR3rvSeZ+FaGrbw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [android]
'@napi-rs/canvas-darwin-arm64@1.0.8':
resolution: {integrity: sha512-7jQ47gi+fZ7KJmfc/5rNyy1CYw/cu4kZ0KPIYbo9UUgSdW0bKQJpt+WihEor6s4Lyp7+xc3a+3HeyXmAEbbnPg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@napi-rs/canvas-darwin-x64@1.0.8':
resolution: {integrity: sha512-rRjDMZs9pIRKGxgijwezplKc1RnJsqUokrA9h88bbTkqQ+7ePj0ZN4ZnZDy8Vu0tXs7KRlI2tQLaK4mx9QlxHg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@napi-rs/canvas-linux-arm-gnueabihf@1.0.8':
resolution: {integrity: sha512-jGcCd+8ra6Q61xKqZeiItujTpp9a9eRLcQ0jW6qYNku+WpupqOPFPY0SrsuSnXFviJwkpKYT9p7QrB4lsf3LNQ==}
engines: {node: '>= 10'}
cpu: [arm]
os: [linux]
'@napi-rs/canvas-linux-arm64-gnu@1.0.8':
resolution: {integrity: sha512-od6I2Y7kU7i1SwZYG2EKW8rWz6JiedtPpko4WEe1DDsiikrfaotVBCRaUTM5/yeZKaZ92EatoAS+5xG+6uJlYA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@napi-rs/canvas-linux-arm64-musl@1.0.8':
resolution: {integrity: sha512-yYkPbJDJiWj6N0gASA3CAvRypZmVpJnxU0DQg3aBhneLDQde9TPLKADsQkobNoJUtTT/lj46aWpzT48PDb3Qcg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@napi-rs/canvas-linux-riscv64-gnu@1.0.8':
resolution: {integrity: sha512-PB00MSKAp4VwK/xwe6duKxRKmH8UH4GIl1pqHSbxng0jnU9Dr7FwaDypDiqwNFZ774N+8G7mJLGuLtg9NTcQsg==}
engines: {node: '>= 10'}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@napi-rs/canvas-linux-x64-gnu@1.0.8':
resolution: {integrity: sha512-TWM2XWJoitLiIPCvgJh7SriC+L/T9qkYCVzC66AidsZy0QP1hkKzBzVwshCdcA3q6fIn3yE0ISbq4lMJSy8jFw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@napi-rs/canvas-linux-x64-musl@1.0.8':
resolution: {integrity: sha512-hb20MxKXXb5IB7AAwN8UHz9WRsa2HmdZfjsDCzjElwJoeV1aotVEwFU4FrFQcYQVzsJQLeaCc/2Qdt/0Q72mMg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@napi-rs/canvas-win32-arm64-msvc@1.0.8':
resolution: {integrity: sha512-WwPN08IXE4SkL+FhJyPz/iFnycMAUkbphFIT4cmKLlvbSU0Zfn1R7BGJ3Hqky1S89QUYc0Q4IOScXb/42Re9wQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@napi-rs/canvas-win32-x64-msvc@1.0.8':
resolution: {integrity: sha512-XkrVqKb+pxyba7kjy2LJvABFVBTE0DNpEl7MrG4OYUmaWarrXH+t54z/Czj2YxCKtizYTV4mg6phNm3x24qjhQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
'@napi-rs/canvas@1.0.8':
resolution: {integrity: sha512-/SaLcvlqGWdm0HSCWMgHu7cjJiQXfP8/mOY+6dUyV9flQz7sPBBZ+ed2zYtoukojPmxOaL7bm+d/G4GeWWoN7g==}
engines: {node: '>= 10'}
'@napi-rs/wasm-runtime@1.2.3':
resolution: {integrity: sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==}
engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0}
@@ -4171,6 +4255,10 @@ packages:
path-to-regexp@8.4.2:
resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==}
pdfjs-dist@6.3.289:
resolution: {integrity: sha512-ZHjSVpDa3D6izMq8/04lvkhkATUmL9px6ChPaXc1k6nU2Mrhlg1/7F0bdUqCwUjw3NsPTfPZsMDUU6ZIcRaeQw==}
engines: {node: '>=22.13.0 || >=24'}
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -4417,6 +4505,9 @@ packages:
resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==}
engines: {node: '>= 18'}
server-only@0.0.1:
resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==}
set-cookie-parser@3.1.2:
resolution: {integrity: sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==}
@@ -5155,14 +5246,14 @@ snapshots:
'@better-auth/core': 1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3)
'@better-auth/utils': 0.4.2
'@better-auth/passkey@1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.7.3(better-sqlite3@13.0.3)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3)(kysely@0.29.5))(next@15.5.25(@babel/core@7.29.7)(@playwright/test@1.63.0)(@types/node@20.19.43)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vitest@5.0.0(@types/node@20.19.43)(vite@8.2.2(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13))))(better-call@1.4.0(zod@4.5.4))(nanostores@1.5.3)':
'@better-auth/passkey@1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.7.3(better-sqlite3@13.0.3)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3)(kysely@0.29.5))(next@15.5.25(@babel/core@7.29.7)(@playwright/test@1.63.0)(@types/node@20.19.43)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vitest@5.0.0(@types/node@20.19.43)(vite@8.2.2(@types/node@20.19.43)(esbuild@0.25.12)(jiti@2.7.0)(tsx@4.23.13))))(better-call@1.4.0(zod@4.5.4))(nanostores@1.5.3)':
dependencies:
'@better-auth/core': 1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3)
'@better-auth/utils': 0.4.2
'@better-fetch/fetch': 1.3.1
'@simplewebauthn/browser': 13.3.0
'@simplewebauthn/server': 13.3.3
better-auth: 1.7.3(better-sqlite3@13.0.3)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3)(kysely@0.29.5))(next@15.5.25(@babel/core@7.29.7)(@playwright/test@1.63.0)(@types/node@20.19.43)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vitest@5.0.0(@types/node@20.19.43)(vite@8.2.2(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)))
better-auth: 1.7.3(better-sqlite3@13.0.3)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3)(kysely@0.29.5))(next@15.5.25(@babel/core@7.29.7)(@playwright/test@1.63.0)(@types/node@20.19.43)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vitest@5.0.0(@types/node@20.19.43)(vite@8.2.2(@types/node@20.19.43)(esbuild@0.25.12)(jiti@2.7.0)(tsx@4.23.13)))
better-call: 1.4.0(zod@4.5.4)
nanostores: 1.5.3
zod: 4.5.4
@@ -5698,6 +5789,54 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@napi-rs/canvas-android-arm64@1.0.8':
optional: true
'@napi-rs/canvas-darwin-arm64@1.0.8':
optional: true
'@napi-rs/canvas-darwin-x64@1.0.8':
optional: true
'@napi-rs/canvas-linux-arm-gnueabihf@1.0.8':
optional: true
'@napi-rs/canvas-linux-arm64-gnu@1.0.8':
optional: true
'@napi-rs/canvas-linux-arm64-musl@1.0.8':
optional: true
'@napi-rs/canvas-linux-riscv64-gnu@1.0.8':
optional: true
'@napi-rs/canvas-linux-x64-gnu@1.0.8':
optional: true
'@napi-rs/canvas-linux-x64-musl@1.0.8':
optional: true
'@napi-rs/canvas-win32-arm64-msvc@1.0.8':
optional: true
'@napi-rs/canvas-win32-x64-msvc@1.0.8':
optional: true
'@napi-rs/canvas@1.0.8':
optionalDependencies:
'@napi-rs/canvas-android-arm64': 1.0.8
'@napi-rs/canvas-darwin-arm64': 1.0.8
'@napi-rs/canvas-darwin-x64': 1.0.8
'@napi-rs/canvas-linux-arm-gnueabihf': 1.0.8
'@napi-rs/canvas-linux-arm64-gnu': 1.0.8
'@napi-rs/canvas-linux-arm64-musl': 1.0.8
'@napi-rs/canvas-linux-riscv64-gnu': 1.0.8
'@napi-rs/canvas-linux-x64-gnu': 1.0.8
'@napi-rs/canvas-linux-x64-musl': 1.0.8
'@napi-rs/canvas-win32-arm64-msvc': 1.0.8
'@napi-rs/canvas-win32-x64-msvc': 1.0.8
optional: true
'@napi-rs/wasm-runtime@1.2.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
dependencies:
'@emnapi/core': 1.10.0
@@ -6954,14 +7093,14 @@ snapshots:
'@unrs/resolver-binding-win32-x64-msvc@1.12.2':
optional: true
'@vitest/mocker@5.0.0(vite@8.2.2(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13))':
'@vitest/mocker@5.0.0(vite@8.2.2(@types/node@20.19.43)(esbuild@0.25.12)(jiti@2.7.0)(tsx@4.23.13))':
dependencies:
'@jridgewell/trace-mapping': 0.3.31
'@vitest/spy': 5.0.0
estree-walker: 3.0.3
magic-string: 1.2.3
optionalDependencies:
vite: 8.2.2(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)
vite: 8.2.2(@types/node@20.19.43)(esbuild@0.25.12)(jiti@2.7.0)(tsx@4.23.13)
'@vitest/spy@5.0.0': {}
@@ -7115,7 +7254,7 @@ snapshots:
baseline-browser-mapping@2.11.21: {}
better-auth@1.7.3(better-sqlite3@13.0.3)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3)(kysely@0.29.5))(next@15.5.25(@babel/core@7.29.7)(@playwright/test@1.63.0)(@types/node@20.19.43)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vitest@5.0.0(@types/node@20.19.43)(vite@8.2.2(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13))):
better-auth@1.7.3(better-sqlite3@13.0.3)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3)(kysely@0.29.5))(next@15.5.25(@babel/core@7.29.7)(@playwright/test@1.63.0)(@types/node@20.19.43)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vitest@5.0.0(@types/node@20.19.43)(vite@8.2.2(@types/node@20.19.43)(esbuild@0.25.12)(jiti@2.7.0)(tsx@4.23.13))):
dependencies:
'@better-auth/core': 1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3)
'@better-auth/drizzle-adapter': 1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.2(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3)(kysely@0.29.5))
@@ -7141,7 +7280,7 @@ snapshots:
next: 15.5.25(@babel/core@7.29.7)(@playwright/test@1.63.0)(@types/node@20.19.43)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
vitest: 5.0.0(@types/node@20.19.43)(vite@8.2.2(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13))
vitest: 5.0.0(@types/node@20.19.43)(vite@8.2.2(@types/node@20.19.43)(esbuild@0.25.12)(jiti@2.7.0)(tsx@4.23.13))
transitivePeerDependencies:
- '@cloudflare/workers-types'
- '@opentelemetry/api'
@@ -8782,6 +8921,10 @@ snapshots:
path-to-regexp@8.4.2: {}
pdfjs-dist@6.3.289:
optionalDependencies:
'@napi-rs/canvas': 1.0.8
picocolors@1.1.1: {}
picomatch@2.3.2: {}
@@ -9111,6 +9254,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
server-only@0.0.1: {}
set-cookie-parser@3.1.2: {}
set-function-length@1.2.2:
@@ -9564,7 +9709,7 @@ snapshots:
vary@1.1.2: {}
vite@8.2.2(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13):
vite@8.2.2(@types/node@20.19.43)(esbuild@0.25.12)(jiti@2.7.0)(tsx@4.23.13):
dependencies:
lightningcss: 1.33.0
picomatch: 4.0.7
@@ -9573,15 +9718,15 @@ snapshots:
tinyglobby: 0.2.17
optionalDependencies:
'@types/node': 20.19.43
esbuild: 0.28.2
esbuild: 0.25.12
fsevents: 2.3.3
jiti: 2.7.0
tsx: 4.23.13
vitest@5.0.0(@types/node@20.19.43)(vite@8.2.2(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)):
vitest@5.0.0(@types/node@20.19.43)(vite@8.2.2(@types/node@20.19.43)(esbuild@0.25.12)(jiti@2.7.0)(tsx@4.23.13)):
dependencies:
'@types/chai': 5.2.3
'@vitest/mocker': 5.0.0(vite@8.2.2(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13))
'@vitest/mocker': 5.0.0(vite@8.2.2(@types/node@20.19.43)(esbuild@0.25.12)(jiti@2.7.0)(tsx@4.23.13))
chai: 6.2.2
es-module-lexer: 2.3.2
expect-type: 1.4.0
@@ -9592,7 +9737,7 @@ snapshots:
tinybench: 6.1.4
tinyexec: 1.3.0
tinyglobby: 0.2.17
vite: 8.2.2(@types/node@20.19.43)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)
vite: 8.2.2(@types/node@20.19.43)(esbuild@0.25.12)(jiti@2.7.0)(tsx@4.23.13)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 20.19.43
+13
View File
@@ -0,0 +1,13 @@
/**
* Wipes the Playwright data directory before the smoke run.
*
* Runs before Playwright starts, because Playwright boots its web server
* before global setup - on Windows the open SQLite file would then block the
* delete.
*/
import fs from 'node:fs';
import path from 'node:path';
const dataDir = path.resolve(process.cwd(), 'data-e2e');
fs.rmSync(dataDir, { recursive: true, force: true });
console.log(`[e2e] ${dataDir} zurueckgesetzt.`);
+21
View File
@@ -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.');
+398
View File
@@ -0,0 +1,398 @@
/**
* 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 - 0 means the set is only in the catalog. */
owned?: {
status: 'owned' | 'built' | 'disassembled' | 'lent_out' | 'sold';
completeness: 'complete' | 'missing_parts' | 'unknown';
location: 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'];
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' },
},
{
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',
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' },
},
{
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' },
},
{
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 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>,
) {
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,
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>) {
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,
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 setIds = seedSets(manufacturers, themes, locations);
seedWishlist(setIds);
console.log(
`[seed] Fertig: ${manufacturers.size} Hersteller, ${themes.size} Themen, ${setIds.size} Sets.`,
);
sqlite.close();
@@ -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>
);
}
+192
View File
@@ -0,0 +1,192 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { Check, ExternalLink, Gift, ImageOff, Pencil } from 'lucide-react';
import { InstructionsSection } from '@/components/katalog/instructions-section';
import { PhotosSection } from '@/components/katalog/photos-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,
} from '@/lib/queries/catalog';
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 }> };
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { id } = await params;
const set = getSetById(id);
return { title: set ? `${set.setNumber} · ${set.name}` : 'Set' };
}
export default async function SetDetailPage({ params }: Props) {
await requireUser();
const { id } = await params;
const set = getSetById(id);
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 collectionItems = listCollectionItemsForSet(set.id).map((item) => ({
...item,
locationName: item.locationId ? (locationNames.get(item.locationId) ?? 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="overflow-hidden rounded-xl border border-border bg-muted">
{image ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={image} alt="" className="aspect-square w-full object-contain p-3" />
) : (
<div className="flex aspect-square items-center justify-center text-muted-foreground">
<ImageOff className="size-10" aria-hidden />
</div>
)}
</div>
<div className="space-y-4">
<SetActions
setId={set.id}
setName={`${set.setNumber} · ${set.name}`}
owned={owned}
wishlists={listWishlistTargets()}
/>
<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} />
</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>
);
}
+291
View File
@@ -0,0 +1,291 @@
'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 { requireUser } from '@/lib/session';
import { deleteStoredFile, storeImage, 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}`);
}
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;
}
}
db.insert(set)
.values({
id,
...parsed.data,
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();
}
+51
View File
@@ -0,0 +1,51 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { SetForm } from '@/components/katalog/set-form';
import { Button } from '@/components/ui/button';
import { listManufacturers, listThemes } from '@/lib/queries/master-data';
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;
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,
}));
if (manufacturers.length === 0) {
return (
<div className="space-y-4">
<h1 className="text-2xl font-semibold tracking-tight">Neues Set</h1>
<p className="rounded-lg border border-dashed border-border p-6 text-sm text-muted-foreground">
Jedes Set gehört zu einem Hersteller. Leg zuerst einen an – Lego, Playmobil, Cobi oder
was auch immer im Kinderzimmer steht.
</p>
<Button asChild size="lg">
<Link href="/verwaltung/hersteller">Hersteller anlegen</Link>
</Button>
</div>
);
}
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>
<SetForm manufacturers={manufacturers} themes={themes} defaultEan={ean} />
</div>
);
}
+83
View File
@@ -0,0 +1,83 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { Plus } from 'lucide-react';
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 { 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 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,
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'
? 'Sets, die noch nicht in der Sammlung sind.'
: 'Alle bekannten Sets – unabhängig davon, ob sie schon da sind.'}
</p>
</div>
<Button asChild size="lg" className="shrink-0">
<Link href="/katalog/neu">
<Plus />
<span className="hidden sm:inline">Set anlegen</span>
</Link>
</Button>
</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} />
<Pagination page={page} total={total} pageSize={CATALOG_PAGE_SIZE} />
</div>
);
}
+45
View File
@@ -0,0 +1,45 @@
'use server';
import { revalidatePath } from 'next/cache';
import { headers } from 'next/headers';
import { z } from 'zod';
import { auth } from '@/lib/auth';
const idSchema = z.object({ id: z.string().min(1) });
const renameSchema = idSchema.extend({ name: z.string().trim().min(1).max(60) });
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 };
}
+62
View File
@@ -0,0 +1,62 @@
import type { Metadata } from 'next';
import { headers } from 'next/headers';
import { PasskeyManager, type PasskeyRow } from '@/components/auth/passkey-manager';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { auth } from '@/lib/auth';
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,
}));
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>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>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { AppHeader } from '@/components/layout/app-header';
import { BottomNav } from '@/components/layout/bottom-nav';
import { requireUser } from '@/lib/session';
export default async function AppLayout({ children }: { children: React.ReactNode }) {
const user = await requireUser();
return (
<div className="flex min-h-dvh flex-col">
<AppHeader user={user} />
{/* Bottom padding keeps the mobile nav from covering the last row. */}
<main className="mx-auto w-full max-w-5xl flex-1 px-4 pt-4 pb-24 md:pb-10">{children}</main>
<BottomNav />
</div>
);
}
+58
View File
@@ -0,0 +1,58 @@
import Link from 'next/link';
import { Boxes, Gift, LayoutGrid, PackageSearch } from 'lucide-react';
import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { requireUser } from '@/lib/session';
const shortcuts = [
{
href: '/katalog',
icon: LayoutGrid,
title: 'Katalog',
description: 'Alle bekannten Sets durchsuchen und neue anlegen.',
},
{
href: '/katalog?besitz=fehlt',
icon: PackageSearch,
title: 'Fehlende Sets',
description: 'Was noch nicht in der Sammlung ist – direkt auf die Wunschliste.',
},
{
href: '/sammlung',
icon: Boxes,
title: 'Sammlung',
description: 'Alles, was tatsächlich da ist – mit Standort und Zustand.',
},
{
href: '/wunschlisten',
icon: Gift,
title: 'Wunschlisten',
description: 'Listen anlegen und per Link mit Familie und Freunden teilen.',
},
] as const;
export default async function DashboardPage() {
const user = await requireUser();
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Hallo {user.name}</h1>
<p className="text-sm text-muted-foreground">Was möchtest du tun?</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
{shortcuts.map((shortcut) => (
<Link key={shortcut.href} href={shortcut.href} className="group">
<Card className="h-full transition-colors group-hover:border-primary/40">
<CardHeader>
<shortcut.icon className="size-6 text-primary" aria-hidden />
<CardTitle>{shortcut.title}</CardTitle>
<CardDescription>{shortcut.description}</CardDescription>
</CardHeader>
</Card>
</Link>
))}
</div>
</div>
);
}
+105
View File
@@ -0,0 +1,105 @@
'use server';
import { revalidatePath } from 'next/cache';
import { eq } from 'drizzle-orm';
import { db } from '@/db';
import { collectionItem, set } from '@/db/schema';
import {
actionData,
actionError,
actionOk,
parseForm,
type ActionResult,
} from '@/lib/action-result';
import { reconcileFulfilledWishlistItems } from '@/lib/queries/wishlists';
import { requireUser } from '@/lib/session';
import { collectionItemSchema } 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): 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.');
const id = crypto.randomUUID();
db.insert(collectionItem)
.values({
id,
setId,
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({
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();
}
+150
View File
@@ -0,0 +1,150 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { Boxes, Euro, ImageOff, Layers, PackageSearch } from 'lucide-react';
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 { setImageSrc } from '@/lib/images';
import { completenessLabel, statusLabel } from '@/lib/labels';
import { formatEuro } from '@/lib/money';
import {
countMissingSets,
getCollectionStats,
listCollectionItems,
type CollectionFilters as Filters,
} from '@/lib/queries/collection';
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 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),
};
const items = listCollectionItems(filters);
const stats = getCollectionStats();
const missing = countMissingSets();
const tiles = [
{ icon: Boxes, label: 'Sets', value: formatNumber(stats.setCount) },
{ icon: Layers, label: 'Teile gesamt', value: formatNumber(stats.pieceCount) },
{ icon: Euro, label: 'Wert nach UVP', value: formatEuro(stats.retailValueCents) },
{ icon: 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">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="divide-y divide-border rounded-lg border border-border">
{items.map((item) => {
const image = setImageSrc(item);
return (
<li key={item.id}>
<Link
href={`/katalog/${item.setId}`}
className="flex items-center gap-3 p-3 transition-colors hover:bg-muted/50"
>
<div className="size-14 shrink-0 overflow-hidden rounded-lg border border-border bg-muted">
{image ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={image} alt="" loading="lazy" className="size-full object-contain" />
) : (
<div className="flex size-full items-center justify-center text-muted-foreground">
<ImageOff className="size-5" aria-hidden />
</div>
)}
</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">
<Badge variant={item.status === 'sold' ? 'outline' : 'default'}>
{statusLabel(item.status)}
</Badge>
{item.completeness !== 'unknown' ? (
<Badge variant="secondary">{completenessLabel(item.completeness)}</Badge>
) : null}
{item.acquiredAt ? (
<span className="text-xs text-muted-foreground">
seit {formatDate(item.acquiredAt)}
</span>
) : null}
</div>
</div>
</Link>
</li>
);
})}
</ul>
)}
</div>
);
}
+236
View File
@@ -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,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} />;
}
+22
View File
@@ -0,0 +1,22 @@
import type { Metadata } from 'next';
import { VerwaltungNav } from '@/components/verwaltung/verwaltung-nav';
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">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Verwaltung</h1>
<p className="text-sm text-muted-foreground">
Stammdaten, die im Katalog und in der Sammlung ausgewählt werden.
</p>
</div>
<VerwaltungNav />
{children}
</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} />;
}
+29
View File
@@ -0,0 +1,29 @@
import type { Metadata } from 'next';
import { ThemeList, type ThemeRow } from '@/components/verwaltung/theme-list';
import { listManufacturers, listThemes } from '@/lib/queries/master-data';
export const metadata: Metadata = { title: 'Themen' };
export default function ThemenPage() {
const manufacturers = listManufacturers().map((row) => ({ id: row.id, name: row.name }));
const rows: ThemeRow[] = listThemes().map((row) => ({
id: row.id,
title: row.name,
subtitle: [row.manufacturerName, row.parentName ? `in ${row.parentName}` : null]
.filter(Boolean)
.join(' · '),
manufacturerId: row.manufacturerId,
parentThemeId: row.parentThemeId,
}));
if (manufacturers.length === 0) {
return (
<p className="rounded-lg border border-dashed border-border p-6 text-center text-sm text-muted-foreground">
Themen gehören immer zu einem Hersteller. Leg zuerst einen Hersteller an.
</p>
);
}
return <ThemeList themes={rows} manufacturers={manufacturers} />;
}
+135
View File
@@ -0,0 +1,135 @@
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { CatalogFilters } from '@/components/katalog/catalog-filters';
import { CatalogGrid } from '@/components/katalog/catalog-grid';
import { Pagination } from '@/components/katalog/pagination';
import { ShareCard } from '@/components/wunschlisten/share-card';
import { WishlistHeaderActions } from '@/components/wunschlisten/wishlist-header-actions';
import { WishlistItems } from '@/components/wunschlisten/wishlist-items';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { formatDate, pluralize } from '@/lib/format';
import { occasionLabel } from '@/lib/labels';
import { CATALOG_PAGE_SIZE, listSets, listSetYears } from '@/lib/queries/catalog';
import { listManufacturers, listThemes } from '@/lib/queries/master-data';
import { getWishlist, listWishlistItems } from '@/lib/queries/wishlists';
import { requireUser } from '@/lib/session';
type Props = {
params: Promise<{ id: string }>;
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 async function generateMetadata({ params }: Props): Promise<Metadata> {
const { id } = await params;
const list = getWishlist(id);
return { title: list?.title ?? 'Wunschliste' };
}
export default async function WunschlisteDetailPage({ params, searchParams }: Props) {
const user = await requireUser();
const { id } = await params;
const query = await searchParams;
const list = getWishlist(id);
if (!list || list.ownerUserId !== user.id) notFound();
const items = listWishlistItems(list.id);
// The embedded picker: missing sets that are not on this list yet. It shares
// the URL parameters with the catalog, so the filters behave identically.
const yearParam = single(query.jahr);
const page = Number(single(query.seite) ?? '1') || 1;
const missing = listSets({
q: single(query.q),
manufacturerId: single(query.hersteller),
themeId: single(query.thema),
year: yearParam ? Number(yearParam) : undefined,
ownership: 'fehlt',
excludeWishlistId: list.id,
page,
});
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="secondary">{occasionLabel(list.occasion)}</Badge>
{list.eventDate ? <Badge variant="outline">{formatDate(list.eventDate)}</Badge> : null}
{list.surpriseMode ? <Badge variant="outline">Überraschungsmodus</Badge> : null}
</div>
<h1 className="mt-2 truncate text-2xl font-semibold tracking-tight">{list.title}</h1>
<p className="text-sm text-muted-foreground">
{pluralize(items.length, 'Wunsch', 'Wünsche')}
</p>
</div>
<WishlistHeaderActions
wishlist={{
id: list.id,
title: list.title,
occasion: list.occasion,
eventDate: list.eventDate,
note: list.note,
isPublic: list.isPublic,
surpriseMode: list.surpriseMode,
}}
/>
</div>
<ShareCard
wishlistId={list.id}
shareToken={list.shareToken}
isPublic={list.isPublic}
title={list.title}
/>
{list.note ? (
<p className="rounded-lg bg-muted p-3 text-sm whitespace-pre-line">{list.note}</p>
) : null}
<WishlistItems wishlistId={list.id} items={items} surpriseMode={list.surpriseMode} />
<Card>
<CardHeader>
<CardTitle>Aus fehlenden Sets hinzufügen</CardTitle>
<CardDescription>
Nur Sets, die noch nicht in der Sammlung und noch nicht auf dieser Liste sind. Kachel
lange drücken oder „Auswählen“ tippen, dann mehrere auf einmal übernehmen.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<CatalogFilters
options={{
manufacturers: listManufacturers().map((row) => ({ id: row.id, name: row.name })),
themes: listThemes().map((row) => ({
id: row.id,
name: row.name,
manufacturerId: row.manufacturerId,
})),
years: listSetYears(),
}}
/>
<p className="text-sm text-muted-foreground">
{pluralize(missing.total, 'Set kommt infrage', 'Sets kommen infrage')}
</p>
<CatalogGrid
sets={missing.rows}
wishlists={[{ id: list.id, title: list.title }]}
defaultWishlistId={list.id}
emptyState="Es fehlt gerade nichts, was noch nicht auf dieser Liste steht."
/>
<Pagination page={page} total={missing.total} pageSize={CATALOG_PAGE_SIZE} />
</CardContent>
</Card>
</div>
);
}
+254
View File
@@ -0,0 +1,254 @@
'use server';
import { revalidatePath } from 'next/cache';
import { and, eq, inArray } from 'drizzle-orm';
import { db } from '@/db';
import { set, wishlist, wishlistItem } from '@/db/schema';
import {
actionData,
actionError,
actionOk,
parseForm,
type ActionResult,
} from '@/lib/action-result';
import { ownedCopiesSql } from '@/lib/ownership';
import {
findExistingWishlistSetIds,
getWishlist,
nextWishlistSortOrder,
} from '@/lib/queries/wishlists';
import { requireUser } from '@/lib/session';
import { createToken } from '@/lib/tokens';
import { describeBulkAdd, planBulkAdd } from '@/lib/wishlist-logic';
import {
addSetsToWishlistSchema,
wishlistItemSchema,
wishlistSchema,
} from '@/lib/validation';
function now() {
return new Date();
}
function revalidateWishlist(id?: string) {
revalidatePath('/wunschlisten');
revalidatePath('/katalog');
if (id) revalidatePath(`/wunschlisten/${id}`);
}
/** Every wishlist action first proves the current user owns the list. */
async function requireOwnedWishlist(id: string) {
const user = await requireUser();
const list = getWishlist(id);
if (!list || list.ownerUserId !== user.id) return null;
return list;
}
// ---------------------------------------------------------------------------
// Lists
// ---------------------------------------------------------------------------
export async function createWishlist(input: unknown): Promise<ActionResult<{ id: string }>> {
const user = await requireUser();
const parsed = parseForm(wishlistSchema, input);
if (!parsed.ok) return parsed;
const id = crypto.randomUUID();
db.insert(wishlist)
.values({
id,
ownerUserId: user.id,
title: parsed.data.title,
occasion: parsed.data.occasion,
eventDate: parsed.data.eventDate,
note: parsed.data.note,
isPublic: parsed.data.isPublic,
surpriseMode: parsed.data.surpriseMode,
shareToken: createToken(),
createdAt: now(),
updatedAt: now(),
})
.run();
revalidateWishlist(id);
return actionData({ id });
}
export async function updateWishlist(id: string, input: unknown): Promise<ActionResult> {
const list = await requireOwnedWishlist(id);
if (!list) return actionError('Diese Wunschliste gibt es nicht.');
const parsed = parseForm(wishlistSchema, input);
if (!parsed.ok) return parsed;
db.update(wishlist)
.set({
title: parsed.data.title,
occasion: parsed.data.occasion,
eventDate: parsed.data.eventDate,
note: parsed.data.note,
isPublic: parsed.data.isPublic,
surpriseMode: parsed.data.surpriseMode,
updatedAt: now(),
})
.where(eq(wishlist.id, id))
.run();
revalidateWishlist(id);
return actionOk();
}
export async function deleteWishlist(id: string): Promise<ActionResult> {
const list = await requireOwnedWishlist(id);
if (!list) return actionError('Diese Wunschliste gibt es nicht.');
db.delete(wishlist).where(eq(wishlist.id, id)).run();
revalidateWishlist(id);
return actionOk();
}
/**
* Issues a fresh share token. The old link stops working immediately - the way
* out when a list was shared with the wrong people.
*/
export async function rotateShareToken(id: string): Promise<ActionResult<{ token: string }>> {
const list = await requireOwnedWishlist(id);
if (!list) return actionError('Diese Wunschliste gibt es nicht.');
const token = createToken();
db.update(wishlist)
.set({ shareToken: token, updatedAt: now() })
.where(eq(wishlist.id, id))
.run();
revalidateWishlist(id);
return actionData({ token });
}
// ---------------------------------------------------------------------------
// Items
// ---------------------------------------------------------------------------
/**
* The central path from "fehlende Sets" onto a wishlist.
*
* Sets that are already on the list are skipped and named in the result, and
* so are sets that are already in the collection. The unique index on
* (wishlist_id, set_id) makes that a guarantee rather than a hope.
*/
export async function addSetsToWishlist(
input: unknown,
): Promise<ActionResult<{ added: number; message: string }>> {
await requireUser();
const parsed = parseForm(addSetsToWishlistSchema, input);
if (!parsed.ok) return parsed;
const list = await requireOwnedWishlist(parsed.data.wishlistId);
if (!list) return actionError('Diese Wunschliste gibt es nicht.');
const setIds = parsed.data.setIds;
const known = db
.select({
id: set.id,
name: set.name,
setNumber: set.setNumber,
ownedCopies: ownedCopiesSql(set.id).mapWith(Number),
})
.from(set)
.where(inArray(set.id, setIds))
.all();
if (known.length === 0) return actionError('Die ausgewählten Sets gibt es nicht mehr.');
const names = new Map(known.map((row) => [row.id, `${row.setNumber} ${row.name}`]));
const ownedSetIds = new Set(known.filter((row) => row.ownedCopies > 0).map((row) => row.id));
const existing = findExistingWishlistSetIds(list.id, setIds);
const plan = planBulkAdd(
setIds.filter((id) => names.has(id)),
existing,
ownedSetIds,
);
if (plan.toAdd.length > 0) {
let sortOrder = nextWishlistSortOrder(list.id);
db.insert(wishlistItem)
.values(
plan.toAdd.map((setId) => ({
id: crypto.randomUUID(),
wishlistId: list.id,
setId,
priority: parsed.data.priority,
sortOrder: sortOrder++,
createdAt: now(),
})),
)
// Belt and braces: a concurrent add must not blow up the whole batch.
.onConflictDoNothing()
.run();
}
revalidateWishlist(list.id);
return actionData({ added: plan.toAdd.length, message: describeBulkAdd(plan, names) });
}
export async function updateWishlistItem(itemId: string, input: unknown): Promise<ActionResult> {
const user = await requireUser();
const parsed = parseForm(wishlistItemSchema, input);
if (!parsed.ok) return parsed;
const item = db
.select({ id: wishlistItem.id, wishlistId: wishlistItem.wishlistId })
.from(wishlistItem)
.innerJoin(wishlist, eq(wishlist.id, wishlistItem.wishlistId))
.where(and(eq(wishlistItem.id, itemId), eq(wishlist.ownerUserId, user.id)))
.get();
if (!item) return actionError('Dieser Eintrag gehört nicht zu deinen Wunschlisten.');
db.update(wishlistItem)
.set({ priority: parsed.data.priority, note: parsed.data.note })
.where(eq(wishlistItem.id, itemId))
.run();
revalidateWishlist(item.wishlistId);
return actionOk();
}
export async function removeWishlistItem(itemId: string): Promise<ActionResult> {
const user = await requireUser();
const item = db
.select({ id: wishlistItem.id, wishlistId: wishlistItem.wishlistId })
.from(wishlistItem)
.innerJoin(wishlist, eq(wishlist.id, wishlistItem.wishlistId))
.where(and(eq(wishlistItem.id, itemId), eq(wishlist.ownerUserId, user.id)))
.get();
if (!item) return actionOk();
db.delete(wishlistItem).where(eq(wishlistItem.id, itemId)).run();
revalidateWishlist(item.wishlistId);
return actionOk();
}
/** Persists the order after drag and drop or the move buttons. */
export async function reorderWishlistItems(
wishlistId: string,
orderedItemIds: string[],
): Promise<ActionResult> {
const list = await requireOwnedWishlist(wishlistId);
if (!list) return actionError('Diese Wunschliste gibt es nicht.');
db.transaction((tx) => {
orderedItemIds.forEach((itemId, index) => {
tx.update(wishlistItem)
.set({ sortOrder: index })
.where(and(eq(wishlistItem.id, itemId), eq(wishlistItem.wishlistId, wishlistId)))
.run();
});
});
revalidateWishlist(wishlistId);
return actionOk();
}
+86
View File
@@ -0,0 +1,86 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { CalendarDays, Gift, Link2Off } from 'lucide-react';
import { NewWishlistButton } from '@/components/wunschlisten/wishlist-form-dialog';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { formatDate, pluralize } from '@/lib/format';
import { occasionLabel } from '@/lib/labels';
import { listWishlists } from '@/lib/queries/wishlists';
import { requireUser } from '@/lib/session';
export const metadata: Metadata = { title: 'Wunschlisten' };
export default async function WunschlistenPage() {
const user = await requireUser();
const lists = listWishlists(user.id);
return (
<div className="space-y-5">
<div className="flex items-start justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Wunschlisten</h1>
<p className="text-sm text-muted-foreground">
Zum Teilen mit Familie und Freunden – ganz ohne Konto für die anderen.
</p>
</div>
<NewWishlistButton />
</div>
{lists.length === 0 ? (
<div className="space-y-2 rounded-lg border border-dashed border-border p-8 text-center">
<p className="text-sm text-muted-foreground">
Noch keine Wunschliste. Der schnellste Weg: im Katalog auf „Fehlt noch“ filtern,
mehrere Sets auswählen und daraus eine Liste anlegen.
</p>
<Link href="/katalog?besitz=fehlt" className="text-sm underline underline-offset-4">
Zu den fehlenden Sets
</Link>
</div>
) : (
<ul className="grid gap-3 sm:grid-cols-2">
{lists.map((list) => (
<li key={list.id}>
<Link href={`/wunschlisten/${list.id}`} className="group block h-full">
<Card className="h-full transition-colors group-hover:border-primary/40">
<CardHeader>
<div className="flex items-center gap-2">
<Gift className="size-5 text-primary" aria-hidden />
<CardTitle className="truncate">{list.title}</CardTitle>
</div>
<CardDescription>
{occasionLabel(list.occasion)}
{list.eventDate ? ` · ${formatDate(list.eventDate)}` : ''}
</CardDescription>
</CardHeader>
<CardContent className="flex flex-wrap items-center gap-2 text-xs">
<Badge variant="secondary">{pluralize(list.itemCount, 'Wunsch', 'Wünsche')}</Badge>
{list.openCount > 0 ? (
<Badge variant="outline">{list.openCount} noch offen</Badge>
) : list.itemCount > 0 ? (
<Badge className="bg-emerald-600 text-white hover:bg-emerald-600">
Alles vergeben
</Badge>
) : null}
{list.isPublic ? null : (
<Badge variant="outline" className="gap-1">
<Link2Off className="size-3" aria-hidden />
Link deaktiviert
</Badge>
)}
{list.eventDate ? (
<span className="ml-auto flex items-center gap-1 text-muted-foreground">
<CalendarDays className="size-3" aria-hidden />
{formatDate(list.eventDate)}
</span>
) : null}
</CardContent>
</Card>
</Link>
</li>
))}
</ul>
)}
</div>
);
}
+38
View File
@@ -0,0 +1,38 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { Suspense } from 'react';
import { LoginForm } from '@/components/auth/login-form';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { isFirstRun } from '@/lib/auth';
import { getSession } from '@/lib/session';
export const metadata: Metadata = { title: 'Anmelden' };
export default async function LoginPage() {
if (await getSession()) redirect('/');
// Nobody registered yet: send the very first visitor straight to sign-up.
if (isFirstRun()) redirect('/registrieren');
return (
<Card>
<CardHeader>
<CardTitle>Anmelden</CardTitle>
<CardDescription>Melde dich an, um die Sammlung zu verwalten.</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<Suspense fallback={<Skeleton className="h-64 w-full" />}>
<LoginForm />
</Suspense>
<p className="text-center text-xs text-muted-foreground">
Noch kein Konto?{' '}
<Link href="/registrieren" className="underline underline-offset-4">
Registrierung
</Link>
</p>
</CardContent>
</Card>
);
}
+17
View File
@@ -0,0 +1,17 @@
import Link from 'next/link';
import { Blocks } from 'lucide-react';
export default function AuthLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex min-h-dvh flex-col items-center justify-center gap-8 px-4 py-10">
<Link href="/" className="flex items-center gap-2 text-xl font-semibold tracking-tight">
<Blocks className="size-6 text-primary" aria-hidden />
Bricktrack
</Link>
<main className="w-full max-w-sm">{children}</main>
<p className="text-center text-xs text-muted-foreground">
Die Sammlung und die Wunschlisten deiner Familie – auf deinem eigenen Server.
</p>
</div>
);
}
+47
View File
@@ -0,0 +1,47 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { RegisterForm } from '@/components/auth/register-form';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { isFirstRun } from '@/lib/auth';
import { getSession } from '@/lib/session';
export const metadata: Metadata = { title: 'Registrieren' };
export default async function RegisterPage() {
if (await getSession()) redirect('/');
// After the first user, accounts only come from an invitation link.
if (!isFirstRun()) {
return (
<Card>
<CardHeader>
<CardTitle>Registrierung geschlossen</CardTitle>
<CardDescription>
Diese Bricktrack-Instanz hat bereits einen Administrator. Weitere Konten entstehen nur
über einen Einladungslink.
</CardDescription>
</CardHeader>
<CardContent>
<Link href="/anmelden" className="text-sm underline underline-offset-4">
Zur Anmeldung
</Link>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle>Erstes Konto anlegen</CardTitle>
<CardDescription>
Du bist der erste Nutzer und wirst damit automatisch Administrator.
</CardDescription>
</CardHeader>
<CardContent>
<RegisterForm />
</CardContent>
</Card>
);
}
+4
View File
@@ -0,0 +1,4 @@
import { toNextJsHandler } from 'better-auth/next-js';
import { auth } from '@/lib/auth';
export const { GET, POST } = toNextJsHandler(auth.handler);
+55
View File
@@ -0,0 +1,55 @@
import fs from 'node:fs/promises';
import { NextResponse, type NextRequest } from 'next/server';
import { contentTypeFor, isPublicFilePath, resolveFilePath, UploadError } from '@/lib/uploads';
import { getSession } from '@/lib/session';
/**
* Serves uploads from DATA_DIR/files.
*
* Set images stay public because the shared wishlist page needs them; photos
* and instruction PDFs require a session.
*/
export async function GET(
request: NextRequest,
context: { params: Promise<{ path: string[] }> },
) {
const { path: segments } = await context.params;
const relativePath = segments.join('/');
if (!isPublicFilePath(relativePath) && !(await getSession())) {
return new NextResponse('Nicht angemeldet', { status: 401 });
}
let absolute: string;
try {
absolute = resolveFilePath(relativePath);
} catch (error) {
if (error instanceof UploadError) return new NextResponse('Ungültiger Pfad', { status: 400 });
throw error;
}
try {
const file = await fs.readFile(absolute);
const stats = await fs.stat(absolute);
return new NextResponse(new Uint8Array(file), {
headers: {
'Content-Type': contentTypeFor(relativePath),
'Content-Length': String(stats.size),
// Uploaded files never change under the same name, so they can be
// cached hard - privately, since some of them need a session.
'Cache-Control': isPublicFilePath(relativePath)
? 'public, max-age=31536000, immutable'
: 'private, max-age=3600',
'Content-Disposition': `inline; filename="${encodeURIComponent(relativePath.split('/').pop() ?? 'datei')}"`,
'X-Content-Type-Options': 'nosniff',
},
});
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return new NextResponse('Nicht gefunden', { status: 404 });
}
console.error('[files] Datei konnte nicht gelesen werden', relativePath, error);
return new NextResponse('Fehler beim Lesen der Datei', { status: 500 });
}
}
+19
View File
@@ -0,0 +1,19 @@
import { sql } from 'drizzle-orm';
import { NextResponse } from 'next/server';
import { db } from '@/db';
export const dynamic = 'force-dynamic';
/**
* Container healthcheck. Verifies that the process is up *and* that the SQLite
* file is readable - a healthy Node process on a broken volume is not healthy.
*/
export function GET() {
try {
db.get(sql`select 1`);
return NextResponse.json({ status: 'ok', time: new Date().toISOString() });
} catch (error) {
console.error('[health] Datenbank nicht erreichbar', error);
return NextResponse.json({ status: 'error', error: 'database unavailable' }, { status: 503 });
}
}
+138 -129
View File
@@ -1,130 +1,139 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-sans);
--font-mono: var(--font-geist-mono);
--font-heading: var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
:root {
--font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
"Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji",
"Segoe UI Emoji";
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
"Liberation Mono", monospace;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
--font-heading: var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}
+26 -24
View File
@@ -1,33 +1,35 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
import type { Metadata, Viewport } from 'next';
import { Toaster } from '@/components/ui/sonner';
import './globals.css';
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: {
default: 'Bricktrack',
template: '%s · Bricktrack',
},
description: 'Sammlung und Wunschlisten für Spielzeug-Sets',
applicationName: 'Bricktrack',
// A private, self-hosted instance never belongs in a search index - and the
// public wishlist links are meant to be shared, not found.
robots: { index: false, follow: false },
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
viewportFit: 'cover',
themeColor: [
{ media: '(prefers-color-scheme: light)', color: '#ffffff' },
{ media: '(prefers-color-scheme: dark)', color: '#0a0a0a' },
],
};
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<html lang="de" suppressHydrationWarning>
<body className="min-h-dvh bg-background text-foreground antialiased">
{children}
<Toaster position="top-center" richColors closeButton />
</body>
</html>
);
-103
View File
@@ -1,103 +0,0 @@
import Image from "next/image";
export default function Home() {
return (
<div className="font-sans grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20">
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={180}
height={38}
priority
/>
<ol className="font-mono list-inside list-decimal text-sm/6 text-center sm:text-left">
<li className="mb-2 tracking-[-.01em]">
Get started by editing{" "}
<code className="bg-black/[.05] dark:bg-white/[.06] font-mono font-semibold px-1 py-0.5 rounded">
src/app/page.tsx
</code>
.
</li>
<li className="tracking-[-.01em]">
Save and see your changes instantly.
</li>
</ol>
<div className="flex gap-4 items-center flex-col sm:flex-row">
<a
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={20}
height={20}
/>
Deploy now
</a>
<a
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Read our docs
</a>
</div>
</main>
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/file.svg"
alt="File icon"
width={16}
height={16}
/>
Learn
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/window.svg"
alt="Window icon"
width={16}
height={16}
/>
Examples
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/globe.svg"
alt="Globe icon"
width={16}
height={16}
/>
Go to nextjs.org →
</a>
</footer>
</div>
);
}
+103
View File
@@ -0,0 +1,103 @@
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { Blocks, CalendarDays, Gift } from 'lucide-react';
import { PublicWishlist, type PublicItem } from '@/components/wunschlisten/public-wishlist';
import { Badge } from '@/components/ui/badge';
import { formatDate, pluralize } from '@/lib/format';
import { occasionLabel } from '@/lib/labels';
import { getWishlistByShareToken, listWishlistItems } from '@/lib/queries/wishlists';
type Props = { params: Promise<{ shareToken: string }> };
/**
* The public face of a wishlist.
*
* Reachable with the share token alone, and it exposes nothing beyond the
* wishlist itself - no owner name, no collection, no other lists. Search
* engines are kept out; this is meant to be shared, not found.
*/
export const metadata: Metadata = {
title: 'Wunschliste',
robots: { index: false, follow: false },
};
export default async function PublicWishlistPage({ params }: Props) {
const { shareToken } = await params;
const list = getWishlistByShareToken(shareToken);
if (!list || !list.isPublic) notFound();
const rows = listWishlistItems(list.id);
// Fulfilled wishes are gone from the public view: the present is already
// under the tree, no reason to let someone buy it twice.
const open = rows.filter((row) => row.fulfilledAt === null);
const items: PublicItem[] = open.map((row) => ({
id: row.id,
setNumber: row.setNumber,
setName: row.setName,
year: row.year,
pieceCount: row.pieceCount,
imageUrl: row.imageUrl,
imagePath: row.imagePath,
retailPriceCents: row.retailPriceCents,
manufacturerName: row.manufacturerName,
manufacturerWebsite: row.manufacturerWebsite,
themeName: row.themeName,
priority: row.priority,
note: row.note,
isReserved: row.reservedAt !== null,
reservedByName: row.reservedByName,
}));
const openCount = items.filter((item) => !item.isReserved).length;
return (
<div className="mx-auto min-h-dvh w-full max-w-2xl px-4 py-8">
<header className="space-y-3 border-b border-border pb-6">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Blocks className="size-4 text-primary" aria-hidden />
Bricktrack
</div>
<h1 className="text-2xl font-semibold tracking-tight">{list.title}</h1>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary" className="gap-1">
<Gift className="size-3" aria-hidden />
{occasionLabel(list.occasion)}
</Badge>
{list.eventDate ? (
<Badge variant="outline" className="gap-1">
<CalendarDays className="size-3" aria-hidden />
{formatDate(list.eventDate)}
</Badge>
) : null}
<span className="text-sm text-muted-foreground">
{pluralize(openCount, 'Wunsch noch offen', 'Wünsche noch offen')}
</span>
</div>
{list.note ? (
<p className="rounded-lg bg-muted p-3 text-sm whitespace-pre-line">{list.note}</p>
) : null}
<p className="text-xs text-muted-foreground">
Klick auf „Das schenke ich“, damit niemand dasselbe doppelt kauft. Eine Anmeldung
brauchst du dafür nicht.
</p>
</header>
<main className="py-6">
{items.length === 0 ? (
<p className="rounded-lg border border-dashed border-border p-8 text-center text-sm text-muted-foreground">
Auf dieser Liste steht gerade nichts.
</p>
) : (
<PublicWishlist shareToken={shareToken} items={items} />
)}
</main>
</div>
);
}
@@ -0,0 +1,38 @@
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { CancelReservation } from '@/components/wunschlisten/cancel-reservation';
import { getWishlistByShareToken, listWishlistItems } from '@/lib/queries/wishlists';
export const metadata: Metadata = {
title: 'Reservierung aufheben',
robots: { index: false, follow: false },
};
/** The link a visitor receives after reserving a gift. */
export default async function CancelReservationPage({
params,
}: {
params: Promise<{ shareToken: string; itemId: string; token: string }>;
}) {
const { shareToken, itemId, token } = await params;
const list = getWishlistByShareToken(shareToken);
if (!list || !list.isPublic) notFound();
const item = listWishlistItems(list.id).find((row) => row.id === itemId);
if (!item) notFound();
return (
<div className="mx-auto flex min-h-dvh w-full max-w-md flex-col justify-center px-4 py-10">
<CancelReservation
shareToken={shareToken}
itemId={itemId}
token={token}
setName={item.setName}
setNumber={item.setNumber}
isReserved={item.reservedAt !== null}
listTitle={list.title}
/>
</div>
);
}
+93
View File
@@ -0,0 +1,93 @@
'use server';
import { revalidatePath } from 'next/cache';
import { and, eq, isNull } from 'drizzle-orm';
import { db } from '@/db';
import { wishlist, wishlistItem } from '@/db/schema';
import { actionData, actionError, actionOk, parseForm, type ActionResult } from '@/lib/action-result';
import { createToken, hashToken, tokensMatch } from '@/lib/tokens';
import { reservationSchema } from '@/lib/validation';
/**
* Actions for the public wishlist page.
*
* These run without a session. The share token is the only key, and every
* query is scoped by it - nothing outside the one wishlist is reachable, and
* nothing but wishlist data is returned.
*/
function loadPublicList(shareToken: string) {
const list = db
.select({ id: wishlist.id, isPublic: wishlist.isPublic })
.from(wishlist)
.where(eq(wishlist.shareToken, shareToken))
.get();
return list && list.isPublic ? list : null;
}
export async function reserveItem(
shareToken: string,
input: unknown,
): Promise<ActionResult<{ cancelToken: string }>> {
const list = loadPublicList(shareToken);
if (!list) return actionError('Diese Wunschliste ist nicht mehr erreichbar.');
const parsed = parseForm(reservationSchema, input);
if (!parsed.ok) return parsed;
const cancelToken = createToken();
// The `reserved_at is null` condition makes this a claim, not a read-then-write:
// if two people tap at the same moment, only the first one wins.
const result = db
.update(wishlistItem)
.set({
reservedByName: parsed.data.name,
reservedAt: new Date(),
reservationTokenHash: hashToken(cancelToken),
})
.where(
and(
eq(wishlistItem.id, parsed.data.itemId),
eq(wishlistItem.wishlistId, list.id),
isNull(wishlistItem.reservedAt),
),
)
.run();
if (result.changes === 0) {
return actionError('Dieser Wunsch ist gerade eben schon von jemand anderem übernommen worden.');
}
revalidatePath(`/w/${shareToken}`);
return actionData({ cancelToken });
}
export async function cancelReservation(
shareToken: string,
itemId: string,
cancelToken: string,
): Promise<ActionResult> {
const list = loadPublicList(shareToken);
if (!list) return actionError('Diese Wunschliste ist nicht mehr erreichbar.');
const item = db
.select({ id: wishlistItem.id, hash: wishlistItem.reservationTokenHash })
.from(wishlistItem)
.where(and(eq(wishlistItem.id, itemId), eq(wishlistItem.wishlistId, list.id)))
.get();
if (!item) return actionError('Diesen Eintrag gibt es nicht mehr.');
if (!tokensMatch(cancelToken, item.hash)) {
return actionError('Dieser Link passt nicht zu der Reservierung.');
}
db.update(wishlistItem)
.set({ reservedByName: null, reservedAt: null, reservationTokenHash: null })
.where(eq(wishlistItem.id, itemId))
.run();
revalidatePath(`/w/${shareToken}`);
return actionOk();
}
+131
View File
@@ -0,0 +1,131 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import { useEffect, useState } from 'react';
import { Fingerprint, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { authClient } from '@/lib/auth-client';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
export function LoginForm() {
const router = useRouter();
const searchParams = useSearchParams();
const target = searchParams.get('weiter') ?? '/';
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [pending, setPending] = useState<'password' | 'passkey' | null>(null);
useEffect(() => {
// Conditional UI: offers matching passkeys straight from the browser's
// autofill, where supported. Silently ignored everywhere else.
if (!window.PublicKeyCredential?.isConditionalMediationAvailable) return;
let cancelled = false;
void window.PublicKeyCredential.isConditionalMediationAvailable().then((available) => {
if (!available || cancelled) return;
void authClient.signIn
.passkey({ autoFill: true })
.then((result) => {
if (cancelled || result?.error) return;
router.push(target);
router.refresh();
})
.catch(() => {
/* user dismissed the autofill prompt */
});
});
return () => {
cancelled = true;
};
}, [router, target]);
async function signInWithPassword(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setPending('password');
const { error } = await authClient.signIn.email({ email, password });
setPending(null);
if (error) {
toast.error(
error.status === 429
? 'Zu viele Versuche. Bitte warte einen Moment.'
: 'E-Mail-Adresse oder Passwort stimmt nicht.',
);
return;
}
router.push(target);
router.refresh();
}
async function signInWithPasskey() {
setPending('passkey');
const result = await authClient.signIn.passkey();
setPending(null);
if (!result || result.error) {
toast.error('Anmeldung mit Passkey hat nicht geklappt.');
return;
}
router.push(target);
router.refresh();
}
return (
<div className="space-y-6">
<form onSubmit={signInWithPassword} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">E-Mail</Label>
<Input
id="email"
name="email"
type="email"
inputMode="email"
autoComplete="username webauthn"
required
value={email}
onChange={(event) => setEmail(event.target.value)}
placeholder="du@example.de"
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Passwort</Label>
<Input
id="password"
name="password"
type="password"
autoComplete="current-password webauthn"
required
value={password}
onChange={(event) => setPassword(event.target.value)}
/>
</div>
<Button type="submit" size="lg" className="w-full" disabled={pending !== null}>
{pending === 'password' ? <Loader2 className="animate-spin" /> : null}
Anmelden
</Button>
</form>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span className="h-px flex-1 bg-border" />
oder
<span className="h-px flex-1 bg-border" />
</div>
<Button
type="button"
variant="outline"
size="lg"
className="w-full"
onClick={signInWithPasskey}
disabled={pending !== null}
>
{pending === 'passkey' ? <Loader2 className="animate-spin" /> : <Fingerprint />}
Mit Passkey anmelden
</Button>
</div>
);
}
+207
View File
@@ -0,0 +1,207 @@
'use client';
import { useRouter } from 'next/navigation';
import { useState, useTransition } from 'react';
import { Fingerprint, KeyRound, Loader2, Pencil, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { deletePasskeyAction, renamePasskeyAction } from '@/app/(app)/konto/actions';
import { authClient } from '@/lib/auth-client';
import { formatDate } from '@/lib/format';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
export type PasskeyRow = {
id: string;
name: string | null;
createdAt: Date | null;
};
export function PasskeyManager({ passkeys }: { passkeys: PasskeyRow[] }) {
const router = useRouter();
const [adding, setAdding] = useState(false);
const [name, setName] = useState('');
const [dialogOpen, setDialogOpen] = useState(false);
const [renaming, setRenaming] = useState<PasskeyRow | null>(null);
const [pending, startTransition] = useTransition();
async function addPasskey() {
setAdding(true);
const result = await authClient.passkey.addPasskey({
name: name.trim() || defaultPasskeyName(),
});
setAdding(false);
if (result?.error) {
toast.error(result.error.message ?? 'Der Passkey konnte nicht angelegt werden.');
return;
}
toast.success('Passkey gespeichert.');
setName('');
setDialogOpen(false);
router.refresh();
}
function remove(passkey: PasskeyRow) {
startTransition(async () => {
const result = await deletePasskeyAction({ id: passkey.id });
if (!result.ok) {
toast.error(result.error);
return;
}
toast.success('Passkey gelöscht.');
router.refresh();
});
}
function rename(passkey: PasskeyRow, newName: string) {
startTransition(async () => {
const result = await renamePasskeyAction({ id: passkey.id, name: newName });
if (!result.ok) {
toast.error(result.error);
return;
}
toast.success('Passkey umbenannt.');
setRenaming(null);
router.refresh();
});
}
return (
<div className="space-y-4">
{passkeys.length === 0 ? (
<p className="rounded-lg border border-dashed border-border p-4 text-sm text-muted-foreground">
Noch kein Passkey hinterlegt. Mit einem Passkey meldest du dich per Fingerabdruck,
Gesichtserkennung oder Geräte-PIN an – ohne Passwort.
</p>
) : (
<ul className="divide-y divide-border rounded-lg border border-border">
{passkeys.map((passkey) => (
<li key={passkey.id} className="flex items-center gap-3 p-3">
<KeyRound className="size-5 shrink-0 text-muted-foreground" aria-hidden />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{passkey.name ?? 'Unbenannt'}</p>
<p className="text-xs text-muted-foreground">
Angelegt am {formatDate(passkey.createdAt)}
</p>
</div>
<Button
variant="ghost"
size="icon-sm"
aria-label="Umbenennen"
onClick={() => setRenaming(passkey)}
disabled={pending}
>
<Pencil />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label="Löschen"
onClick={() => remove(passkey)}
disabled={pending}
>
<Trash2 className="text-destructive" />
</Button>
</li>
))}
</ul>
)}
<Button size="lg" className="w-full sm:w-auto" onClick={() => setDialogOpen(true)}>
<Fingerprint />
Passkey hinzufügen
</Button>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Passkey hinzufügen</DialogTitle>
<DialogDescription>
Gib dem Passkey einen Namen, damit du ihn später wiedererkennst – zum Beispiel
&bdquo;iPhone von Papa&ldquo;.
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="passkey-name">Name</Label>
<Input
id="passkey-name"
value={name}
onChange={(event) => setName(event.target.value)}
placeholder={defaultPasskeyName()}
maxLength={60}
/>
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Abbrechen</Button>
</DialogClose>
<Button onClick={() => void addPasskey()} disabled={adding}>
{adding ? <Loader2 className="animate-spin" /> : <Fingerprint />}
Jetzt anlegen
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={renaming !== null} onOpenChange={(open) => !open && setRenaming(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Passkey umbenennen</DialogTitle>
<DialogDescription>
Der Name hilft dir nur beim Wiedererkennen und ist sonst ohne Bedeutung.
</DialogDescription>
</DialogHeader>
<form
id="rename-passkey"
onSubmit={(event) => {
event.preventDefault();
const value = new FormData(event.currentTarget).get('name');
if (renaming && typeof value === 'string') rename(renaming, value);
}}
className="space-y-2"
>
<Label htmlFor="rename-input">Name</Label>
<Input
id="rename-input"
name="name"
defaultValue={renaming?.name ?? ''}
maxLength={60}
required
/>
</form>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Abbrechen</Button>
</DialogClose>
<Button type="submit" form="rename-passkey" disabled={pending}>
Speichern
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
/** A sensible default so the list does not fill up with "Unbenannt". */
function defaultPasskeyName(): string {
if (typeof navigator === 'undefined') return 'Mein Gerät';
const ua = navigator.userAgent;
if (/iPhone/.test(ua)) return 'iPhone';
if (/iPad/.test(ua)) return 'iPad';
if (/Android/.test(ua)) return 'Android-Gerät';
if (/Macintosh/.test(ua)) return 'Mac';
if (/Windows/.test(ua)) return 'Windows-PC';
return 'Mein Gerät';
}
+91
View File
@@ -0,0 +1,91 @@
'use client';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { authClient } from '@/lib/auth-client';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
const MIN_PASSWORD_LENGTH = 10;
export function RegisterForm() {
const router = useRouter();
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [pending, setPending] = useState(false);
async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (password.length < MIN_PASSWORD_LENGTH) {
toast.error(`Das Passwort braucht mindestens ${MIN_PASSWORD_LENGTH} Zeichen.`);
return;
}
setPending(true);
const { error } = await authClient.signUp.email({ name, email, password });
setPending(false);
if (error) {
toast.error(error.message ?? 'Registrierung fehlgeschlagen.');
return;
}
toast.success('Konto angelegt. Willkommen bei Bricktrack!');
router.push('/');
router.refresh();
}
return (
<form onSubmit={onSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
autoComplete="name"
required
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="Vorname"
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">E-Mail</Label>
<Input
id="email"
type="email"
inputMode="email"
autoComplete="username"
required
value={email}
onChange={(event) => setEmail(event.target.value)}
placeholder="du@example.de"
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Passwort</Label>
<Input
id="password"
type="password"
autoComplete="new-password"
required
minLength={MIN_PASSWORD_LENGTH}
value={password}
onChange={(event) => setPassword(event.target.value)}
/>
<p className="text-xs text-muted-foreground">
Mindestens {MIN_PASSWORD_LENGTH} Zeichen. Einen Passkey kannst du direkt nach der
Anmeldung unter „Konto“ hinterlegen.
</p>
</div>
<Button type="submit" size="lg" className="w-full" disabled={pending}>
{pending ? <Loader2 className="animate-spin" /> : null}
Konto anlegen
</Button>
</form>
);
}
+148
View File
@@ -0,0 +1,148 @@
'use client';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { useEffect, useState, useTransition } from 'react';
import { Loader2, Search, X } from 'lucide-react';
import { OWNERSHIP_FILTERS, OWNERSHIP_FILTER_LABELS } from '@/lib/ownership';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { NativeSelect } from '@/components/ui/native-select';
export type FilterOptions = {
manufacturers: Array<{ id: string; name: string }>;
themes: Array<{ id: string; name: string; manufacturerId: string }>;
years: number[];
};
/**
* Filters live in the URL, so every state of the catalog is a shareable link
* and the back button behaves the way people expect.
*/
export function CatalogFilters({ options }: { options: FilterOptions }) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [pending, startTransition] = useTransition();
const [query, setQuery] = useState(searchParams.get('q') ?? '');
const manufacturerId = searchParams.get('hersteller') ?? '';
const themeId = searchParams.get('thema') ?? '';
const year = searchParams.get('jahr') ?? '';
const ownership = searchParams.get('besitz') ?? 'alle';
function apply(changes: Record<string, string | null>) {
const params = new URLSearchParams(searchParams.toString());
for (const [key, value] of Object.entries(changes)) {
if (value === null || value === '') params.delete(key);
else params.set(key, value);
}
// Any filter change starts over at page one.
params.delete('seite');
startTransition(() => router.push(`${pathname}?${params.toString()}`));
}
// Debounced search so typing on a phone does not fire a request per keystroke.
useEffect(() => {
const current = searchParams.get('q') ?? '';
if (query === current) return;
const timer = setTimeout(() => apply({ q: query || null }), 350);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query]);
const visibleThemes = manufacturerId
? options.themes.filter((theme) => theme.manufacturerId === manufacturerId)
: options.themes;
const hasFilters = Boolean(manufacturerId || themeId || year || query) || ownership !== 'alle';
return (
<div className="space-y-3">
<div className="relative">
<Search
className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground"
aria-hidden
/>
<Input
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Setnummer, Name oder EAN"
aria-label="Sets durchsuchen"
className="pl-9"
/>
{pending ? (
<Loader2 className="absolute top-1/2 right-3 size-4 -translate-y-1/2 animate-spin text-muted-foreground" />
) : null}
</div>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
<NativeSelect
aria-label="Besitzstatus"
value={ownership}
onChange={(event) => apply({ besitz: event.target.value })}
>
{OWNERSHIP_FILTERS.map((value) => (
<option key={value} value={value}>
{OWNERSHIP_FILTER_LABELS[value]}
</option>
))}
</NativeSelect>
<NativeSelect
aria-label="Hersteller"
value={manufacturerId}
onChange={(event) => apply({ hersteller: event.target.value || null, thema: null })}
>
<option value="">Alle Hersteller</option>
{options.manufacturers.map((manufacturer) => (
<option key={manufacturer.id} value={manufacturer.id}>
{manufacturer.name}
</option>
))}
</NativeSelect>
<NativeSelect
aria-label="Thema"
value={themeId}
onChange={(event) => apply({ thema: event.target.value || null })}
>
<option value="">Alle Themen</option>
{visibleThemes.map((theme) => (
<option key={theme.id} value={theme.id}>
{theme.name}
</option>
))}
</NativeSelect>
<NativeSelect
aria-label="Jahr"
value={year}
onChange={(event) => apply({ jahr: event.target.value || null })}
>
<option value="">Alle Jahre</option>
{options.years.map((value) => (
<option key={value} value={String(value)}>
{value}
</option>
))}
</NativeSelect>
</div>
{hasFilters ? (
<Button
variant="ghost"
size="sm"
onClick={() => {
setQuery('');
startTransition(() => router.push(pathname));
}}
>
<X />
Filter zurücksetzen
</Button>
) : null}
</div>
);
}
+317
View File
@@ -0,0 +1,317 @@
'use client';
import { useRouter } from 'next/navigation';
import { useRef, useState, useTransition } from 'react';
import { CheckCheck, Gift, Loader2, X } from 'lucide-react';
import { toast } from 'sonner';
import { cn } from 'cn';
import { addSetsToWishlist, createWishlist } from '@/app/(app)/wunschlisten/actions';
import type { CatalogRow } from '@/lib/queries/catalog';
import { PRIORITY_LABELS } from '@/lib/wishlist-logic';
import { SetCard, SetCardContent } from './set-card';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Field } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { NativeSelect } from '@/components/ui/native-select';
const LONG_PRESS_MS = 450;
const NEW_LIST = '__neu__';
export type WishlistTarget = { id: string; title: string };
type Props = {
sets: CatalogRow[];
wishlists: WishlistTarget[];
/** Preselected target, used by the embedded view on a wishlist page. */
defaultWishlistId?: string;
emptyState?: React.ReactNode;
};
/**
* The catalog grid with its selection mode.
*
* On a phone a long press starts the selection, afterwards a tap toggles.
* On a desktop the "Auswählen" button does the same job. Sets that are already
* in the collection cannot be selected - they do not belong on a wishlist.
*/
export function CatalogGrid({ sets, wishlists, defaultWishlistId, emptyState }: Props) {
const router = useRouter();
const [selecting, setSelecting] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [dialogOpen, setDialogOpen] = useState(false);
const [targetId, setTargetId] = useState(defaultWishlistId ?? wishlists[0]?.id ?? NEW_LIST);
const [newTitle, setNewTitle] = useState('');
const [priority, setPriority] = useState('2');
const [pending, startTransition] = useTransition();
const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const longPressFired = useRef(false);
const selectable = sets.filter((item) => item.ownedCopies === 0);
function toggle(setId: string) {
setSelected((current) => {
const next = new Set(current);
if (next.has(setId)) next.delete(setId);
else next.add(setId);
return next;
});
}
function startLongPress(item: CatalogRow) {
if (item.ownedCopies > 0) return;
longPressFired.current = false;
longPressTimer.current = setTimeout(() => {
longPressFired.current = true;
setSelecting(true);
toggle(item.id);
// A short buzz confirms the mode switch where the device supports it.
navigator.vibrate?.(15);
}, LONG_PRESS_MS);
}
function cancelLongPress() {
if (longPressTimer.current) clearTimeout(longPressTimer.current);
longPressTimer.current = null;
}
function endSelection() {
setSelecting(false);
setSelected(new Set());
}
function submit() {
const setIds = [...selected];
if (setIds.length === 0) return;
startTransition(async () => {
let wishlistId = targetId;
if (wishlistId === NEW_LIST) {
// isPublic: a list created on the fly is meant to be shared right away.
const created = await createWishlist({
title: newTitle,
occasion: 'other',
isPublic: 'on',
});
if (!created.ok) {
toast.error(created.error);
return;
}
wishlistId = created.data.id;
}
const result = await addSetsToWishlist({ wishlistId, setIds, priority });
if (!result.ok) {
toast.error(result.error);
return;
}
if (result.data.added === 0) toast.warning(result.data.message);
else toast.success(result.data.message);
setDialogOpen(false);
endSelection();
router.refresh();
});
}
if (sets.length === 0) {
return (
<div className="rounded-lg border border-dashed border-border p-8 text-center text-sm text-muted-foreground">
{emptyState ?? 'Keine Sets gefunden. Passe die Filter an oder leg ein neues Set an.'}
</div>
);
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between gap-2">
<p className="text-xs text-muted-foreground">
{selecting
? 'Tippe die Sets an, die auf die Wunschliste sollen.'
: 'Tipp: Kachel lange drücken, um mehrere Sets auszuwählen.'}
</p>
{selecting ? (
<Button variant="ghost" size="sm" onClick={endSelection}>
<X />
Abbrechen
</Button>
) : (
<Button
variant="outline"
size="sm"
onClick={() => setSelecting(true)}
disabled={selectable.length === 0}
>
<CheckCheck />
Auswählen
</Button>
)}
</div>
<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) => {
const owned = item.ownedCopies > 0;
const isSelected = selected.has(item.id);
if (!selecting) {
return (
<div
key={item.id}
onPointerDown={() => startLongPress(item)}
onPointerUp={cancelLongPress}
onPointerLeave={cancelLongPress}
onPointerCancel={cancelLongPress}
onContextMenu={(event) => {
// The long press must not open the browser context menu.
if (longPressFired.current) event.preventDefault();
}}
onClickCapture={(event) => {
if (longPressFired.current) {
event.preventDefault();
event.stopPropagation();
longPressFired.current = false;
}
}}
>
<SetCard set={item} className="block h-full" />
</div>
);
}
return (
<button
key={item.id}
type="button"
disabled={owned}
aria-pressed={isSelected}
onClick={() => toggle(item.id)}
title={owned ? 'Schon in der Sammlung' : undefined}
className={cn(
'relative rounded-xl text-left outline-none transition-opacity',
'focus-visible:ring-3 focus-visible:ring-ring/50',
owned && 'cursor-not-allowed opacity-50',
isSelected && '[&>div]:border-primary [&>div]:ring-2 [&>div]:ring-primary/40',
)}
>
<SetCardContent set={item} />
{!owned ? (
<span className="absolute top-2 right-2 rounded-md bg-background/90 p-1 shadow-sm">
<Checkbox checked={isSelected} tabIndex={-1} aria-hidden />
</span>
) : null}
</button>
);
})}
</div>
{selecting ? (
<div className="fixed inset-x-0 bottom-0 z-50 border-t border-border bg-background/95 p-3 pb-[calc(env(safe-area-inset-bottom)+0.75rem)] backdrop-blur">
<div className="mx-auto flex max-w-5xl items-center gap-3">
<span className="text-sm font-medium">
{selected.size} ausgewählt
</span>
<div className="flex-1" />
<Button
size="lg"
disabled={selected.size === 0}
onClick={() => setDialogOpen(true)}
>
<Gift />
Zur Wunschliste hinzufügen
</Button>
</div>
</div>
) : null}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{selected.size === 1
? '1 Set auf die Wunschliste'
: `${selected.size} Sets auf die Wunschliste`}
</DialogTitle>
<DialogDescription>
Sets, die schon auf der Liste oder bereits in der Sammlung sind, werden
übersprungen.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<Field label="Wunschliste" htmlFor="target-wishlist" required>
<NativeSelect
id="target-wishlist"
value={targetId}
onChange={(event) => setTargetId(event.target.value)}
>
{wishlists.map((list) => (
<option key={list.id} value={list.id}>
{list.title}
</option>
))}
<option value={NEW_LIST}>+ Neue Wunschliste anlegen</option>
</NativeSelect>
</Field>
{targetId === NEW_LIST ? (
<Field label="Titel der neuen Liste" htmlFor="new-title" required>
<Input
id="new-title"
value={newTitle}
onChange={(event) => setNewTitle(event.target.value)}
placeholder="Weihnachten 2026"
maxLength={80}
/>
</Field>
) : null}
<Field
label="Priorität"
htmlFor="priority"
hint="Gilt für alle ausgewählten Sets und lässt sich später je Eintrag ändern."
>
<NativeSelect
id="priority"
value={priority}
onChange={(event) => setPriority(event.target.value)}
>
{Object.entries(PRIORITY_LABELS).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</NativeSelect>
</Field>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)} disabled={pending}>
Abbrechen
</Button>
<Button onClick={submit} disabled={pending}>
{pending ? <Loader2 className="animate-spin" /> : <Gift />}
Hinzufügen
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -0,0 +1,215 @@
'use client';
import { useRouter } from 'next/navigation';
import { useState, useTransition } from 'react';
import { Download, ExternalLink, FileText, Loader2, Plus, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import {
addInstructionPdf,
addInstructionUrl,
deleteInstruction,
} from '@/app/(app)/katalog/actions';
import { fileUrl } from '@/lib/images';
import { PdfViewer } from './pdf-viewer';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Field } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { NativeSelect } from '@/components/ui/native-select';
export type InstructionRow = {
id: string;
kind: 'pdf_upload' | 'external_url';
filePath: string | null;
url: string | null;
label: string | null;
fileSizeBytes: number | null;
};
function formatSize(bytes: number | null): string {
if (!bytes) return '';
const mb = bytes / (1024 * 1024);
return mb >= 1
? `${mb.toLocaleString('de-DE', { maximumFractionDigits: 1 })} MB`
: `${Math.round(bytes / 1024)} KB`;
}
export function InstructionsSection({
setId,
instructions,
}: {
setId: string;
instructions: InstructionRow[];
}) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [addOpen, setAddOpen] = useState(false);
const [kind, setKind] = useState<'pdf_upload' | 'external_url'>('pdf_upload');
const [viewing, setViewing] = useState<InstructionRow | null>(null);
function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const formData = new FormData(event.currentTarget);
startTransition(async () => {
const result =
kind === 'pdf_upload'
? await addInstructionPdf(setId, formData)
: await addInstructionUrl(setId, Object.fromEntries(formData.entries()));
if (!result.ok) {
toast.error(result.error);
return;
}
toast.success('Anleitung hinzugefügt.');
setAddOpen(false);
router.refresh();
});
}
function remove(id: string) {
startTransition(async () => {
const result = await deleteInstruction(id);
if (!result.ok) {
toast.error(result.error);
return;
}
toast.success('Anleitung entfernt.');
router.refresh();
});
}
return (
<div className="space-y-3">
{instructions.length === 0 ? (
<p className="text-sm text-muted-foreground">
Noch keine Anleitung hinterlegt. Lade das PDF hoch oder verlinke es beim Hersteller.
</p>
) : (
<ul className="divide-y divide-border rounded-lg border border-border">
{instructions.map((item) => (
<li key={item.id} className="flex items-center gap-3 p-3">
<FileText className="size-5 shrink-0 text-muted-foreground" aria-hidden />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">
{item.label ?? (item.kind === 'pdf_upload' ? 'Anleitung' : 'Externe Anleitung')}
</p>
<p className="text-xs text-muted-foreground">
{item.kind === 'pdf_upload' ? `PDF ${formatSize(item.fileSizeBytes)}` : 'Link'}
</p>
</div>
{item.kind === 'pdf_upload' && item.filePath ? (
<>
<Button variant="outline" size="sm" onClick={() => setViewing(item)}>
Ansehen
</Button>
<Button asChild variant="ghost" size="icon-sm" aria-label="Herunterladen">
<a href={fileUrl(item.filePath)} download>
<Download />
</a>
</Button>
</>
) : item.url ? (
<Button asChild variant="outline" size="sm">
<a href={item.url} target="_blank" rel="noreferrer noopener">
Öffnen
<ExternalLink />
</a>
</Button>
) : null}
<Button
variant="ghost"
size="icon-sm"
aria-label="Anleitung entfernen"
onClick={() => remove(item.id)}
disabled={pending}
>
<Trash2 className="text-destructive" />
</Button>
</li>
))}
</ul>
)}
<Button variant="outline" onClick={() => setAddOpen(true)}>
<Plus />
Anleitung hinzufügen
</Button>
<Dialog open={addOpen} onOpenChange={setAddOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Anleitung hinzufügen</DialogTitle>
<DialogDescription>
Ein Set kann mehrere Anleitungen haben – zum Beispiel „Buch 1 von 3“.
</DialogDescription>
</DialogHeader>
<form id="instruction-form" onSubmit={submit} className="space-y-4">
<Field label="Art" htmlFor="instruction-kind">
<NativeSelect
id="instruction-kind"
value={kind}
onChange={(event) => setKind(event.target.value as typeof kind)}
>
<option value="pdf_upload">PDF hochladen</option>
<option value="external_url">Link zur Anleitung</option>
</NativeSelect>
</Field>
<Field label="Bezeichnung" htmlFor="label" hint="Optional, z. B. „Buch 1 von 3“.">
<Input id="label" name="label" maxLength={80} />
</Field>
{kind === 'pdf_upload' ? (
<Field label="PDF-Datei" htmlFor="file" required>
<Input id="file" name="file" type="file" accept="application/pdf" required />
</Field>
) : (
<Field label="Adresse" htmlFor="url" required>
<Input
id="url"
name="url"
type="url"
inputMode="url"
required
placeholder="https://www.lego.com/…"
/>
</Field>
)}
</form>
<DialogFooter>
<Button variant="outline" onClick={() => setAddOpen(false)} disabled={pending}>
Abbrechen
</Button>
<Button type="submit" form="instruction-form" disabled={pending}>
{pending ? <Loader2 className="animate-spin" /> : null}
Hinzufügen
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={viewing !== null} onOpenChange={(open) => !open && setViewing(null)}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>{viewing?.label ?? 'Anleitung'}</DialogTitle>
</DialogHeader>
{viewing?.filePath ? (
<PdfViewer url={fileUrl(viewing.filePath)} label={viewing.label ?? 'Anleitung'} />
) : null}
</DialogContent>
</Dialog>
</div>
);
}
+54
View File
@@ -0,0 +1,54 @@
'use client';
import { usePathname, useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { Button } from '@/components/ui/button';
export function Pagination({
page,
total,
pageSize,
}: {
page: number;
total: number;
pageSize: number;
}) {
const pathname = usePathname();
const searchParams = useSearchParams();
const lastPage = Math.max(1, Math.ceil(total / pageSize));
if (lastPage <= 1) return null;
function hrefFor(target: number) {
const params = new URLSearchParams(searchParams.toString());
if (target <= 1) params.delete('seite');
else params.set('seite', String(target));
const query = params.toString();
return query ? `${pathname}?${query}` : pathname;
}
return (
<nav className="flex items-center justify-between gap-3" aria-label="Seiten">
<Button asChild variant="outline" disabled={page <= 1}>
<Link href={hrefFor(page - 1)} aria-disabled={page <= 1} className={page <= 1 ? 'pointer-events-none opacity-50' : ''}>
<ChevronLeft />
Zurück
</Link>
</Button>
<span className="text-sm text-muted-foreground">
Seite {page} von {lastPage}
</span>
<Button asChild variant="outline" disabled={page >= lastPage}>
<Link
href={hrefFor(page + 1)}
aria-disabled={page >= lastPage}
className={page >= lastPage ? 'pointer-events-none opacity-50' : ''}
>
Weiter
<ChevronRight />
</Link>
</Button>
</nav>
);
}
+155
View File
@@ -0,0 +1,155 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import { ChevronLeft, ChevronRight, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
/**
* Minimal pdf.js viewer for building instructions.
*
* The library and its worker are bundled with the app - no CDN, no external
* request. One page at a time, because that is how you actually build a set,
* and because rendering 200 pages at once would stall a phone.
*/
export function PdfViewer({ url, label }: { url: string; label: string }) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const documentRef = useRef<any>(null);
const renderTaskRef = useRef<{ cancel: () => void } | null>(null);
const loadingTaskRef = useRef<{ destroy: () => Promise<void> } | null>(null);
const [page, setPage] = useState(1);
const [pageCount, setPageCount] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
async function load() {
try {
const pdfjs = await import('pdfjs-dist');
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url,
).toString();
const loadingTask = pdfjs.getDocument({ url });
const document = await loadingTask.promise;
if (cancelled) {
void loadingTask.destroy();
return;
}
loadingTaskRef.current = loadingTask;
documentRef.current = document;
setPageCount(document.numPages);
setPage(1);
} catch (cause) {
console.error('[pdf] konnte nicht geladen werden', cause);
if (!cancelled) setError('Die Anleitung konnte nicht geladen werden.');
}
}
void load();
return () => {
cancelled = true;
renderTaskRef.current?.cancel();
void loadingTaskRef.current?.destroy();
loadingTaskRef.current = null;
documentRef.current = null;
};
}, [url]);
const renderPage = useCallback(async (pageNumber: number) => {
const document = documentRef.current;
const canvas = canvasRef.current;
const container = containerRef.current;
if (!document || !canvas || !container) return;
setLoading(true);
renderTaskRef.current?.cancel();
try {
const pdfPage = await document.getPage(pageNumber);
const unscaled = pdfPage.getViewport({ scale: 1 });
// Fit the width of the container, but stay sharp on high-DPI screens.
const scale = (container.clientWidth || 600) / unscaled.width;
const ratio = Math.min(window.devicePixelRatio || 1, 2);
const viewport = pdfPage.getViewport({ scale: scale * ratio });
canvas.width = viewport.width;
canvas.height = viewport.height;
canvas.style.width = '100%';
canvas.style.height = 'auto';
const context = canvas.getContext('2d');
if (!context) return;
const task = pdfPage.render({ canvas, canvasContext: context, viewport });
renderTaskRef.current = task;
await task.promise;
} catch (cause) {
if ((cause as { name?: string })?.name !== 'RenderingCancelledException') {
console.error('[pdf] Seite konnte nicht gezeichnet werden', cause);
}
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (pageCount > 0) void renderPage(page);
}, [page, pageCount, renderPage]);
if (error) {
return (
<div className="space-y-3 rounded-lg border border-border p-4 text-sm">
<p className="text-destructive">{error}</p>
<Button asChild variant="outline" size="sm">
<a href={url} target="_blank" rel="noreferrer">
PDF direkt öffnen
</a>
</Button>
</div>
);
}
return (
<div className="space-y-3">
<div ref={containerRef} className="relative rounded-lg border border-border bg-muted/30 p-2">
<canvas ref={canvasRef} aria-label={`${label}, Seite ${page}`} className="mx-auto block" />
{loading ? (
<div className="absolute inset-0 flex items-center justify-center bg-background/60">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
) : null}
</div>
<div className="flex items-center justify-between gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPage((current) => Math.max(1, current - 1))}
disabled={page <= 1 || loading}
>
<ChevronLeft />
Zurück
</Button>
<span className="text-sm text-muted-foreground">
Seite {page} von {pageCount || '…'}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setPage((current) => Math.min(pageCount, current + 1))}
disabled={page >= pageCount || loading}
>
Weiter
<ChevronRight />
</Button>
</div>
</div>
);
}
+138
View File
@@ -0,0 +1,138 @@
'use client';
import { useRouter } from 'next/navigation';
import { useState, useTransition } from 'react';
import { Camera, Loader2, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { addSetPhoto, deletePhoto } from '@/app/(app)/katalog/actions';
import { fileUrl } from '@/lib/images';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Field } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
export type PhotoRow = {
id: string;
filePath: string;
thumbnailPath: string | null;
caption: string | null;
};
export function PhotosSection({ setId, photos }: { setId: string; photos: PhotoRow[] }) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [addOpen, setAddOpen] = useState(false);
function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const formData = new FormData(event.currentTarget);
startTransition(async () => {
const result = await addSetPhoto(setId, formData);
if (!result.ok) {
toast.error(result.error);
return;
}
toast.success('Foto gespeichert.');
setAddOpen(false);
router.refresh();
});
}
function remove(id: string) {
startTransition(async () => {
const result = await deletePhoto(id);
if (!result.ok) {
toast.error(result.error);
return;
}
toast.success('Foto gelöscht.');
router.refresh();
});
}
return (
<div className="space-y-3">
{photos.length > 0 ? (
<ul className="grid grid-cols-3 gap-2 sm:grid-cols-4">
{photos.map((item) => (
<li key={item.id} className="group relative">
<a
href={fileUrl(item.filePath)}
target="_blank"
rel="noreferrer"
className="block overflow-hidden rounded-lg border border-border"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={fileUrl(item.thumbnailPath ?? item.filePath)}
alt={item.caption ?? ''}
loading="lazy"
className="aspect-square w-full object-cover"
/>
</a>
<Button
variant="secondary"
size="icon-xs"
aria-label="Foto löschen"
className="absolute top-1 right-1 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
onClick={() => remove(item.id)}
disabled={pending}
>
<Trash2 className="text-destructive" />
</Button>
</li>
))}
</ul>
) : (
<p className="text-sm text-muted-foreground">
Noch keine eigenen Fotos – zum Beispiel vom fertig gebauten Modell.
</p>
)}
<Button variant="outline" onClick={() => setAddOpen(true)}>
<Camera />
Foto hinzufügen
</Button>
<Dialog open={addOpen} onOpenChange={setAddOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Foto hinzufügen</DialogTitle>
</DialogHeader>
<form id="photo-form" onSubmit={submit} className="space-y-4">
<Field label="Bild" htmlFor="photo-file" required>
{/* capture lets the phone open the camera directly. */}
<Input
id="photo-file"
name="file"
type="file"
accept="image/jpeg,image/png,image/webp"
capture="environment"
required
/>
</Field>
<Field label="Bildunterschrift" htmlFor="caption">
<Input id="caption" name="caption" maxLength={160} />
</Field>
</form>
<DialogFooter>
<Button variant="outline" onClick={() => setAddOpen(false)} disabled={pending}>
Abbrechen
</Button>
<Button type="submit" form="photo-form" disabled={pending}>
{pending ? <Loader2 className="animate-spin" /> : null}
Speichern
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+221
View File
@@ -0,0 +1,221 @@
'use client';
import { useRouter } from 'next/navigation';
import { useState, useTransition } from 'react';
import { Gift, Loader2, Plus, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { deleteSet } from '@/app/(app)/katalog/actions';
import { addSetToCollection } from '@/app/(app)/sammlung/actions';
import { addSetsToWishlist, createWishlist } from '@/app/(app)/wunschlisten/actions';
import { PRIORITY_LABELS } from '@/lib/wishlist-logic';
import { Button } from '@/components/ui/button';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Field } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { NativeSelect } from '@/components/ui/native-select';
const NEW_LIST = '__neu__';
export function SetActions({
setId,
setName,
owned,
wishlists,
}: {
setId: string;
setName: string;
owned: boolean;
wishlists: Array<{ id: string; title: string }>;
}) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [wishlistOpen, setWishlistOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [targetId, setTargetId] = useState(wishlists[0]?.id ?? NEW_LIST);
const [newTitle, setNewTitle] = useState('');
const [priority, setPriority] = useState('2');
function addToCollection() {
startTransition(async () => {
const result = await addSetToCollection(setId);
if (!result.ok) {
toast.error(result.error);
return;
}
toast.success('Zur Sammlung hinzugefügt.');
router.refresh();
});
}
function addToWishlist() {
startTransition(async () => {
let wishlistId = targetId;
if (wishlistId === NEW_LIST) {
// isPublic: a list created on the fly is meant to be shared right away.
const created = await createWishlist({
title: newTitle,
occasion: 'other',
isPublic: 'on',
});
if (!created.ok) {
toast.error(created.error);
return;
}
wishlistId = created.data.id;
}
const result = await addSetsToWishlist({ wishlistId, setIds: [setId], priority });
if (!result.ok) {
toast.error(result.error);
return;
}
if (result.data.added === 0) toast.warning(result.data.message);
else toast.success('Auf die Wunschliste gesetzt.');
setWishlistOpen(false);
router.refresh();
});
}
function remove() {
startTransition(async () => {
const result = await deleteSet(setId);
if (!result.ok) {
toast.error(result.error);
return;
}
toast.success('Set gelöscht.');
router.push('/katalog');
router.refresh();
});
}
return (
<div className="flex flex-wrap gap-2">
<Button size="lg" onClick={addToCollection} disabled={pending}>
{pending ? <Loader2 className="animate-spin" /> : <Plus />}
Zur Sammlung hinzufügen
</Button>
<Button
size="lg"
variant="outline"
onClick={() => setWishlistOpen(true)}
disabled={pending || owned}
title={owned ? 'Das Set ist bereits in der Sammlung.' : undefined}
>
<Gift />
Auf Wunschliste
</Button>
<Button size="lg" variant="ghost" onClick={() => setDeleteOpen(true)} disabled={pending}>
<Trash2 className="text-destructive" />
Löschen
</Button>
{owned ? (
<p className="w-full text-xs text-muted-foreground">
Sets aus der Sammlung lassen sich nicht auf eine Wunschliste setzen.
</p>
) : null}
<Dialog open={wishlistOpen} onOpenChange={setWishlistOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Auf Wunschliste setzen</DialogTitle>
<DialogDescription>{setName}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<Field label="Wunschliste" htmlFor="single-target" required>
<NativeSelect
id="single-target"
value={targetId}
onChange={(event) => setTargetId(event.target.value)}
>
{wishlists.map((list) => (
<option key={list.id} value={list.id}>
{list.title}
</option>
))}
<option value={NEW_LIST}>+ Neue Wunschliste anlegen</option>
</NativeSelect>
</Field>
{targetId === NEW_LIST ? (
<Field label="Titel der neuen Liste" htmlFor="single-new-title" required>
<Input
id="single-new-title"
value={newTitle}
onChange={(event) => setNewTitle(event.target.value)}
placeholder="Weihnachten 2026"
maxLength={80}
/>
</Field>
) : null}
<Field label="Priorität" htmlFor="single-priority">
<NativeSelect
id="single-priority"
value={priority}
onChange={(event) => setPriority(event.target.value)}
>
{Object.entries(PRIORITY_LABELS).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</NativeSelect>
</Field>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setWishlistOpen(false)} disabled={pending}>
Abbrechen
</Button>
<Button onClick={addToWishlist} disabled={pending}>
{pending ? <Loader2 className="animate-spin" /> : <Gift />}
Hinzufügen
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Set wirklich löschen?</AlertDialogTitle>
<AlertDialogDescription>
„{setName}“ wird aus dem Katalog entfernt – mit allen Sammlungseinträgen,
Anleitungen, Fotos und Wunschlisten-Einträgen dazu. Das lässt sich nicht rückgängig
machen.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Abbrechen</AlertDialogCancel>
<AlertDialogAction onClick={remove}>Löschen</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
+88
View File
@@ -0,0 +1,88 @@
import Link from 'next/link';
import { Check, Gift, ImageOff } from 'lucide-react';
import { cn } from 'cn';
import type { CatalogRow } from '@/lib/queries/catalog';
import { formatEuro } from '@/lib/money';
import { setImageSrc } from '@/lib/images';
import { Badge } from '@/components/ui/badge';
/**
* One set in the catalog grid.
*
* Rendered as a plain block so it can be used both as a link (browsing) and
* inside a selection wrapper (bulk add to a wishlist).
*/
export function SetCardContent({ set }: { set: CatalogRow }) {
const image = setImageSrc(set);
const owned = set.ownedCopies > 0;
return (
<div className="flex h-full flex-col overflow-hidden rounded-xl border border-border bg-card transition-colors">
<div className="relative aspect-square bg-muted">
{image ? (
// Plain img: images are either local files or a URL the user entered,
// and the optimizer must never fetch arbitrary remote hosts.
// eslint-disable-next-line @next/next/no-img-element
<img
src={image}
alt=""
loading="lazy"
decoding="async"
className="size-full object-contain p-2"
/>
) : (
<div className="flex size-full items-center justify-center text-muted-foreground">
<ImageOff className="size-8" aria-hidden />
</div>
)}
<div className="absolute top-2 left-2 flex flex-col items-start gap-1">
{owned ? (
<Badge className="gap-1 bg-emerald-600 text-white hover:bg-emerald-600">
<Check className="size-3" aria-hidden />
{set.ownedCopies > 1 ? `${set.ownedCopies}× vorhanden` : 'Vorhanden'}
</Badge>
) : null}
{set.wishlistCount > 0 ? (
<Badge variant="secondary" className="gap-1">
<Gift className="size-3" aria-hidden />
Auf Wunschliste
</Badge>
) : null}
</div>
</div>
<div className="flex flex-1 flex-col gap-1 p-3">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Badge variant="outline" className="font-mono">
{set.setNumber}
</Badge>
<span className="truncate">{set.manufacturerName}</span>
</div>
<p className="line-clamp-2 text-sm leading-snug font-medium">{set.name}</p>
<div className="mt-auto flex items-center justify-between gap-2 pt-1 text-xs text-muted-foreground">
<span className="truncate">{[set.themeName, set.year].filter(Boolean).join(' · ')}</span>
{set.retailPriceCents !== null ? (
<span className="shrink-0 font-medium text-foreground">
{formatEuro(set.retailPriceCents)}
</span>
) : null}
</div>
</div>
</div>
);
}
export function SetCard({ set, className }: { set: CatalogRow; className?: string }) {
return (
<Link
href={`/katalog/${set.id}`}
className={cn(
'rounded-xl outline-none focus-visible:ring-3 focus-visible:ring-ring/50 [&>div]:hover:border-primary/40',
className,
)}
>
<SetCardContent set={set} />
</Link>
);
}
+248
View File
@@ -0,0 +1,248 @@
'use client';
import { useRouter } from 'next/navigation';
import { useState, useTransition } from 'react';
import { Loader2, Save } from 'lucide-react';
import { toast } from 'sonner';
import { createSet, updateSet } from '@/app/(app)/katalog/actions';
import { centsToInput } from '@/lib/money';
import { setImageSrc } from '@/lib/images';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Field } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { NativeSelect } from '@/components/ui/native-select';
import { Textarea } from '@/components/ui/textarea';
export type SetFormValues = {
id: string;
manufacturerId: string;
themeId: string | null;
setNumber: string;
name: string;
year: number | null;
pieceCount: number | null;
minifigCount: number | null;
ean: string | null;
ageRange: string | null;
imageUrl: string | null;
imagePath: string | null;
retailPriceCents: number | null;
notes: string | null;
};
type Props = {
manufacturers: Array<{ id: string; name: string }>;
themes: Array<{ id: string; name: string; manufacturerId: string }>;
set?: SetFormValues;
/** Prefilled EAN coming from the barcode scanner (phase 2). */
defaultEan?: string;
};
export function SetForm({ manufacturers, themes, set, defaultEan }: Props) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [manufacturerId, setManufacturerId] = useState(
set?.manufacturerId ?? manufacturers[0]?.id ?? '',
);
const existingImage = set ? setImageSrc(set) : null;
const availableThemes = themes.filter((theme) => theme.manufacturerId === manufacturerId);
function onSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const formData = new FormData(event.currentTarget);
startTransition(async () => {
if (set) {
const result = await updateSet(set.id, formData);
if (!result.ok) {
toast.error(result.error);
return;
}
toast.success('Set gespeichert.');
router.push(`/katalog/${set.id}`);
router.refresh();
return;
}
const result = await createSet(formData);
if (!result.ok) {
toast.error(result.error);
return;
}
toast.success('Set angelegt.');
router.push(`/katalog/${result.data.id}`);
router.refresh();
});
}
return (
<form onSubmit={onSubmit} className="space-y-6">
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Hersteller" htmlFor="manufacturerId" required>
<NativeSelect
id="manufacturerId"
name="manufacturerId"
value={manufacturerId}
onChange={(event) => setManufacturerId(event.target.value)}
required
>
{manufacturers.map((manufacturer) => (
<option key={manufacturer.id} value={manufacturer.id}>
{manufacturer.name}
</option>
))}
</NativeSelect>
</Field>
<Field label="Thema" htmlFor="themeId" hint="Optional.">
<NativeSelect id="themeId" name="themeId" defaultValue={set?.themeId ?? 'none'}>
<option value="none">– kein Thema –</option>
{availableThemes.map((theme) => (
<option key={theme.id} value={theme.id}>
{theme.name}
</option>
))}
</NativeSelect>
</Field>
<Field label="Setnummer" htmlFor="setNumber" required hint="Die Nummer des Herstellers.">
<Input
id="setNumber"
name="setNumber"
defaultValue={set?.setNumber ?? ''}
required
maxLength={40}
placeholder="60398"
className="font-mono"
/>
</Field>
<Field label="Name" htmlFor="name" required className="sm:col-span-1">
<Input
id="name"
name="name"
defaultValue={set?.name ?? ''}
required
maxLength={160}
placeholder="Familienhaus und Elektroauto"
/>
</Field>
<Field label="Jahr" htmlFor="year">
<Input
id="year"
name="year"
type="number"
inputMode="numeric"
defaultValue={set?.year ?? ''}
min={1930}
max={new Date().getFullYear() + 5}
/>
</Field>
<Field label="Teile" htmlFor="pieceCount">
<Input
id="pieceCount"
name="pieceCount"
type="number"
inputMode="numeric"
defaultValue={set?.pieceCount ?? ''}
min={0}
/>
</Field>
<Field label="Minifiguren" htmlFor="minifigCount">
<Input
id="minifigCount"
name="minifigCount"
type="number"
inputMode="numeric"
defaultValue={set?.minifigCount ?? ''}
min={0}
/>
</Field>
<Field label="UVP" htmlFor="retailPriceCents" hint="In Euro, z. B. 89,99.">
<Input
id="retailPriceCents"
name="retailPriceCents"
inputMode="decimal"
defaultValue={centsToInput(set?.retailPriceCents)}
placeholder="89,99"
/>
</Field>
<Field label="EAN" htmlFor="ean" hint="Der Strichcode auf der Verpackung.">
<Input
id="ean"
name="ean"
inputMode="numeric"
defaultValue={set?.ean ?? defaultEan ?? ''}
maxLength={20}
className="font-mono"
/>
</Field>
<Field label="Alter" htmlFor="ageRange" hint="Zum Beispiel 6-12.">
<Input id="ageRange" name="ageRange" defaultValue={set?.ageRange ?? ''} maxLength={20} />
</Field>
</div>
<Field
label="Bild-URL"
htmlFor="imageUrl"
hint="Wird nur verwendet, wenn kein Bild hochgeladen ist."
>
<Input
id="imageUrl"
name="imageUrl"
type="url"
inputMode="url"
defaultValue={set?.imageUrl ?? ''}
placeholder="https://…"
/>
</Field>
<div className="space-y-2">
<Label htmlFor="image">Bild hochladen</Label>
{existingImage ? (
<div className="flex items-center gap-3">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={existingImage}
alt=""
className="size-20 rounded-lg border border-border object-contain"
/>
{set?.imagePath ? (
<label className="flex items-center gap-2 text-sm">
<Checkbox name="removeImage" value="on" />
Hochgeladenes Bild entfernen
</label>
) : null}
</div>
) : null}
<Input id="image" name="image" type="file" accept="image/jpeg,image/png,image/webp" />
<p className="text-xs text-muted-foreground">
JPEG, PNG oder WebP. Das Bild wird beim Speichern verkleinert und lokal abgelegt.
</p>
</div>
<Field label="Notizen" htmlFor="notes">
<Textarea id="notes" name="notes" defaultValue={set?.notes ?? ''} rows={4} />
</Field>
<div className="flex gap-3">
<Button type="submit" size="lg" disabled={pending}>
{pending ? <Loader2 className="animate-spin" /> : <Save />}
{set ? 'Speichern' : 'Set anlegen'}
</Button>
<Button type="button" variant="outline" size="lg" onClick={() => router.back()}>
Abbrechen
</Button>
</div>
</form>
);
}
+21
View File
@@ -0,0 +1,21 @@
import Link from 'next/link';
import { Blocks } from 'lucide-react';
import { DesktopNav } from './bottom-nav';
import { UserMenu } from './user-menu';
import type { SessionUser } from '@/lib/session';
export function AppHeader({ user }: { user: SessionUser }) {
return (
<header className="sticky top-0 z-40 border-b border-border bg-background/95 backdrop-blur">
<div className="mx-auto flex h-14 max-w-5xl items-center gap-3 px-4">
<Link href="/" className="flex items-center gap-2 font-semibold tracking-tight">
<Blocks className="size-5 text-primary" aria-hidden />
<span>Bricktrack</span>
</Link>
<div className="flex-1" />
<DesktopNav />
<UserMenu user={user} />
</div>
</header>
);
}
+73
View File
@@ -0,0 +1,73 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { Boxes, Gift, LayoutGrid, User } from 'lucide-react';
import { cn } from 'cn';
const items = [
{ href: '/katalog', label: 'Katalog', icon: LayoutGrid },
{ href: '/sammlung', label: 'Sammlung', icon: Boxes },
{ href: '/wunschlisten', label: 'Wünsche', icon: Gift },
{ href: '/konto', label: 'Konto', icon: User },
] as const;
export function BottomNav() {
const pathname = usePathname();
return (
<nav
aria-label="Hauptnavigation"
className="fixed inset-x-0 bottom-0 z-40 border-t border-border bg-background/95 pb-[env(safe-area-inset-bottom)] backdrop-blur md:hidden"
>
<ul className="mx-auto flex max-w-lg">
{items.map((item) => {
const active = pathname === item.href || pathname.startsWith(`${item.href}/`);
return (
<li key={item.href} className="flex-1">
<Link
href={item.href}
aria-current={active ? 'page' : undefined}
className={cn(
'flex h-16 flex-col items-center justify-center gap-1 text-xs transition-colors',
active ? 'text-primary' : 'text-muted-foreground hover:text-foreground',
)}
>
<item.icon className="size-5" aria-hidden />
{item.label}
</Link>
</li>
);
})}
</ul>
</nav>
);
}
export function DesktopNav() {
const pathname = usePathname();
return (
<nav aria-label="Hauptnavigation" className="hidden items-center gap-1 md:flex">
{items.map((item) => {
const active = pathname === item.href || pathname.startsWith(`${item.href}/`);
return (
<Link
key={item.href}
href={item.href}
aria-current={active ? 'page' : undefined}
className={cn(
'flex h-10 items-center gap-2 rounded-lg px-3 text-sm font-medium transition-colors',
active
? 'bg-muted text-foreground'
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground',
)}
>
<item.icon className="size-4" aria-hidden />
{item.label}
</Link>
);
})}
</nav>
);
}
+62
View File
@@ -0,0 +1,62 @@
'use client';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { LogOut, Settings, UserRound } from 'lucide-react';
import { authClient } from '@/lib/auth-client';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import type { SessionUser } from '@/lib/session';
export function UserMenu({ user }: { user: SessionUser }) {
const router = useRouter();
async function signOut() {
await authClient.signOut();
router.push('/anmelden');
router.refresh();
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" aria-label="Konto-Menü">
<UserRound />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel className="font-normal">
<span className="block text-sm font-medium">{user.name}</span>
<span className="block truncate text-xs text-muted-foreground">{user.email}</span>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href="/konto">
<UserRound />
Konto und Passkeys
</Link>
</DropdownMenuItem>
{user.role === 'admin' ? (
<DropdownMenuItem asChild>
<Link href="/verwaltung/hersteller">
<Settings />
Verwaltung
</Link>
</DropdownMenuItem>
) : null}
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => void signOut()}>
<LogOut />
Abmelden
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -0,0 +1,132 @@
'use client';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { useEffect, useState, useTransition } from 'react';
import { Loader2, Search, X } from 'lucide-react';
import { COMPLETENESS_LABELS, STATUS_LABELS } from '@/lib/labels';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { NativeSelect } from '@/components/ui/native-select';
export function CollectionFilters({
locations,
manufacturers,
}: {
locations: Array<{ id: string; name: string }>;
manufacturers: Array<{ id: string; name: string }>;
}) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [pending, startTransition] = useTransition();
const [query, setQuery] = useState(searchParams.get('q') ?? '');
function apply(changes: Record<string, string | null>) {
const params = new URLSearchParams(searchParams.toString());
for (const [key, value] of Object.entries(changes)) {
if (value === null || value === '') params.delete(key);
else params.set(key, value);
}
startTransition(() => router.push(`${pathname}?${params.toString()}`));
}
useEffect(() => {
const current = searchParams.get('q') ?? '';
if (query === current) return;
const timer = setTimeout(() => apply({ q: query || null }), 350);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query]);
const hasFilters = [...searchParams.keys()].length > 0;
return (
<div className="space-y-3">
<div className="relative">
<Search
className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground"
aria-hidden
/>
<Input
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Setnummer oder Name"
aria-label="Sammlung durchsuchen"
className="pl-9"
/>
{pending ? (
<Loader2 className="absolute top-1/2 right-3 size-4 -translate-y-1/2 animate-spin text-muted-foreground" />
) : null}
</div>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
<NativeSelect
aria-label="Status"
value={searchParams.get('status') ?? ''}
onChange={(event) => apply({ status: event.target.value || null })}
>
<option value="">Alle Status</option>
{Object.entries(STATUS_LABELS).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</NativeSelect>
<NativeSelect
aria-label="Vollständigkeit"
value={searchParams.get('zustand') ?? ''}
onChange={(event) => apply({ zustand: event.target.value || null })}
>
<option value="">Alle Vollständigkeiten</option>
{Object.entries(COMPLETENESS_LABELS).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</NativeSelect>
<NativeSelect
aria-label="Standort"
value={searchParams.get('standort') ?? ''}
onChange={(event) => apply({ standort: event.target.value || null })}
>
<option value="">Alle Standorte</option>
{locations.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</NativeSelect>
<NativeSelect
aria-label="Hersteller"
value={searchParams.get('hersteller') ?? ''}
onChange={(event) => apply({ hersteller: event.target.value || null })}
>
<option value="">Alle Hersteller</option>
{manufacturers.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</NativeSelect>
</div>
{hasFilters ? (
<Button
variant="ghost"
size="sm"
onClick={() => {
setQuery('');
startTransition(() => router.push(pathname));
}}
>
<X />
Filter zurücksetzen
</Button>
) : null}
</div>
);
}
@@ -0,0 +1,302 @@
'use client';
import { useRouter } from 'next/navigation';
import { useState, useTransition } from 'react';
import { Loader2, Pencil, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { deleteCollectionItem, updateCollectionItem } from '@/app/(app)/sammlung/actions';
import type { CollectionStatus, Completeness, Condition } from '@/db/types';
import { formatDate, toDateInputValue } from '@/lib/format';
import {
COMPLETENESS_LABELS,
CONDITION_LABELS,
STATUS_LABELS,
completenessLabel,
conditionLabel,
statusLabel,
} from '@/lib/labels';
import { centsToInput, formatEuro } from '@/lib/money';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Field } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { NativeSelect } from '@/components/ui/native-select';
import { Textarea } from '@/components/ui/textarea';
export type CollectionItemRow = {
id: string;
setId: string;
status: CollectionStatus;
completeness: Completeness;
condition: Condition | null;
locationId: string | null;
locationName?: string | null;
acquiredAt: Date | null;
acquiredPriceCents: number | null;
acquiredFrom: string | null;
giftedBy: string | null;
lentOutTo: string | null;
notes: string | null;
};
export function CollectionItemsSection({
items,
locations,
}: {
items: CollectionItemRow[];
locations: Array<{ id: string; name: string }>;
}) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [editing, setEditing] = useState<CollectionItemRow | null>(null);
const [deleting, setDeleting] = useState<CollectionItemRow | null>(null);
function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const item = editing;
if (!item) return;
const input = Object.fromEntries(new FormData(event.currentTarget).entries());
startTransition(async () => {
const result = await updateCollectionItem(item.id, { ...input, setId: item.setId });
if (!result.ok) {
toast.error(result.error);
return;
}
toast.success('Sammlungseintrag gespeichert.');
setEditing(null);
router.refresh();
});
}
function remove() {
const item = deleting;
if (!item) return;
startTransition(async () => {
const result = await deleteCollectionItem(item.id);
if (!result.ok) {
toast.error(result.error);
return;
}
toast.success('Sammlungseintrag entfernt.');
setDeleting(null);
router.refresh();
});
}
if (items.length === 0) {
return (
<p className="text-sm text-muted-foreground">
Dieses Set ist noch nicht in der Sammlung.
</p>
);
}
return (
<div className="space-y-3">
<ul className="divide-y divide-border rounded-lg border border-border">
{items.map((item) => (
<li key={item.id} className="flex items-start gap-3 p-3">
<div className="min-w-0 flex-1 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<Badge variant={item.status === 'sold' ? 'outline' : 'default'}>
{statusLabel(item.status)}
</Badge>
<Badge variant="secondary">{completenessLabel(item.completeness)}</Badge>
{item.locationName ? (
<span className="text-xs text-muted-foreground">{item.locationName}</span>
) : null}
</div>
<p className="text-xs text-muted-foreground">
{[
item.acquiredAt ? `Seit ${formatDate(item.acquiredAt)}` : null,
item.acquiredPriceCents !== null
? `Gekauft für ${formatEuro(item.acquiredPriceCents)}`
: null,
item.giftedBy ? `Geschenk von ${item.giftedBy}` : null,
item.lentOutTo ? `Verliehen an ${item.lentOutTo}` : null,
item.condition ? conditionLabel(item.condition) : null,
]
.filter(Boolean)
.join(' · ') || 'Keine weiteren Angaben'}
</p>
{item.notes ? <p className="text-xs">{item.notes}</p> : null}
</div>
<Button
variant="ghost"
size="icon-sm"
aria-label="Bearbeiten"
onClick={() => setEditing(item)}
disabled={pending}
>
<Pencil />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label="Entfernen"
onClick={() => setDeleting(item)}
disabled={pending}
>
<Trash2 className="text-destructive" />
</Button>
</li>
))}
</ul>
<Dialog open={editing !== null} onOpenChange={(open) => !open && setEditing(null)}>
<DialogContent className="max-h-[85dvh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Sammlungseintrag bearbeiten</DialogTitle>
</DialogHeader>
<form id="collection-item-form" onSubmit={submit} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Status" htmlFor="status">
<NativeSelect id="status" name="status" defaultValue={editing?.status ?? 'owned'}>
{Object.entries(STATUS_LABELS).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</NativeSelect>
</Field>
<Field label="Vollständigkeit" htmlFor="completeness">
<NativeSelect
id="completeness"
name="completeness"
defaultValue={editing?.completeness ?? 'unknown'}
>
{Object.entries(COMPLETENESS_LABELS).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</NativeSelect>
</Field>
<Field label="Zustand" htmlFor="condition">
<NativeSelect id="condition" name="condition" defaultValue={editing?.condition ?? 'none'}>
<option value="none">– keine Angabe –</option>
{Object.entries(CONDITION_LABELS).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</NativeSelect>
</Field>
<Field label="Standort" htmlFor="locationId">
<NativeSelect
id="locationId"
name="locationId"
defaultValue={editing?.locationId ?? 'none'}
>
<option value="none">– kein Standort –</option>
{locations.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</NativeSelect>
</Field>
<Field label="Im Besitz seit" htmlFor="acquiredAt">
<Input
id="acquiredAt"
name="acquiredAt"
type="date"
defaultValue={toDateInputValue(editing?.acquiredAt)}
/>
</Field>
<Field label="Kaufpreis" htmlFor="acquiredPriceCents" hint="In Euro.">
<Input
id="acquiredPriceCents"
name="acquiredPriceCents"
inputMode="decimal"
defaultValue={centsToInput(editing?.acquiredPriceCents)}
/>
</Field>
<Field label="Gekauft bei" htmlFor="acquiredFrom">
<Input
id="acquiredFrom"
name="acquiredFrom"
defaultValue={editing?.acquiredFrom ?? ''}
maxLength={120}
/>
</Field>
<Field label="Geschenkt von" htmlFor="giftedBy">
<Input
id="giftedBy"
name="giftedBy"
defaultValue={editing?.giftedBy ?? ''}
maxLength={120}
/>
</Field>
<Field label="Verliehen an" htmlFor="lentOutTo">
<Input
id="lentOutTo"
name="lentOutTo"
defaultValue={editing?.lentOutTo ?? ''}
maxLength={120}
/>
</Field>
</div>
<Field label="Notizen" htmlFor="item-notes">
<Textarea id="item-notes" name="notes" defaultValue={editing?.notes ?? ''} rows={3} />
</Field>
</form>
<DialogFooter>
<Button variant="outline" onClick={() => setEditing(null)} disabled={pending}>
Abbrechen
</Button>
<Button type="submit" form="collection-item-form" disabled={pending}>
{pending ? <Loader2 className="animate-spin" /> : null}
Speichern
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog open={deleting !== null} onOpenChange={(open) => !open && setDeleting(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Eintrag entfernen?</AlertDialogTitle>
<AlertDialogDescription>
Das Set bleibt im Katalog, gilt danach aber wieder als „fehlt noch“.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Abbrechen</AlertDialogCancel>
<AlertDialogAction onClick={remove}>Entfernen</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
+66 -66
View File
@@ -1,66 +1,66 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"
import { Slot } from "radix-ui"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"
import { Slot } from "radix-ui"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-10 gap-1.5 px-3.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-9 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-11 gap-2 px-5 text-base has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-10",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-9 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-11",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
+36
View File
@@ -0,0 +1,36 @@
import { cn } from 'cn';
import { Label } from './label';
/** Label + control + optional hint, the layout every form in the app uses. */
export function Field({
label,
htmlFor,
hint,
required,
className,
children,
}: {
label: string;
htmlFor: string;
hint?: string;
required?: boolean;
className?: string;
children: React.ReactNode;
}) {
return (
<div className={cn('space-y-2', className)}>
<Label htmlFor={htmlFor}>
{label}
{/* aria-hidden keeps the accessible name clean ("Name", not "Name *");
the required state is already carried by the input itself. */}
{required ? (
<span aria-hidden className="text-destructive">
{' *'}
</span>
) : null}
</Label>
{children}
{hint ? <p className="text-xs text-muted-foreground">{hint}</p> : null}
</div>
);
}
+18 -18
View File
@@ -1,18 +1,18 @@
import * as React from "react"
import { cn } from "cn"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }
import * as React from "react"
import { cn } from "cn"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"h-11 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }
+23
View File
@@ -0,0 +1,23 @@
import { cn } from 'cn';
/**
* A plain <select>.
*
* Deliberately not the Radix select: this app is used on phones, where the
* native picker is faster to operate, and the value lands in FormData without
* a hidden mirror input.
*/
export function NativeSelect({ className, ...props }: React.ComponentProps<'select'>) {
return (
<select
data-slot="native-select"
className={cn(
'h-11 w-full rounded-lg border border-input bg-transparent px-2.5 text-base outline-none transition-colors',
'focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50',
'disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30',
className,
)}
{...props}
/>
);
}
+191 -191
View File
@@ -1,191 +1,191 @@
"use client"
import * as React from "react"
import { cn } from "cn"
import { Select as SelectPrimitive } from "radix-ui"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
data-align-trigger={position === "item-aligned"}
className={cn("relative z-50 max-h-(--radix-select-content-available-height) min-w-36 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", position ==="popper"&&"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", className )}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
data-position={position}
className={cn(
"data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)",
position === "popper" && ""
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronUpIcon
/>
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronDownIcon
/>
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
"use client"
import * as React from "react"
import { cn } from "cn"
import { Select as SelectPrimitive } from "radix-ui"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-11 data-[size=sm]:h-9 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
data-align-trigger={position === "item-aligned"}
className={cn("relative z-50 max-h-(--radix-select-content-available-height) min-w-36 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", position ==="popper"&&"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", className )}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
data-position={position}
className={cn(
"data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)",
position === "popper" && ""
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronUpIcon
/>
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronDownIcon
/>
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
+211
View File
@@ -0,0 +1,211 @@
'use client';
import { useRouter } from 'next/navigation';
import { useState, useTransition } from 'react';
import { Loader2, Pencil, Plus, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import type { ActionResult } from '@/lib/action-result';
import { Button } from '@/components/ui/button';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
export type CrudItem = {
id: string;
title: string;
subtitle?: string | null;
};
type Props<T extends CrudItem> = {
items: T[];
emptyText: string;
createLabel: string;
dialogTitle: (item: T | null) => string;
dialogDescription?: string;
/** Form fields for the create/edit dialog. `item` is null when creating. */
renderFields: (item: T | null) => React.ReactNode;
onCreate: (input: Record<string, unknown>) => Promise<ActionResult>;
onUpdate: (id: string, input: Record<string, unknown>) => Promise<ActionResult>;
onDelete: (id: string) => Promise<ActionResult>;
deleteWarning: (item: T) => string;
};
/**
* Shared list + create/edit/delete UI for the small master-data tables
* (manufacturers, themes, locations). They differ only in their form fields.
*/
export function CrudSection<T extends CrudItem>({
items,
emptyText,
createLabel,
dialogTitle,
dialogDescription,
renderFields,
onCreate,
onUpdate,
onDelete,
deleteWarning,
}: Props<T>) {
const router = useRouter();
const [editing, setEditing] = useState<T | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [deleting, setDeleting] = useState<T | null>(null);
const [pending, startTransition] = useTransition();
function openCreate() {
setEditing(null);
setDialogOpen(true);
}
function openEdit(item: T) {
setEditing(item);
setDialogOpen(true);
}
function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const input = Object.fromEntries(formData.entries());
const current = editing;
startTransition(async () => {
const result = current ? await onUpdate(current.id, input) : await onCreate(input);
if (!result.ok) {
toast.error(result.error);
return;
}
toast.success(current ? 'Gespeichert.' : 'Angelegt.');
setDialogOpen(false);
setEditing(null);
router.refresh();
});
}
function confirmDelete() {
const item = deleting;
if (!item) return;
startTransition(async () => {
const result = await onDelete(item.id);
if (!result.ok) {
toast.error(result.error);
setDeleting(null);
return;
}
toast.success('Gelöscht.');
setDeleting(null);
router.refresh();
});
}
return (
<div className="space-y-4">
<Button size="lg" className="w-full sm:w-auto" onClick={openCreate}>
<Plus />
{createLabel}
</Button>
{items.length === 0 ? (
<p className="rounded-lg border border-dashed border-border p-6 text-center text-sm text-muted-foreground">
{emptyText}
</p>
) : (
<ul className="divide-y divide-border rounded-lg border border-border">
{items.map((item) => (
<li key={item.id} className="flex items-center gap-2 p-3">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{item.title}</p>
{item.subtitle ? (
<p className="truncate text-xs text-muted-foreground">{item.subtitle}</p>
) : null}
</div>
<Button
variant="ghost"
size="icon-sm"
aria-label={`${item.title} bearbeiten`}
onClick={() => openEdit(item)}
disabled={pending}
>
<Pencil />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={`${item.title} löschen`}
onClick={() => setDeleting(item)}
disabled={pending}
>
<Trash2 className="text-destructive" />
</Button>
</li>
))}
</ul>
)}
<Dialog
open={dialogOpen}
onOpenChange={(open) => {
setDialogOpen(open);
if (!open) setEditing(null);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{dialogTitle(editing)}</DialogTitle>
{dialogDescription ? (
<DialogDescription>{dialogDescription}</DialogDescription>
) : null}
</DialogHeader>
{/* Remounting on change resets defaultValue-based fields. */}
<form
key={editing?.id ?? 'new'}
id="crud-form"
onSubmit={submit}
className="space-y-4"
>
{renderFields(editing)}
</form>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)} disabled={pending}>
Abbrechen
</Button>
<Button type="submit" form="crud-form" disabled={pending}>
{pending ? <Loader2 className="animate-spin" /> : null}
Speichern
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog open={deleting !== null} onOpenChange={(open) => !open && setDeleting(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Wirklich löschen?</AlertDialogTitle>
<AlertDialogDescription>
{deleting ? deleteWarning(deleting) : ''}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Abbrechen</AlertDialogCancel>
<AlertDialogAction onClick={confirmDelete}>Löschen</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -0,0 +1,62 @@
'use client';
import { createLocation, deleteLocation, updateLocation } from '@/app/(app)/verwaltung/actions';
import { CrudSection, type CrudItem } from './crud-section';
import { Field } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { NativeSelect } from '@/components/ui/native-select';
export type LocationRow = CrudItem & {
parentLocationId: string | null;
};
export function LocationList({ locations }: { locations: LocationRow[] }) {
return (
<CrudSection<LocationRow>
items={locations}
emptyText="Noch kein Standort angelegt – zum Beispiel „Regal Kinderzimmer“ oder „Kiste Keller 3“."
createLabel="Standort anlegen"
dialogTitle={(item) => (item ? 'Standort bearbeiten' : 'Neuer Standort')}
onCreate={createLocation}
onUpdate={updateLocation}
onDelete={deleteLocation}
deleteWarning={(item) =>
`„${item.title}“ wird gelöscht. Sets an diesem Standort bleiben erhalten und haben danach keinen Standort mehr.`
}
renderFields={(item) => (
<>
<Field label="Name" htmlFor="name" required>
<Input
id="name"
name="name"
defaultValue={item?.title ?? ''}
required
maxLength={80}
placeholder="Regal Kinderzimmer"
/>
</Field>
<Field
label="Liegt in"
htmlFor="parentLocationId"
hint="Optional, für Schachtelungen wie „Keller → Kiste 3“."
>
<NativeSelect
id="parentLocationId"
name="parentLocationId"
defaultValue={item?.parentLocationId ?? 'none'}
>
<option value="none">– kein übergeordneter Standort –</option>
{locations
.filter((candidate) => candidate.id !== item?.id)
.map((candidate) => (
<option key={candidate.id} value={candidate.id}>
{candidate.title}
</option>
))}
</NativeSelect>
</Field>
</>
)}
/>
);
}
@@ -0,0 +1,54 @@
'use client';
import {
createManufacturer,
deleteManufacturer,
updateManufacturer,
} from '@/app/(app)/verwaltung/actions';
import { CrudSection, type CrudItem } from './crud-section';
import { Field } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
export type ManufacturerRow = CrudItem & {
website: string | null;
setCount: number;
};
export function ManufacturerList({ manufacturers }: { manufacturers: ManufacturerRow[] }) {
return (
<CrudSection<ManufacturerRow>
items={manufacturers}
emptyText="Noch kein Hersteller angelegt. Lego, Playmobil, Cobi – leg einfach los."
createLabel="Hersteller anlegen"
dialogTitle={(item) => (item ? 'Hersteller bearbeiten' : 'Neuer Hersteller')}
dialogDescription="Der Name erscheint als Badge auf jeder Set-Kachel."
onCreate={createManufacturer}
onUpdate={updateManufacturer}
onDelete={deleteManufacturer}
deleteWarning={(item) =>
`„${item.title}“ wird gelöscht. Das geht nur, solange kein Set diesen Hersteller nutzt.`
}
renderFields={(item) => (
<>
<Field label="Name" htmlFor="name" required>
<Input id="name" name="name" defaultValue={item?.title ?? ''} required maxLength={80} />
</Field>
<Field
label="Website"
htmlFor="website"
hint="Optional. Wird auf der öffentlichen Wunschliste als Link zum Shop angeboten."
>
<Input
id="website"
name="website"
type="url"
inputMode="url"
defaultValue={item?.website ?? ''}
placeholder="https://www.lego.com"
/>
</Field>
</>
)}
/>
);
}
+76
View File
@@ -0,0 +1,76 @@
'use client';
import { createTheme, deleteTheme, updateTheme } from '@/app/(app)/verwaltung/actions';
import { CrudSection, type CrudItem } from './crud-section';
import { Field } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { NativeSelect } from '@/components/ui/native-select';
export type ThemeRow = CrudItem & {
manufacturerId: string;
parentThemeId: string | null;
};
export function ThemeList({
themes,
manufacturers,
}: {
themes: ThemeRow[];
manufacturers: Array<{ id: string; name: string }>;
}) {
return (
<CrudSection<ThemeRow>
items={themes}
emptyText="Noch kein Thema angelegt. Themen sind Serien wie „City“, „Technic“ oder „Novelmore“."
createLabel="Thema anlegen"
dialogTitle={(item) => (item ? 'Thema bearbeiten' : 'Neues Thema')}
onCreate={createTheme}
onUpdate={updateTheme}
onDelete={deleteTheme}
deleteWarning={(item) =>
`„${item.title}“ wird gelöscht. Das geht nur, solange dem Thema kein Set zugeordnet ist.`
}
renderFields={(item) => (
<>
<Field label="Hersteller" htmlFor="manufacturerId" required>
<NativeSelect
id="manufacturerId"
name="manufacturerId"
defaultValue={item?.manufacturerId ?? manufacturers[0]?.id ?? ''}
required
>
{manufacturers.map((manufacturer) => (
<option key={manufacturer.id} value={manufacturer.id}>
{manufacturer.name}
</option>
))}
</NativeSelect>
</Field>
<Field label="Name" htmlFor="name" required>
<Input id="name" name="name" defaultValue={item?.title ?? ''} required maxLength={80} />
</Field>
<Field
label="Oberthema"
htmlFor="parentThemeId"
hint="Optional, für Unterthemen wie „City → Feuerwehr“."
>
<NativeSelect
id="parentThemeId"
name="parentThemeId"
defaultValue={item?.parentThemeId ?? 'none'}
>
<option value="none">– kein Oberthema –</option>
{themes
.filter((candidate) => candidate.id !== item?.id)
.map((candidate) => (
<option key={candidate.id} value={candidate.id}>
{candidate.title}
</option>
))}
</NativeSelect>
</Field>
</>
)}
/>
);
}

Some files were not shown because too many files have changed in this diff Show More