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>
@@ -0,0 +1,20 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.git
|
||||||
|
.github
|
||||||
|
data
|
||||||
|
data-e2e
|
||||||
|
test-results
|
||||||
|
playwright-report
|
||||||
|
blob-report
|
||||||
|
coverage
|
||||||
|
.env
|
||||||
|
.env.*.local
|
||||||
|
*.log
|
||||||
|
docs
|
||||||
|
README.md
|
||||||
|
QUICKSTART.md
|
||||||
|
CHANGELOG.md
|
||||||
|
CLAUDE.md
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
@@ -21,6 +21,12 @@ MAX_UPLOAD_MB=25
|
|||||||
# Zeitzone des Containers.
|
# Zeitzone des Containers.
|
||||||
TZ=Europe/Berlin
|
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.
|
# Optional (ab Phase 2): Rebrickable-API-Key fuer den Lego-Import.
|
||||||
# Ohne Key funktioniert alles manuell weiter.
|
# Ohne Key funktioniert alles manuell weiter.
|
||||||
# REBRICKABLE_API_KEY=
|
# REBRICKABLE_API_KEY=
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Normalise line endings so files written on Windows still work in the
|
||||||
|
# Linux container. A shell script with CRLF fails with the confusing
|
||||||
|
# "no such file or directory" on its shebang line.
|
||||||
|
* text=auto eol=lf
|
||||||
|
|
||||||
|
*.sh text eol=lf
|
||||||
|
Dockerfile text eol=lf
|
||||||
|
*.yml text eol=lf
|
||||||
|
*.yaml text eol=lf
|
||||||
|
|
||||||
|
# Binary files must never be touched.
|
||||||
|
*.png binary
|
||||||
|
*.jpg binary
|
||||||
|
*.jpeg binary
|
||||||
|
*.webp binary
|
||||||
|
*.pdf binary
|
||||||
|
*.ico binary
|
||||||
@@ -19,6 +19,10 @@ next-env.d.ts
|
|||||||
|
|
||||||
# runtime data (database, uploads) - never in git, this is the backup target
|
# runtime data (database, uploads) - never in git, this is the backup target
|
||||||
/data
|
/data
|
||||||
|
/data-e2e
|
||||||
|
/data-shots
|
||||||
|
/data-docker
|
||||||
|
/backups
|
||||||
|
|
||||||
# production
|
# production
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|||||||
@@ -7,14 +7,86 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.1.0] - 2026-09-07
|
||||||
|
|
||||||
|
Erste lauffaehige Fassung: Phase 1 vollstaendig, im Docker-Stack verifiziert.
|
||||||
|
|
||||||
### Hinzugefuegt
|
### Hinzugefuegt
|
||||||
|
|
||||||
- Projekt-Scaffold: Next.js 15 (App Router, TypeScript strict),
|
**Anmeldung**
|
||||||
Tailwind CSS 4, shadcn/ui, pnpm.
|
- Registrierung des ersten Nutzers, der damit Administrator wird; danach ist
|
||||||
- Vollstaendiges Datenmodell in Drizzle ORM mit initialer Migration:
|
die Registrierung geschlossen.
|
||||||
Katalog, Besitz, Wunschlisten und die Auth-Tabellen von better-auth.
|
- Anmeldung per E-Mail und Passwort oder per Passkey (WebAuthn), inklusive
|
||||||
- SQLite-Anbindung mit WAL-Modus, aktivierten Fremdschluesseln und
|
Conditional UI, wo der Browser sie unterstuetzt.
|
||||||
Migrations-Runner (`pnpm db:migrate`).
|
- Passkey-Verwaltung unter "Konto": beliebig viele Geraete, umbenennen,
|
||||||
- Projektdokumentation: CLAUDE.md, Roadmap, Changelog, Devlog.
|
loeschen.
|
||||||
|
- Rate-Limiting auf den Login-Endpunkten, in der Datenbank abgelegt und damit
|
||||||
|
neustartfest.
|
||||||
|
|
||||||
[Unreleased]: https://git.wilde-server.de/florian.wilde/bricktrack/compare/main...HEAD
|
**Katalog**
|
||||||
|
- Set-Katalog mit Kachelansicht, Suche ueber Setnummer, Name und EAN sowie
|
||||||
|
Filtern nach Hersteller, Thema, Jahr und Besitzstatus.
|
||||||
|
- Sets anlegen und bearbeiten, mit Bild-Upload oder Bild-URL.
|
||||||
|
- Set-Detailseite mit Metadaten, Anleitungen, Fotos und Sammlungseintraegen.
|
||||||
|
- Anleitungen als PDF-Upload mit eingebautem pdf.js-Viewer oder als externer
|
||||||
|
Link; mehrere Anleitungen je Set.
|
||||||
|
- Eigene Fotos je Set, am Handy direkt aus der Kamera.
|
||||||
|
|
||||||
|
**Sammlung**
|
||||||
|
- Uebersicht aller besessenen Sets mit Filtern nach Status, Vollstaendigkeit,
|
||||||
|
Standort und Hersteller.
|
||||||
|
- Statistik-Kacheln: Anzahl Sets, Teile gesamt, Wert nach UVP, fehlende Sets,
|
||||||
|
sowie die Aufteilung nach Hersteller.
|
||||||
|
- Sammlungseintraege mit Status, Zustand, Standort, Kaufdatum, Kaufpreis und
|
||||||
|
"geschenkt von".
|
||||||
|
|
||||||
|
**Wunschlisten**
|
||||||
|
- Ansicht "Fehlende Sets" mit Mehrfachauswahl: langes Druecken startet die
|
||||||
|
Auswahl, die Aktionsleiste uebernimmt sie in eine bestehende oder neue
|
||||||
|
Wunschliste. Dubletten und bereits besessene Sets werden uebersprungen und
|
||||||
|
im Ergebnis benannt.
|
||||||
|
- Wunschlisten mit Prioritaet, Reihenfolge, Anlass, Termin und Hinweistext.
|
||||||
|
- Bereich "Aus fehlenden Sets hinzufuegen" auf der Wunschlisten-Seite,
|
||||||
|
vorgefiltert auf Sets, die noch nicht drauf sind.
|
||||||
|
- Oeffentliche Ansicht unter /w/[shareToken] ohne Anmeldung; Besucher
|
||||||
|
reservieren mit ihrem Namen und erhalten einen Storno-Link.
|
||||||
|
- Ueberraschungsmodus: der Besitzer sieht nur "reserviert", nicht von wem.
|
||||||
|
- Erfuellte Wuensche werden automatisch markiert, sobald das Set in der
|
||||||
|
Sammlung landet, und verschwinden aus der oeffentlichen Ansicht.
|
||||||
|
- Share-Token laesst sich neu erzeugen; der alte Link ist danach tot.
|
||||||
|
|
||||||
|
**Stammdaten**
|
||||||
|
- Hersteller, Themen (mit Unterthemen) und Standorte (schachtelbar) verwalten.
|
||||||
|
|
||||||
|
**Betrieb**
|
||||||
|
- Docker-Stack: Multi-Stage-Build mit standalone-Output, non-root Nutzer,
|
||||||
|
Healthcheck unter /api/health, ein Service und ein Bind-Mount auf ./data.
|
||||||
|
- Migrationen laufen bei jedem Containerstart automatisch.
|
||||||
|
- update.sh mit Sicherung, Build, Healthcheck und automatischem Rollback.
|
||||||
|
- Seed-Skript mit 3 Herstellern, 6 Themen, 10 Sets, 4 Sammlungseintraegen und
|
||||||
|
einer Wunschliste.
|
||||||
|
- Dokumentation: Schnellstart inklusive Nginx-Proxy-Manager-Konfiguration,
|
||||||
|
bebilderte Anleitung, README, Roadmap und Devlog.
|
||||||
|
|
||||||
|
### Sicherheit
|
||||||
|
|
||||||
|
- Serverseitige Validierung aller Eingaben mit zod.
|
||||||
|
- Uploads auf PDF, JPEG, PNG und WebP begrenzt, geprueft an den Magic Bytes
|
||||||
|
statt am gemeldeten MIME-Typ; Dateinamen erzeugt immer der Server.
|
||||||
|
- Oeffentliche Wunschlisten-Routen nutzen ausschliesslich den Share-Token und
|
||||||
|
geben nichts ausserhalb der Wunschliste preis; die Seite ist noindex.
|
||||||
|
- Storno- und Einladungs-Token liegen nur als SHA-256-Hash in der Datenbank.
|
||||||
|
- Session-Cookies httpOnly, sameSite=lax und im Betrieb secure.
|
||||||
|
- Anleitungs-PDFs und eigene Fotos sind nur mit Session abrufbar; oeffentlich
|
||||||
|
sind allein die Set-Bilder, die die geteilte Wunschliste braucht.
|
||||||
|
|
||||||
|
### Bekannte Einschraenkungen
|
||||||
|
|
||||||
|
- Der Katalog enthaelt nur, was von Hand angelegt wurde. Der Import ueber
|
||||||
|
Rebrickable kommt in 0.2.0.
|
||||||
|
- Weitere Konten sind noch nicht moeglich; Einladungslinks kommen in 0.2.0.
|
||||||
|
- Unter Windows scheitert `pnpm build` an fehlenden Symlink-Rechten. Der
|
||||||
|
Docker-Build unter Linux ist davon nicht betroffen.
|
||||||
|
|
||||||
|
[Unreleased]: https://git.wilde-server.de/florian.wilde/bricktrack/compare/v0.1.0...HEAD
|
||||||
|
[0.1.0]: https://git.wilde-server.de/florian.wilde/bricktrack/releases/tag/v0.1.0
|
||||||
|
|||||||
@@ -33,9 +33,9 @@ Bild-URLs, die der Nutzer selbst hinterlegt hat.
|
|||||||
(`retail_price_cents`, `acquired_price_cents`). SQLite hat keinen
|
(`retail_price_cents`, `acquired_price_cents`). SQLite hat keinen
|
||||||
Decimal-Typ; Float-Summen waeren ungenau. Umrechnung ausschliesslich
|
Decimal-Typ; Float-Summen waeren ungenau. Umrechnung ausschliesslich
|
||||||
in `src/lib/money.ts`.
|
in `src/lib/money.ts`.
|
||||||
- Datenbankspalten in `snake_case` - **ausser** in den Auth-Tabellen,
|
- Alle Datenbankspalten in `snake_case`. Auch in den Auth-Tabellen -
|
||||||
die `camelCase` verwenden, weil das exakt der Ausgabe von
|
der Drizzle-Adapter von better-auth loest Modelle ueber die
|
||||||
`@better-auth/cli generate` entspricht.
|
TypeScript-Property-Namen auf, nicht ueber die Spaltennamen.
|
||||||
- IDs sind Text-UUIDs (`crypto.randomUUID()`), Zeitstempel sind
|
- IDs sind Text-UUIDs (`crypto.randomUUID()`), Zeitstempel sind
|
||||||
`integer(mode: 'timestamp')`.
|
`integer(mode: 'timestamp')`.
|
||||||
- Routen sind deutsch (`/katalog`, `/sammlung`, `/wunschlisten`),
|
- Routen sind deutsch (`/katalog`, `/sammlung`, `/wunschlisten`),
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Bricktrack - Multi-Stage-Build
|
||||||
|
#
|
||||||
|
# Ergebnis ist ein schlankes Runtime-Image mit Next.js im Standalone-Modus,
|
||||||
|
# das als Nutzer "node" laeuft. Alle Daten liegen ausserhalb des Images unter
|
||||||
|
# /data, damit ein Update nichts anfasst, was dir gehoert.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
FROM node:24-alpine AS base
|
||||||
|
ENV PNPM_HOME=/pnpm
|
||||||
|
ENV PATH=$PNPM_HOME:$PATH
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
RUN corepack enable
|
||||||
|
|
||||||
|
# --- Abhaengigkeiten -------------------------------------------------------
|
||||||
|
# better-sqlite3 und sharp bringen Prebuilds mit; die Build-Werkzeuge sind der
|
||||||
|
# Rueckfallweg, falls fuer die Plattform keines existiert.
|
||||||
|
FROM base AS deps
|
||||||
|
RUN apk add --no-cache libc6-compat python3 make g++
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json pnpm-lock.yaml ./
|
||||||
|
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# --- Build -----------------------------------------------------------------
|
||||||
|
FROM base AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Platzhalter nur fuer den Build: src/env.ts validiert beim Import, und die
|
||||||
|
# echten Werte kommen zur Laufzeit aus der .env. Keiner dieser Werte landet im
|
||||||
|
# Image-Zustand, weil keine Seite vorgerendert wird.
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV BASE_URL=http://localhost:3000
|
||||||
|
ENV BETTER_AUTH_SECRET=build-platzhalter-0123456789-abcdefghij
|
||||||
|
ENV DATA_DIR=/tmp/build-data
|
||||||
|
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
|
# Der Migrations-Runner wird zu einer einzelnen Datei gebuendelt. So braucht
|
||||||
|
# das Runtime-Image kein drizzle-kit und kein tsx - nur das native
|
||||||
|
# better-sqlite3, das ohnehin dabei ist. Das Buendel ist CommonJS, weil
|
||||||
|
# mitgebuendelte CJS-Abhaengigkeiten sonst am require() der Node-Builtins
|
||||||
|
# scheitern.
|
||||||
|
RUN pnpm exec esbuild scripts/migrate.ts \
|
||||||
|
--bundle \
|
||||||
|
--platform=node \
|
||||||
|
--format=cjs \
|
||||||
|
--target=node22 \
|
||||||
|
--external:better-sqlite3 \
|
||||||
|
--outfile=dist/migrate.cjs
|
||||||
|
|
||||||
|
# --- Laufzeit --------------------------------------------------------------
|
||||||
|
FROM base AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV HOSTNAME=0.0.0.0
|
||||||
|
ENV DATA_DIR=/data
|
||||||
|
ENV MIGRATIONS_DIR=/app/drizzle
|
||||||
|
|
||||||
|
# Standalone bringt seine eigenen node_modules mit - kein pnpm install noetig.
|
||||||
|
COPY --from=builder --chown=node:node /app/.next/standalone ./
|
||||||
|
COPY --from=builder --chown=node:node /app/.next/static ./.next/static
|
||||||
|
COPY --from=builder --chown=node:node /app/public ./public
|
||||||
|
COPY --from=builder --chown=node:node /app/drizzle ./drizzle
|
||||||
|
COPY --from=builder --chown=node:node /app/dist/migrate.cjs ./migrate.cjs
|
||||||
|
COPY --chown=node:node docker/entrypoint.sh /app/entrypoint.sh
|
||||||
|
RUN chmod +x /app/entrypoint.sh
|
||||||
|
|
||||||
|
# Anlaufstelle fuer Datenbank und Uploads. Wird per Bind-Mount ueberdeckt;
|
||||||
|
# das Anlegen hier sorgt nur dafuer, dass der Pfad auch ohne Mount existiert.
|
||||||
|
RUN mkdir -p /data/db /data/files && chown -R node:node /data
|
||||||
|
|
||||||
|
USER node
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||||
|
CMD wget -q --spider http://127.0.0.1:3000/api/health || exit 1
|
||||||
|
|
||||||
|
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||||
|
CMD ["node", "server.js"]
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
# Bricktrack – Schnellstart
|
||||||
|
|
||||||
|
Von null auf laufende Instanz. Zeitbedarf: etwa 15 Minuten, davon 10 für den
|
||||||
|
ersten Docker-Build.
|
||||||
|
|
||||||
|
**Voraussetzungen:** Ein Linux-Host (z. B. eine LXC oder VM auf Proxmox) mit
|
||||||
|
Docker und dem Compose-Plugin, ein Reverse Proxy mit gültigem TLS-Zertifikat
|
||||||
|
und eine Subdomain, die auf den Proxy zeigt.
|
||||||
|
|
||||||
|
> **Warum HTTPS Pflicht ist:** Passkeys (WebAuthn) funktionieren
|
||||||
|
> ausschließlich über HTTPS und sind fest an den Hostnamen gebunden. Ohne
|
||||||
|
> Zertifikat bleibt nur die Anmeldung per Passwort. Wird die Domain später
|
||||||
|
> geändert, sind **alle** Passkeys ungültig und müssen neu angelegt werden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Projekt holen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo mkdir -p /opt/bricktrack
|
||||||
|
sudo chown "$USER" /opt/bricktrack
|
||||||
|
git clone https://git.wilde-server.de/florian.wilde/bricktrack.git /opt/bricktrack
|
||||||
|
cd /opt/bricktrack
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Konfiguration anlegen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Jetzt `.env` öffnen und zwei Werte setzen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Die Adresse, unter der du die App im Browser aufrufst.
|
||||||
|
BASE_URL=https://bricktrack.example.de
|
||||||
|
|
||||||
|
# Signiergeheimnis - hiermit erzeugen:
|
||||||
|
# openssl rand -base64 48
|
||||||
|
BETTER_AUTH_SECRET=<dein-erzeugter-wert>
|
||||||
|
```
|
||||||
|
|
||||||
|
Alles andere kann so bleiben. `DATA_DIR=/data` ist der Pfad **im Container**
|
||||||
|
und wird nicht angefasst.
|
||||||
|
|
||||||
|
## 3. Datenverzeichnis vorbereiten
|
||||||
|
|
||||||
|
Der Container läuft aus Sicherheitsgründen nicht als root, sondern als Nutzer
|
||||||
|
mit der UID 1000. Das gemountete Verzeichnis muss ihm gehören:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ./data
|
||||||
|
sudo chown -R 1000:1000 ./data
|
||||||
|
```
|
||||||
|
|
||||||
|
Diesen Schritt vergisst man gern – der Container sagt dir dann aber beim Start
|
||||||
|
genau das.
|
||||||
|
|
||||||
|
## 4. Starten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Der erste Build dauert einige Minuten. Danach:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose ps # Status "healthy" abwarten
|
||||||
|
docker compose logs -f app # Log mitlesen, mit Strg+C beenden
|
||||||
|
curl -s http://127.0.0.1:3000/api/health
|
||||||
|
# {"status":"ok","time":"..."}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Reverse Proxy einrichten (Nginx Proxy Manager)
|
||||||
|
|
||||||
|
In NPM unter **Hosts → Proxy Hosts → Add Proxy Host**:
|
||||||
|
|
||||||
|
**Reiter „Details"**
|
||||||
|
|
||||||
|
| Feld | Wert |
|
||||||
|
|---|---|
|
||||||
|
| Domain Names | `bricktrack.example.de` |
|
||||||
|
| Scheme | `http` |
|
||||||
|
| Forward Hostname / IP | IP des Docker-Hosts, z. B. `192.168.1.50` |
|
||||||
|
| Forward Port | `3000` |
|
||||||
|
| Cache Assets | **aus** (Next.js setzt seine Cache-Header selbst) |
|
||||||
|
| Block Common Exploits | an |
|
||||||
|
| Websockets Support | **an** |
|
||||||
|
|
||||||
|
**Reiter „SSL"**
|
||||||
|
|
||||||
|
| Feld | Wert |
|
||||||
|
|---|---|
|
||||||
|
| SSL Certificate | Let's Encrypt anfordern oder vorhandenes wählen |
|
||||||
|
| Force SSL | **an** |
|
||||||
|
| HTTP/2 Support | an |
|
||||||
|
| HSTS Enabled | an (optional, aber sinnvoll) |
|
||||||
|
|
||||||
|
**Reiter „Advanced"** – hier hinein, sonst scheitern große Anleitungs-PDFs am
|
||||||
|
Standardlimit von 1 MB:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
# Anleitungs-PDFs koennen mehrere Megabyte gross sein.
|
||||||
|
client_max_body_size 64m;
|
||||||
|
|
||||||
|
# Uploads und PDF-Auslieferung duerfen etwas dauern.
|
||||||
|
proxy_read_timeout 300;
|
||||||
|
proxy_send_timeout 300;
|
||||||
|
|
||||||
|
# Die App leitet ihre oeffentliche Adresse und die Client-IP hieraus ab.
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
```
|
||||||
|
|
||||||
|
`client_max_body_size` muss mindestens so groß sein wie `MAX_UPLOAD_MB` in
|
||||||
|
deiner `.env` (Standard 25 MB); 64m gibt Luft.
|
||||||
|
|
||||||
|
> **Wo lauscht der Container?** Voreinstellung ist `127.0.0.1:3000` – die App
|
||||||
|
> ist dann ausschließlich über einen Proxy auf demselben Host erreichbar.
|
||||||
|
> Zwei Fälle brauchen eine Anpassung in der `.env`:
|
||||||
|
>
|
||||||
|
> - **NPM läuft auf einem anderen Host:** `BIND_ADDRESS=0.0.0.0` setzen und
|
||||||
|
> den Zugriff per Firewall auf die Proxy-IP beschränken.
|
||||||
|
> - **Port 3000 ist schon belegt:** `HOST_PORT=3001` (o. ä.) setzen und im
|
||||||
|
> Proxy denselben Port eintragen.
|
||||||
|
>
|
||||||
|
> **Läuft NPM im selben Docker-Netz?** Dann brauchst du gar kein
|
||||||
|
> Port-Mapping: die `ports`-Zeile in `docker-compose.yml` entfernen, beide
|
||||||
|
> Container in ein gemeinsames Netzwerk hängen und als Forward Hostname den
|
||||||
|
> Container-Namen `bricktrack` mit Port `3000` eintragen.
|
||||||
|
|
||||||
|
## 6. Ersten Nutzer anlegen
|
||||||
|
|
||||||
|
`https://bricktrack.example.de` aufrufen. Die App leitet direkt auf die
|
||||||
|
Registrierung.
|
||||||
|
|
||||||
|
**Der erste registrierte Nutzer wird automatisch Administrator.** Danach ist
|
||||||
|
die Registrierung geschlossen – weitere Konten entstehen nur über einen
|
||||||
|
Einladungslink (ab Version 0.2.0).
|
||||||
|
|
||||||
|
Also: gleich beim ersten Aufruf selbst registrieren, bevor es jemand anderes
|
||||||
|
tut.
|
||||||
|
|
||||||
|
## 7. Passkey hinterlegen
|
||||||
|
|
||||||
|
Unter **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 |
|
||||||
@@ -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
|
```bash
|
||||||
npm run dev
|
git clone https://git.wilde-server.de/florian.wilde/bricktrack.git
|
||||||
# or
|
cd bricktrack
|
||||||
yarn dev
|
|
||||||
# or
|
cp .env.example .env
|
||||||
pnpm dev
|
# BASE_URL und BETTER_AUTH_SECRET setzen:
|
||||||
# or
|
# openssl rand -base64 48
|
||||||
bun dev
|
|
||||||
|
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.
|
TLS wird außerhalb terminiert, der Container spricht nur HTTP auf Port 3000.
|
||||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
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.
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Bricktrack - ein Service, ein Datenverzeichnis.
|
||||||
|
#
|
||||||
|
# TLS terminiert der Reverse Proxy (z. B. Nginx Proxy Manager). Der Container
|
||||||
|
# spricht nur HTTP und muss nicht nach aussen erreichbar sein.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
image: bricktrack:latest
|
||||||
|
container_name: bricktrack
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
|
||||||
|
environment:
|
||||||
|
# Im Container liegen die Daten immer unter /data - der Pfad auf dem Host
|
||||||
|
# wird ueber den Bind-Mount unten bestimmt.
|
||||||
|
DATA_DIR: /data
|
||||||
|
PORT: 3000
|
||||||
|
|
||||||
|
ports:
|
||||||
|
# Standardmaessig nur an localhost gebunden: erreichbar wird die App
|
||||||
|
# ueber den Reverse Proxy. Sitzt der Proxy auf einem anderen Host, in
|
||||||
|
# der .env BIND_ADDRESS=0.0.0.0 setzen (und die Firewall zuziehen).
|
||||||
|
- "${BIND_ADDRESS:-127.0.0.1}:${HOST_PORT:-3000}:3000"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
# Das eine Verzeichnis, das gesichert werden muss.
|
||||||
|
- ./data:/data
|
||||||
|
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:3000/api/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
start_period: 20s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Startet den Container: Verzeichnisse pruefen, Migrationen anwenden, App starten.
|
||||||
|
set -e
|
||||||
|
|
||||||
|
DATA_DIR="${DATA_DIR:-/data}"
|
||||||
|
|
||||||
|
echo "[bricktrack] Datenverzeichnis: $DATA_DIR"
|
||||||
|
|
||||||
|
if ! mkdir -p "$DATA_DIR/db" "$DATA_DIR/files" 2>/dev/null; then
|
||||||
|
echo ""
|
||||||
|
echo " FEHLER: In $DATA_DIR darf nicht geschrieben werden."
|
||||||
|
echo ""
|
||||||
|
echo " Der Container laeuft als Nutzer 'node' (UID 1000). Das gemountete"
|
||||||
|
echo " Verzeichnis muss ihm gehoeren. Auf dem Host einmalig:"
|
||||||
|
echo ""
|
||||||
|
echo " mkdir -p ./data && sudo chown -R 1000:1000 ./data"
|
||||||
|
echo ""
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -w "$DATA_DIR/db" ]; then
|
||||||
|
echo " FEHLER: $DATA_DIR/db ist nicht beschreibbar (sudo chown -R 1000:1000 ./data)."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[bricktrack] Migrationen werden angewendet …"
|
||||||
|
node /app/migrate.cjs
|
||||||
|
|
||||||
|
echo "[bricktrack] Start auf Port ${PORT:-3000}"
|
||||||
|
exec "$@"
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
# Bricktrack – Anleitung
|
||||||
|
|
||||||
|
So benutzt du Bricktrack im Alltag. Die Bilder sind auf einem Handy
|
||||||
|
aufgenommen, weil die App genau dafür gebaut ist: für den Laden und das
|
||||||
|
Kinderzimmer, nicht für den Schreibtisch.
|
||||||
|
|
||||||
|
> Installation und Reverse Proxy stehen im [Schnellstart](../QUICKSTART.md).
|
||||||
|
> Die Bilder hier lassen sich mit `pnpm screenshots` jederzeit neu erzeugen –
|
||||||
|
> sie stammen aus den Beispieldaten des Seed-Skripts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Inhalt
|
||||||
|
|
||||||
|
1. [Das erste Konto](#1-das-erste-konto)
|
||||||
|
2. [Der Überblick](#2-der-überblick)
|
||||||
|
3. [Stammdaten anlegen](#3-stammdaten-anlegen)
|
||||||
|
4. [Sets in den Katalog](#4-sets-in-den-katalog)
|
||||||
|
5. [Was fehlt noch? Mehrere Sets auf einmal auswählen](#5-was-fehlt-noch)
|
||||||
|
6. [Die Set-Detailseite](#6-die-set-detailseite)
|
||||||
|
7. [Die Sammlung](#7-die-sammlung)
|
||||||
|
8. [Wunschlisten](#8-wunschlisten)
|
||||||
|
9. [Teilen – so sehen es Oma und Tante Anne](#9-teilen)
|
||||||
|
10. [Passkeys einrichten](#10-passkeys-einrichten)
|
||||||
|
11. [Antworten auf die zwei wichtigen Fragen](#11-die-zwei-fragen)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Das erste Konto
|
||||||
|
|
||||||
|

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

|
||||||
|
|
||||||
|
Die Startseite ist die Abzweigung zu den vier Bereichen:
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| **Katalog** | Alle bekannten Sets – egal ob vorhanden oder nicht |
|
||||||
|
| **Fehlende Sets** | Der direkte Weg zu allem, was noch fehlt |
|
||||||
|
| **Sammlung** | Was tatsächlich da ist |
|
||||||
|
| **Wunschlisten** | Zum Teilen mit Familie und Freunden |
|
||||||
|
|
||||||
|
Unten am Bildschirmrand liegt die Navigation – auf dem Handy mit dem Daumen
|
||||||
|
erreichbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Stammdaten anlegen
|
||||||
|
|
||||||
|

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

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

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

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

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

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

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

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

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

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

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

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

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

|
||||||
|
|
||||||
|
Unter **Konto → Passkeys → Passkey hinzufügen** hinterlegst du Fingerabdruck,
|
||||||
|
Gesichtserkennung oder die Geräte-PIN als Anmeldung. Danach reicht auf der
|
||||||
|
Anmeldeseite ein Tipp auf „Mit Passkey anmelden" – auf vielen Geräten schlägt
|
||||||
|
der Browser den Passkey sogar von selbst vor.
|
||||||
|
|
||||||
|
Ein Passkey gilt pro Gerät. Leg ruhig mehrere an – Handy, Tablet, Laptop – und
|
||||||
|
gib ihnen sprechende Namen.
|
||||||
|
|
||||||
|
> **Zwei Dinge, die man wissen muss:**
|
||||||
|
> 1. Passkeys brauchen HTTPS. Über eine reine `http://`-Adresse (außer
|
||||||
|
> `localhost`) bietet der Browser sie gar nicht erst an.
|
||||||
|
> 2. Passkeys sind an die Domain gebunden. Ziehst du von
|
||||||
|
> `bricktrack.example.de` auf eine andere Adresse um, musst du alle
|
||||||
|
> Passkeys neu anlegen. Das Passwort funktioniert weiterhin – deshalb ist
|
||||||
|
> es sinnvoll, es nicht zu vergessen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Die zwei Fragen
|
||||||
|
|
||||||
|
Dafür ist die ganze App gebaut:
|
||||||
|
|
||||||
|
**„Welche Sets hat er?"**
|
||||||
|
→ Sammlung. Oder im Katalog der Filter „In der Sammlung".
|
||||||
|
|
||||||
|
**„Welche Sets hat er noch nicht?"**
|
||||||
|
→ Katalog, Filter „Fehlt noch". Von dort in zwei Handgriffen auf eine
|
||||||
|
Wunschliste und per Link zu Oma.
|
||||||
|
|
||||||
|
Im Laden vor dem Regal: Katalog öffnen, Setnummer von der Packung eintippen.
|
||||||
|
Steht ein grüner Haken dran, habt ihr es schon.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Kleinigkeiten, die den Alltag leichter machen
|
||||||
|
|
||||||
|
- **Filter stecken in der URL.** Ein Lesezeichen auf
|
||||||
|
`/katalog?besitz=fehlt&hersteller=…` ist eine gespeicherte Suche.
|
||||||
|
- **Preise** kannst du als `12,99` oder `12.99` eintippen – beides wird
|
||||||
|
verstanden, gespeichert wird auf den Cent genau.
|
||||||
|
- **Dasselbe Set zweimal** ist kein Problem: Zwei Exemplare sind zwei Einträge
|
||||||
|
in der Sammlung, mit eigenem Standort und Zustand.
|
||||||
|
- **Anleitungs-PDFs** können groß sein. Kommt beim Hochladen ein Fehler
|
||||||
|
„413", muss im Reverse Proxy `client_max_body_size` hoch – siehe
|
||||||
|
[QUICKSTART, Schritt 5](../QUICKSTART.md#5-reverse-proxy-einrichten-nginx-proxy-manager).
|
||||||
|
- **Alles Wichtige liegt in `./data`.** Dieses eine Verzeichnis sichern
|
||||||
|
genügt – Datenbank, Bilder, Fotos und Anleitungen sind darin.
|
||||||
@@ -1,32 +1,98 @@
|
|||||||
# Devlog
|
# Devlog
|
||||||
|
|
||||||
Zeiterfassung und Nachvollziehbarkeit der Entwicklung. Eine Zeile je
|
Zeiterfassung und Nachvollziehbarkeit der Entwicklung. Eine Zeile je
|
||||||
abgeschlossenem Arbeitsschritt, Zeiten in `HH:mm` (Europe/Berlin).
|
abgeschlossenem Arbeitsschritt, Zeiten in `HH:mm` (Europe/Berlin). Die
|
||||||
|
Commit-Spalte verweist auf den Stand am Ende des Schritts.
|
||||||
|
|
||||||
## Phase 1 - MVP
|
## Phase 1 – MVP (v0.1.0)
|
||||||
|
|
||||||
### 2026-09-06
|
### 06.09.2026
|
||||||
|
|
||||||
| Von | Bis | Dauer | Schritt | Ergebnis | Commit |
|
| Von | Bis | Dauer | Schritt | Ergebnis | Commit |
|
||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|
|
||||||
| 22:06 | 22:12 | 0:06 | Planung | Phase-1-Plan und Drizzle-Schema zur Freigabe vorgelegt; 5 Entscheidungsfragen geklaert (Cent statt Float, Migrator statt drizzle-kit im Image, Bilder oeffentlich / PDFs geschuetzt, Token gehasht, Branch pro Phase) | - |
|
| 22:06 | 22:12 | 0:06 | Planung | Phase-1-Plan und Drizzle-Schema zur Freigabe vorgelegt; fünf Entscheidungsfragen geklärt (Cent statt Float, Migrator statt drizzle-kit im Image, Bilder öffentlich / PDFs geschützt, Token gehasht, Branch pro Phase) | – |
|
||||||
| 22:12 | 22:20 | 0:08 | Scaffold | pnpm via npm global installiert (corepack scheiterte an Rechten in `C:\Program Files\nodejs`), Next 15.5.25 + Tailwind 4 + shadcn/ui (radix-nova) mit 20 Komponenten | `0de49a9` |
|
| 22:12 | 22:20 | 0:08 | Scaffold | pnpm installiert, Next 15.5.25 + Tailwind 4 + shadcn/ui (radix-nova) mit 20 Komponenten, Touch-Größen auf 44 px angehoben | `0de49a9` |
|
||||||
| 22:20 | 22:26 | 0:06 | Datenmodell | 19 Tabellen in Drizzle, CHECK-Constraints fuer `instruction`, `photo` und `priority`, Unique-Index `(wishlist_id, set_id)`, Migration erzeugt und angewendet, WAL verifiziert | `06268bc` |
|
| 22:20 | 22:25 | 0:05 | Datenmodell | 19 Tabellen in Drizzle, CHECK-Constraints, Unique-Index `(wishlist_id, set_id)`, Migration erzeugt und angewendet, WAL verifiziert | `06268bc` |
|
||||||
| 22:26 | 22:34 | 0:08 | Dokumentation | CLAUDE.md (Stack, Konventionen, Datenmodell, Sicherheitsregeln), Roadmap, Changelog, Devlog | `folgt` |
|
| 22:25 | 22:26 | 0:01 | Dokumentation | CLAUDE.md, Roadmap, Changelog, Devlog | `b74cecb` |
|
||||||
|
| 22:26 | 22:40 | 0:14 | Auth und App-Shell | better-auth mit Passwort und Passkeys, erster Nutzer wird Admin, Rate-Limit in der Datenbank, Konto-Seite, Bottom-Navigation, `/api/health` | `4c78c56` |
|
||||||
|
| 22:40 | 22:46 | 0:06 | Stammdaten | CRUD für Hersteller, Themen und Standorte über eine gemeinsame CrudSection; zod-Schemata, ActionResult, Slug, Geld- und Datumshelfer | `af0ae03` |
|
||||||
|
| 22:46 | 22:59 | 0:13 | Katalog | Kachelliste mit Suche und vier Filtern, Besitzlogik in `ownership.ts`, Set-Formular, Detailseite, Upload-Pipeline mit Magic-Byte-Prüfung, pdf.js-Viewer, Seed-Skript | `856800b` |
|
||||||
|
| 22:59 | 23:06 | 0:07 | Wunschlisten und Sammlung | Mehrfachauswahl mit Dubletten-Skip, Wunschlisten-Verwaltung, Teilen, Überraschungsmodus, öffentliche Ansicht mit Reservierung und Storno, Sammlungsübersicht mit Statistik | `79b0133` |
|
||||||
|
| 23:06 | 23:31 | 0:25 | Tests | 50 Vitest-Unit-Tests, Playwright-Smoke-Test über den ganzen Weg bis zur Reservierung durch einen Besucher ohne Konto | `b0152b8` |
|
||||||
|
|
||||||
**Summe Phase 1 bisher: 0:28**
|
### 07.09.2026
|
||||||
|
|
||||||
|
| Von | Bis | Dauer | Schritt | Ergebnis | Commit |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 23:31 | 00:09 | 0:38 | Docker | Multi-Stage-Build, Entrypoint mit Rechteprüfung und Migration, Compose-Stack, Healthcheck, `update.sh` mit Rollback; Image gebaut und Container verifiziert | `61b1249` |
|
||||||
|
| 00:09 | 00:50 | 0:41 | Dokumentation und Screenshots | README, QUICKSTART mit Nginx-Proxy-Manager-Konfiguration, bebilderte Anleitung, 16 automatisiert erzeugte Screenshots, Changelog 0.1.0 | folgt |
|
||||||
|
|
||||||
|
**Summe Phase 1: 2:44**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Notizen und Entscheidungen
|
## Notizen und Entscheidungen
|
||||||
|
|
||||||
|
### Umgebung
|
||||||
|
|
||||||
- **pnpm**: `corepack enable` scheitert auf diesem Rechner an fehlenden
|
- **pnpm**: `corepack enable` scheitert auf diesem Rechner an fehlenden
|
||||||
Schreibrechten in `C:\Program Files\nodejs`. Stattdessen
|
Schreibrechten in `C:\Program Files\nodejs`. Stattdessen
|
||||||
`npm install -g pnpm@10`; pnpm liegt unter
|
`npm install -g pnpm@10`; pnpm liegt unter `%APPDATA%\npm` und ist im PATH.
|
||||||
`%APPDATA%\npm\pnpm.ps1` und ist im PATH.
|
- **Port 3000** ist auf dem Entwicklungsrechner von einer anderen Anwendung
|
||||||
- **shadcn-CLI**: Die aktuelle Version (4.21) arbeitet mit Presets
|
belegt. Der Dev-Server läuft deshalb lokal auf 3100, die Testläufe auf
|
||||||
(`-p nova`) und Base-Libraries (`-b radix`) statt der frueheren
|
3210 und 3220.
|
||||||
Style-Auswahl. Sie zieht `cn` und `radix-ui` als Pakete; `clsx` und
|
- **`pnpm build` unter Windows** scheitert mit `EPERM … symlink`: Der
|
||||||
`tailwind-merge` werden dadurch nicht mehr direkt gebraucht.
|
Standalone-Output legt Symlinks in `.next/standalone` an, wofür Windows den
|
||||||
|
Entwicklermodus oder administrative Rechte braucht. Der Docker-Build unter
|
||||||
|
Linux ist davon nicht betroffen; lokal prüft man mit
|
||||||
|
`docker build -t bricktrack:test .`.
|
||||||
|
|
||||||
|
### Bibliotheken
|
||||||
|
|
||||||
|
- **shadcn-CLI 4.21** arbeitet mit Presets (`-p nova`) und Base-Libraries
|
||||||
|
(`-b radix`) statt der früheren Style-Auswahl. Sie zieht `cn` und `radix-ui`
|
||||||
|
als Pakete; `clsx` und `tailwind-merge` werden dadurch nicht mehr direkt
|
||||||
|
gebraucht und wurden wieder entfernt. Die Voreinstellungen sind auf
|
||||||
|
Desktop-Dichte ausgelegt (Buttons `h-8`) – für die Bedienung am Handy auf
|
||||||
|
44 px angehoben.
|
||||||
- **better-auth 1.7.3**: Das Passkey-Plugin liegt nicht mehr unter
|
- **better-auth 1.7.3**: Das Passkey-Plugin liegt nicht mehr unter
|
||||||
`better-auth/plugins/passkey`, sondern im eigenen Paket
|
`better-auth/plugins/passkey`, sondern im eigenen Paket
|
||||||
`@better-auth/passkey` (Exports `.` und `./client`). Das erwartete
|
`@better-auth/passkey`. Der Drizzle-Adapter löst Modelle über die
|
||||||
Schema deckt sich exakt mit der Tabelle `passkey`.
|
TypeScript-Property-Namen auf, nicht über die Spaltennamen – deshalb können
|
||||||
|
auch die Auth-Tabellen durchgehend `snake_case` verwenden.
|
||||||
|
- **Drizzle**: Selbstbezügliche Fremdschlüssel (`parent_theme_id`) brauchen
|
||||||
|
`AnySQLiteColumn` als Rückgabetyp, nicht `AnyColumn`.
|
||||||
|
- **pdf.js** wird lokal gebündelt, inklusive Worker. Kein CDN, damit der
|
||||||
|
Browser des Nutzers keine fremden Server kontaktiert.
|
||||||
|
|
||||||
|
### Stolpersteine
|
||||||
|
|
||||||
|
- **CRLF im Entrypoint**: Eine unter Windows geschriebene `entrypoint.sh` mit
|
||||||
|
CRLF scheitert im Container mit dem irreführenden
|
||||||
|
`exec /app/entrypoint.sh: no such file or directory` – der Kernel sucht
|
||||||
|
dann `/bin/sh\r`. Behoben mit einer `.gitattributes`, die für Shell-Skripte
|
||||||
|
LF erzwingt.
|
||||||
|
- **Migrations-Bundle**: Der mit esbuild als ESM gebündelte Migrations-Runner
|
||||||
|
scheiterte an `Dynamic require of "fs" is not supported` – mitgebündelte
|
||||||
|
CJS-Abhängigkeiten (dotenv) rufen `require()` für Node-Builtins auf. Als
|
||||||
|
CommonJS gebündelt läuft es.
|
||||||
|
- **Playwright und Reihenfolge**: Playwright startet den Web-Server **vor**
|
||||||
|
dem `globalSetup`. Das Löschen des Testdatenverzeichnisses im globalSetup
|
||||||
|
scheiterte deshalb unter Windows an der bereits geöffneten SQLite-Datei;
|
||||||
|
das Aufräumen läuft jetzt im npm-Skript davor.
|
||||||
|
- **Screenshots gegen den Dev-Server** waren nicht reproduzierbar: unter Last
|
||||||
|
brach der Stream mit `ChunkLoadError` beziehungsweise
|
||||||
|
`Unexpected end of JSON input` ab. Die Bilder entstehen jetzt gegen das
|
||||||
|
Produktions-Image im Container – verlässlich und zugleich näher an dem, was
|
||||||
|
Nutzer wirklich sehen.
|
||||||
|
|
||||||
|
### Beim Testen gefunden und behoben
|
||||||
|
|
||||||
|
- Eine spontan aus dem Katalog angelegte Wunschliste war **nicht teilbar**:
|
||||||
|
Das Feld `isPublic` kommt aus einer Checkbox und fiel beim programmatischen
|
||||||
|
Anlegen auf `false`. Die Schnellanlage setzt es jetzt ausdrücklich.
|
||||||
|
- Das Sternchen an Pflichtfeldern gehörte zum zugänglichen Namen des Feldes
|
||||||
|
(„Name *"). Jetzt `aria-hidden` – der Pflichtstatus steckt ohnehin im
|
||||||
|
`required` des Eingabefeldes.
|
||||||
|
- In der Mehrfachauswahl verdeckte die Aktionsleiste die letzte Kachelreihe;
|
||||||
|
das Raster bekommt im Auswahlmodus zusätzliches Polster nach unten.
|
||||||
|
|||||||
@@ -2,36 +2,36 @@
|
|||||||
|
|
||||||
Legende: `[ ]` offen · `[~]` in Arbeit · `[x]` fertig
|
Legende: `[ ]` offen · `[~]` in Arbeit · `[x]` fertig
|
||||||
|
|
||||||
## Phase 1 - MVP (Ziel: v0.1.0)
|
## Phase 1 - MVP (v0.1.0, abgeschlossen am 07.09.2026)
|
||||||
|
|
||||||
Alles laeuft vollstaendig im Docker-Stack.
|
Laeuft vollstaendig im Docker-Stack.
|
||||||
|
|
||||||
- [x] Projekt-Scaffold (Next 15, Tailwind 4, shadcn/ui, pnpm)
|
- [x] Projekt-Scaffold (Next 15, Tailwind 4, shadcn/ui, pnpm)
|
||||||
- [x] Datenmodell in Drizzle, initiale Migration
|
- [x] Datenmodell in Drizzle, initiale Migration
|
||||||
- [x] Projektdokumentation (CLAUDE.md, Roadmap, Changelog, Devlog)
|
- [x] Projektdokumentation (CLAUDE.md, Roadmap, Changelog, Devlog)
|
||||||
- [ ] Auth: Registrierung des ersten Users (wird Admin), Login mit
|
- [x] Auth: Registrierung des ersten Users (wird Admin), Login mit
|
||||||
Passwort, Passkey anlegen und damit einloggen (Conditional UI),
|
Passwort, Passkey anlegen und damit einloggen (Conditional UI),
|
||||||
Passkey-Verwaltung unter "Konto", Rate-Limit auf Login
|
Passkey-Verwaltung unter "Konto", Rate-Limit auf Login
|
||||||
- [ ] Mobile-first Layout-Shell mit Bottom-Navigation
|
- [x] Mobile-first Layout-Shell mit Bottom-Navigation
|
||||||
- [ ] Stammdaten: Hersteller, Themen (mit Unterthemen), Standorte
|
- [x] Stammdaten: Hersteller, Themen (mit Unterthemen), Standorte
|
||||||
- [ ] Set-Katalog: Kachelliste, Suche, Filter (Hersteller, Thema, Jahr,
|
- [x] Set-Katalog: Kachelliste, Suche, Filter (Hersteller, Thema, Jahr,
|
||||||
Besitzstatus), Anlegen und Bearbeiten
|
Besitzstatus), Anlegen und Bearbeiten
|
||||||
- [ ] Upload-Pipeline (PDF/JPEG/PNG/WebP, Magic-Byte-Pruefung,
|
- [x] Upload-Pipeline (PDF/JPEG/PNG/WebP, Magic-Byte-Pruefung,
|
||||||
serverseitige Dateinamen, Thumbnails)
|
serverseitige Dateinamen, Thumbnails)
|
||||||
- [ ] Set-Detailseite: Metadaten, Bild, Anleitungen mit pdf.js-Viewer,
|
- [x] Set-Detailseite: Metadaten, Bild, Anleitungen mit pdf.js-Viewer,
|
||||||
Fotos, Sammlungseintraege
|
Fotos, Sammlungseintraege
|
||||||
- [ ] Sammlung: Uebersicht, Filter, Statistik-Kacheln
|
- [x] Sammlung: Uebersicht, Filter, Statistik-Kacheln
|
||||||
- [ ] Fehlende Sets (`/katalog?besitz=fehlt`) mit Mehrfachauswahl und
|
- [x] Fehlende Sets (`/katalog?besitz=fehlt`) mit Mehrfachauswahl und
|
||||||
Bulk-Aktion "Zur Wunschliste hinzufuegen"
|
Bulk-Aktion "Zur Wunschliste hinzufuegen"
|
||||||
- [ ] Wunschlisten: Verwaltung, Prioritaet, Sortierung, Teilen,
|
- [x] Wunschlisten: Verwaltung, Prioritaet, Sortierung, Teilen,
|
||||||
Ueberraschungsmodus, automatisches "erfuellt"
|
Ueberraschungsmodus, automatisches "erfuellt"
|
||||||
- [ ] Oeffentliche Wunschliste `/w/[shareToken]` mit Reservierung ohne
|
- [x] Oeffentliche Wunschliste `/w/[shareToken]` mit Reservierung ohne
|
||||||
Anmeldung und Storno-Link
|
Anmeldung und Storno-Link
|
||||||
- [ ] Seed-Skript (3 Hersteller, 10 Sets, 4 in Sammlung, 1 Wunschliste)
|
- [x] Seed-Skript (3 Hersteller, 10 Sets, 4 in Sammlung, 1 Wunschliste)
|
||||||
- [ ] Unit-Tests (Besitzstatus, Bulk-Add inkl. Dubletten, Reservierung,
|
- [x] Unit-Tests (Besitzstatus, Bulk-Add inkl. Dubletten, Reservierung,
|
||||||
Token-Erzeugung) und Playwright-Smoke-Test
|
Token-Erzeugung) und Playwright-Smoke-Test
|
||||||
- [ ] Docker: Multi-Stage-Build, Compose-Stack, Healthcheck, Entrypoint
|
- [x] Docker: Multi-Stage-Build, Compose-Stack, Healthcheck, Entrypoint
|
||||||
- [ ] Dokumentation: README, QUICKSTART, bebilderte Anleitung,
|
- [x] Dokumentation: README, QUICKSTART, bebilderte Anleitung,
|
||||||
update.sh, Nginx-Proxy-Manager-Konfiguration
|
update.sh, Nginx-Proxy-Manager-Konfiguration
|
||||||
|
|
||||||
## Phase 2 - Komfort (Ziel: v0.2.0)
|
## Phase 2 - Komfort (Ziel: v0.2.0)
|
||||||
|
|||||||
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 285 KiB |
|
After Width: | Height: | Size: 202 KiB |
|
After Width: | Height: | Size: 212 KiB |
|
After Width: | Height: | Size: 252 KiB |
|
After Width: | Height: | Size: 183 KiB |
|
After Width: | Height: | Size: 184 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 292 KiB |
|
After Width: | Height: | Size: 155 KiB |
|
After Width: | Height: | Size: 244 KiB |
|
After Width: | Height: | Size: 187 KiB |
|
After Width: | Height: | Size: 294 KiB |
|
After Width: | Height: | Size: 112 KiB |
@@ -1,21 +1,21 @@
|
|||||||
CREATE TABLE `account` (
|
CREATE TABLE `account` (
|
||||||
`id` text PRIMARY KEY NOT NULL,
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
`accountId` text NOT NULL,
|
`account_id` text NOT NULL,
|
||||||
`providerId` text NOT NULL,
|
`provider_id` text NOT NULL,
|
||||||
`userId` text NOT NULL,
|
`user_id` text NOT NULL,
|
||||||
`accessToken` text,
|
`access_token` text,
|
||||||
`refreshToken` text,
|
`refresh_token` text,
|
||||||
`idToken` text,
|
`id_token` text,
|
||||||
`accessTokenExpiresAt` integer,
|
`access_token_expires_at` integer,
|
||||||
`refreshTokenExpiresAt` integer,
|
`refresh_token_expires_at` integer,
|
||||||
`scope` text,
|
`scope` text,
|
||||||
`password` text,
|
`password` text,
|
||||||
`createdAt` integer NOT NULL,
|
`created_at` integer NOT NULL,
|
||||||
`updatedAt` integer NOT NULL,
|
`updated_at` integer NOT NULL,
|
||||||
FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
|
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
);
|
);
|
||||||
--> statement-breakpoint
|
--> 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` (
|
CREATE TABLE `invite` (
|
||||||
`id` text PRIMARY KEY NOT NULL,
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
`token_hash` text 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` (
|
CREATE TABLE `passkey` (
|
||||||
`id` text PRIMARY KEY NOT NULL,
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
`name` text,
|
`name` text,
|
||||||
`publicKey` text NOT NULL,
|
`public_key` text NOT NULL,
|
||||||
`userId` text NOT NULL,
|
`user_id` text NOT NULL,
|
||||||
`credentialID` text NOT NULL,
|
`credential_id` text NOT NULL,
|
||||||
`counter` integer NOT NULL,
|
`counter` integer NOT NULL,
|
||||||
`deviceType` text NOT NULL,
|
`device_type` text NOT NULL,
|
||||||
`backedUp` integer NOT NULL,
|
`backed_up` integer NOT NULL,
|
||||||
`transports` text,
|
`transports` text,
|
||||||
`aaguid` text,
|
`aaguid` text,
|
||||||
`createdAt` integer,
|
`created_at` integer,
|
||||||
FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
|
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
);
|
);
|
||||||
--> statement-breakpoint
|
--> statement-breakpoint
|
||||||
CREATE UNIQUE INDEX `passkey_credentialID_unique` ON `passkey` (`credentialID`);--> statement-breakpoint
|
CREATE UNIQUE INDEX `passkey_credential_id_unique` ON `passkey` (`credential_id`);--> statement-breakpoint
|
||||||
CREATE INDEX `passkey_user_idx` ON `passkey` (`userId`);--> statement-breakpoint
|
CREATE INDEX `passkey_user_idx` ON `passkey` (`user_id`);--> statement-breakpoint
|
||||||
CREATE TABLE `rateLimit` (
|
CREATE TABLE `rate_limit` (
|
||||||
`id` text PRIMARY KEY NOT NULL,
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
`key` text,
|
`key` text,
|
||||||
`count` integer,
|
`count` integer,
|
||||||
`lastRequest` integer
|
`last_request` integer
|
||||||
);
|
);
|
||||||
--> statement-breakpoint
|
--> statement-breakpoint
|
||||||
CREATE TABLE `session` (
|
CREATE TABLE `session` (
|
||||||
`id` text PRIMARY KEY NOT NULL,
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
`token` text NOT NULL,
|
`token` text NOT NULL,
|
||||||
`userId` text NOT NULL,
|
`user_id` text NOT NULL,
|
||||||
`expiresAt` integer NOT NULL,
|
`expires_at` integer NOT NULL,
|
||||||
`ipAddress` text,
|
`ip_address` text,
|
||||||
`userAgent` text,
|
`user_agent` text,
|
||||||
`createdAt` integer NOT NULL,
|
`created_at` integer NOT NULL,
|
||||||
`updatedAt` integer NOT NULL,
|
`updated_at` integer NOT NULL,
|
||||||
FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
|
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
);
|
);
|
||||||
--> statement-breakpoint
|
--> statement-breakpoint
|
||||||
CREATE UNIQUE INDEX `session_token_unique` ON `session` (`token`);--> 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` (
|
CREATE TABLE `user` (
|
||||||
`id` text PRIMARY KEY NOT NULL,
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
`name` text NOT NULL,
|
`name` text NOT NULL,
|
||||||
`email` text NOT NULL,
|
`email` text NOT NULL,
|
||||||
`emailVerified` integer DEFAULT false NOT NULL,
|
`email_verified` integer DEFAULT false NOT NULL,
|
||||||
`image` text,
|
`image` text,
|
||||||
`role` text DEFAULT 'member' NOT NULL,
|
`role` text DEFAULT 'member' NOT NULL,
|
||||||
`createdAt` integer NOT NULL,
|
`created_at` integer NOT NULL,
|
||||||
`updatedAt` integer NOT NULL
|
`updated_at` integer NOT NULL
|
||||||
);
|
);
|
||||||
--> statement-breakpoint
|
--> statement-breakpoint
|
||||||
CREATE UNIQUE INDEX `user_email_unique` ON `user` (`email`);--> 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,
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
`identifier` text NOT NULL,
|
`identifier` text NOT NULL,
|
||||||
`value` text NOT NULL,
|
`value` text NOT NULL,
|
||||||
`expiresAt` integer NOT NULL,
|
`expires_at` integer NOT NULL,
|
||||||
`createdAt` integer NOT NULL,
|
`created_at` integer NOT NULL,
|
||||||
`updatedAt` integer NOT NULL
|
`updated_at` integer NOT NULL
|
||||||
);
|
);
|
||||||
--> statement-breakpoint
|
--> statement-breakpoint
|
||||||
CREATE INDEX `verification_identifier_idx` ON `verification` (`identifier`);--> statement-breakpoint
|
CREATE INDEX `verification_identifier_idx` ON `verification` (`identifier`);--> statement-breakpoint
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"dialect": "sqlite",
|
"dialect": "sqlite",
|
||||||
"id": "5d6a1e8b-5b30-4c01-8044-1db2d04615e0",
|
"id": "426c9bf5-ccbd-473a-a819-11f8b9252494",
|
||||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||||
"tables": {
|
"tables": {
|
||||||
"account": {
|
"account": {
|
||||||
@@ -14,57 +14,57 @@
|
|||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"accountId": {
|
"account_id": {
|
||||||
"name": "accountId",
|
"name": "account_id",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"providerId": {
|
"provider_id": {
|
||||||
"name": "providerId",
|
"name": "provider_id",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"userId": {
|
"user_id": {
|
||||||
"name": "userId",
|
"name": "user_id",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"accessToken": {
|
"access_token": {
|
||||||
"name": "accessToken",
|
"name": "access_token",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"refreshToken": {
|
"refresh_token": {
|
||||||
"name": "refreshToken",
|
"name": "refresh_token",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"idToken": {
|
"id_token": {
|
||||||
"name": "idToken",
|
"name": "id_token",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"accessTokenExpiresAt": {
|
"access_token_expires_at": {
|
||||||
"name": "accessTokenExpiresAt",
|
"name": "access_token_expires_at",
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"refreshTokenExpiresAt": {
|
"refresh_token_expires_at": {
|
||||||
"name": "refreshTokenExpiresAt",
|
"name": "refresh_token_expires_at",
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
@@ -84,15 +84,15 @@
|
|||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"createdAt": {
|
"created_at": {
|
||||||
"name": "createdAt",
|
"name": "created_at",
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"updatedAt": {
|
"updated_at": {
|
||||||
"name": "updatedAt",
|
"name": "updated_at",
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
@@ -103,18 +103,18 @@
|
|||||||
"account_user_idx": {
|
"account_user_idx": {
|
||||||
"name": "account_user_idx",
|
"name": "account_user_idx",
|
||||||
"columns": [
|
"columns": [
|
||||||
"userId"
|
"user_id"
|
||||||
],
|
],
|
||||||
"isUnique": false
|
"isUnique": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"foreignKeys": {
|
"foreignKeys": {
|
||||||
"account_userId_user_id_fk": {
|
"account_user_id_user_id_fk": {
|
||||||
"name": "account_userId_user_id_fk",
|
"name": "account_user_id_user_id_fk",
|
||||||
"tableFrom": "account",
|
"tableFrom": "account",
|
||||||
"tableTo": "user",
|
"tableTo": "user",
|
||||||
"columnsFrom": [
|
"columnsFrom": [
|
||||||
"userId"
|
"user_id"
|
||||||
],
|
],
|
||||||
"columnsTo": [
|
"columnsTo": [
|
||||||
"id"
|
"id"
|
||||||
@@ -253,22 +253,22 @@
|
|||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"publicKey": {
|
"public_key": {
|
||||||
"name": "publicKey",
|
"name": "public_key",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"userId": {
|
"user_id": {
|
||||||
"name": "userId",
|
"name": "user_id",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"credentialID": {
|
"credential_id": {
|
||||||
"name": "credentialID",
|
"name": "credential_id",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
@@ -281,15 +281,15 @@
|
|||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"deviceType": {
|
"device_type": {
|
||||||
"name": "deviceType",
|
"name": "device_type",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"backedUp": {
|
"backed_up": {
|
||||||
"name": "backedUp",
|
"name": "backed_up",
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
@@ -309,8 +309,8 @@
|
|||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"createdAt": {
|
"created_at": {
|
||||||
"name": "createdAt",
|
"name": "created_at",
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
@@ -318,28 +318,28 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"indexes": {
|
"indexes": {
|
||||||
"passkey_credentialID_unique": {
|
"passkey_credential_id_unique": {
|
||||||
"name": "passkey_credentialID_unique",
|
"name": "passkey_credential_id_unique",
|
||||||
"columns": [
|
"columns": [
|
||||||
"credentialID"
|
"credential_id"
|
||||||
],
|
],
|
||||||
"isUnique": true
|
"isUnique": true
|
||||||
},
|
},
|
||||||
"passkey_user_idx": {
|
"passkey_user_idx": {
|
||||||
"name": "passkey_user_idx",
|
"name": "passkey_user_idx",
|
||||||
"columns": [
|
"columns": [
|
||||||
"userId"
|
"user_id"
|
||||||
],
|
],
|
||||||
"isUnique": false
|
"isUnique": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"foreignKeys": {
|
"foreignKeys": {
|
||||||
"passkey_userId_user_id_fk": {
|
"passkey_user_id_user_id_fk": {
|
||||||
"name": "passkey_userId_user_id_fk",
|
"name": "passkey_user_id_user_id_fk",
|
||||||
"tableFrom": "passkey",
|
"tableFrom": "passkey",
|
||||||
"tableTo": "user",
|
"tableTo": "user",
|
||||||
"columnsFrom": [
|
"columnsFrom": [
|
||||||
"userId"
|
"user_id"
|
||||||
],
|
],
|
||||||
"columnsTo": [
|
"columnsTo": [
|
||||||
"id"
|
"id"
|
||||||
@@ -352,8 +352,8 @@
|
|||||||
"uniqueConstraints": {},
|
"uniqueConstraints": {},
|
||||||
"checkConstraints": {}
|
"checkConstraints": {}
|
||||||
},
|
},
|
||||||
"rateLimit": {
|
"rate_limit": {
|
||||||
"name": "rateLimit",
|
"name": "rate_limit",
|
||||||
"columns": {
|
"columns": {
|
||||||
"id": {
|
"id": {
|
||||||
"name": "id",
|
"name": "id",
|
||||||
@@ -376,8 +376,8 @@
|
|||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"lastRequest": {
|
"last_request": {
|
||||||
"name": "lastRequest",
|
"name": "last_request",
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
@@ -407,43 +407,43 @@
|
|||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"userId": {
|
"user_id": {
|
||||||
"name": "userId",
|
"name": "user_id",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"expiresAt": {
|
"expires_at": {
|
||||||
"name": "expiresAt",
|
"name": "expires_at",
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"ipAddress": {
|
"ip_address": {
|
||||||
"name": "ipAddress",
|
"name": "ip_address",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"userAgent": {
|
"user_agent": {
|
||||||
"name": "userAgent",
|
"name": "user_agent",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"createdAt": {
|
"created_at": {
|
||||||
"name": "createdAt",
|
"name": "created_at",
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"updatedAt": {
|
"updated_at": {
|
||||||
"name": "updatedAt",
|
"name": "updated_at",
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
@@ -461,18 +461,18 @@
|
|||||||
"session_user_idx": {
|
"session_user_idx": {
|
||||||
"name": "session_user_idx",
|
"name": "session_user_idx",
|
||||||
"columns": [
|
"columns": [
|
||||||
"userId"
|
"user_id"
|
||||||
],
|
],
|
||||||
"isUnique": false
|
"isUnique": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"foreignKeys": {
|
"foreignKeys": {
|
||||||
"session_userId_user_id_fk": {
|
"session_user_id_user_id_fk": {
|
||||||
"name": "session_userId_user_id_fk",
|
"name": "session_user_id_user_id_fk",
|
||||||
"tableFrom": "session",
|
"tableFrom": "session",
|
||||||
"tableTo": "user",
|
"tableTo": "user",
|
||||||
"columnsFrom": [
|
"columnsFrom": [
|
||||||
"userId"
|
"user_id"
|
||||||
],
|
],
|
||||||
"columnsTo": [
|
"columnsTo": [
|
||||||
"id"
|
"id"
|
||||||
@@ -509,8 +509,8 @@
|
|||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"emailVerified": {
|
"email_verified": {
|
||||||
"name": "emailVerified",
|
"name": "email_verified",
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
@@ -532,15 +532,15 @@
|
|||||||
"autoincrement": false,
|
"autoincrement": false,
|
||||||
"default": "'member'"
|
"default": "'member'"
|
||||||
},
|
},
|
||||||
"createdAt": {
|
"created_at": {
|
||||||
"name": "createdAt",
|
"name": "created_at",
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"updatedAt": {
|
"updated_at": {
|
||||||
"name": "updatedAt",
|
"name": "updated_at",
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
@@ -585,22 +585,22 @@
|
|||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"expiresAt": {
|
"expires_at": {
|
||||||
"name": "expiresAt",
|
"name": "expires_at",
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"createdAt": {
|
"created_at": {
|
||||||
"name": "createdAt",
|
"name": "created_at",
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"updatedAt": {
|
"updated_at": {
|
||||||
"name": "updatedAt",
|
"name": "updated_at",
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
{
|
{
|
||||||
"idx": 0,
|
"idx": 0,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1788726280857,
|
"when": 1788726503038,
|
||||||
"tag": "0000_clean_sumo",
|
"tag": "0000_rapid_gargoyle",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -1,70 +1,73 @@
|
|||||||
{
|
{
|
||||||
"name": "bricktrack",
|
"name": "bricktrack",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Self-hosted Verwaltung fuer Spielzeug-Set-Sammlungen (Lego, Playmobil, Cobi, ...)",
|
"description": "Self-hosted Verwaltung fuer Spielzeug-Set-Sammlungen (Lego, Playmobil, Cobi, ...)",
|
||||||
"packageManager": "pnpm@10.34.5",
|
"packageManager": "pnpm@10.34.5",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22"
|
"node": ">=22"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "eslint .",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "tsx scripts/migrate.ts",
|
"db:migrate": "tsx scripts/migrate.ts",
|
||||||
"db:seed": "tsx scripts/seed.ts",
|
"db:seed": "tsx scripts/seed.ts",
|
||||||
"db:studio": "drizzle-kit studio",
|
"db:studio": "drizzle-kit studio",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "node scripts/reset-e2e.mjs && playwright test",
|
||||||
"screenshots": "playwright test --config=playwright.screenshots.config.ts"
|
"screenshots": "node scripts/screenshots-prepare.mjs && playwright test --config=playwright.screenshots.config.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@better-auth/passkey": "^1.7.3",
|
"@better-auth/passkey": "^1.7.3",
|
||||||
"better-auth": "^1.7.3",
|
"better-auth": "^1.7.3",
|
||||||
"better-sqlite3": "^13.0.3",
|
"better-sqlite3": "^13.0.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"cn": "^0.2.6",
|
"cn": "^0.2.6",
|
||||||
"date-fns": "^4.4.0",
|
"date-fns": "^4.4.0",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"lucide-react": "^1.41.0",
|
"lucide-react": "^1.41.0",
|
||||||
"next": "15.5.25",
|
"next": "15.5.25",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"radix-ui": "^1.6.7",
|
"pdfjs-dist": "^6.3.289",
|
||||||
"react": "19.1.0",
|
"radix-ui": "^1.6.7",
|
||||||
"react-dom": "19.1.0",
|
"react": "19.1.0",
|
||||||
"sharp": "^0.35.4",
|
"react-dom": "19.1.0",
|
||||||
"sonner": "^2.0.8",
|
"server-only": "^0.0.1",
|
||||||
"tw-animate-css": "^1.4.0",
|
"sharp": "^0.35.4",
|
||||||
"zod": "^4.5.4"
|
"sonner": "^2.0.8",
|
||||||
},
|
"tw-animate-css": "^1.4.0",
|
||||||
"devDependencies": {
|
"zod": "^4.5.4"
|
||||||
"@eslint/eslintrc": "^3",
|
},
|
||||||
"@playwright/test": "^1.63.0",
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@eslint/eslintrc": "^3",
|
||||||
"@types/better-sqlite3": "^9.6.0",
|
"@playwright/test": "^1.63.0",
|
||||||
"@types/node": "^20",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/react": "^19",
|
"@types/better-sqlite3": "^9.6.0",
|
||||||
"@types/react-dom": "^19",
|
"@types/node": "^20",
|
||||||
"dotenv": "^17.4.2",
|
"@types/react": "^19",
|
||||||
"drizzle-kit": "^0.31.10",
|
"@types/react-dom": "^19",
|
||||||
"eslint": "^9",
|
"dotenv": "^17.4.2",
|
||||||
"eslint-config-next": "15.5.25",
|
"drizzle-kit": "^0.31.10",
|
||||||
"shadcn": "^4.21.0",
|
"esbuild": "^0.25.12",
|
||||||
"tailwindcss": "^4",
|
"eslint": "^9",
|
||||||
"tsx": "^4.23.13",
|
"eslint-config-next": "15.5.25",
|
||||||
"typescript": "^5",
|
"shadcn": "^4.21.0",
|
||||||
"vitest": "^5.0.0"
|
"tailwindcss": "^4",
|
||||||
},
|
"tsx": "^4.23.13",
|
||||||
"pnpm": {
|
"typescript": "^5",
|
||||||
"onlyBuiltDependencies": [
|
"vitest": "^5.0.0"
|
||||||
"better-sqlite3",
|
},
|
||||||
"esbuild",
|
"pnpm": {
|
||||||
"sharp",
|
"onlyBuiltDependencies": [
|
||||||
"unrs-resolver"
|
"better-sqlite3",
|
||||||
]
|
"esbuild",
|
||||||
}
|
"sharp",
|
||||||
}
|
"unrs-resolver"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -10,10 +10,10 @@ importers:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@better-auth/passkey':
|
'@better-auth/passkey':
|
||||||
specifier: ^1.7.3
|
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:
|
better-auth:
|
||||||
specifier: ^1.7.3
|
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:
|
better-sqlite3:
|
||||||
specifier: ^13.0.3
|
specifier: ^13.0.3
|
||||||
version: 13.0.3
|
version: 13.0.3
|
||||||
@@ -38,6 +38,9 @@ importers:
|
|||||||
next-themes:
|
next-themes:
|
||||||
specifier: ^0.4.6
|
specifier: ^0.4.6
|
||||||
version: 0.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
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:
|
radix-ui:
|
||||||
specifier: ^1.6.7
|
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)
|
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:
|
react-dom:
|
||||||
specifier: 19.1.0
|
specifier: 19.1.0
|
||||||
version: 19.1.0(react@19.1.0)
|
version: 19.1.0(react@19.1.0)
|
||||||
|
server-only:
|
||||||
|
specifier: ^0.0.1
|
||||||
|
version: 0.0.1
|
||||||
sharp:
|
sharp:
|
||||||
specifier: ^0.35.4
|
specifier: ^0.35.4
|
||||||
version: 0.35.4(@types/node@20.19.43)
|
version: 0.35.4(@types/node@20.19.43)
|
||||||
@@ -87,6 +93,9 @@ importers:
|
|||||||
drizzle-kit:
|
drizzle-kit:
|
||||||
specifier: ^0.31.10
|
specifier: ^0.31.10
|
||||||
version: 0.31.10
|
version: 0.31.10
|
||||||
|
esbuild:
|
||||||
|
specifier: ^0.25.12
|
||||||
|
version: 0.25.12
|
||||||
eslint:
|
eslint:
|
||||||
specifier: ^9
|
specifier: ^9
|
||||||
version: 9.39.5(jiti@2.7.0)
|
version: 9.39.5(jiti@2.7.0)
|
||||||
@@ -107,7 +116,7 @@ importers:
|
|||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
vitest:
|
vitest:
|
||||||
specifier: ^5.0.0
|
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:
|
packages:
|
||||||
|
|
||||||
@@ -1083,6 +1092,81 @@ packages:
|
|||||||
'@cfworker/json-schema':
|
'@cfworker/json-schema':
|
||||||
optional: true
|
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':
|
'@napi-rs/wasm-runtime@1.2.3':
|
||||||
resolution: {integrity: sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==}
|
resolution: {integrity: sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==}
|
||||||
engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0}
|
engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0}
|
||||||
@@ -4171,6 +4255,10 @@ packages:
|
|||||||
path-to-regexp@8.4.2:
|
path-to-regexp@8.4.2:
|
||||||
resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==}
|
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:
|
picocolors@1.1.1:
|
||||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||||
|
|
||||||
@@ -4417,6 +4505,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==}
|
resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==}
|
||||||
engines: {node: '>= 18'}
|
engines: {node: '>= 18'}
|
||||||
|
|
||||||
|
server-only@0.0.1:
|
||||||
|
resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==}
|
||||||
|
|
||||||
set-cookie-parser@3.1.2:
|
set-cookie-parser@3.1.2:
|
||||||
resolution: {integrity: sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==}
|
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/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/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:
|
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/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/utils': 0.4.2
|
||||||
'@better-fetch/fetch': 1.3.1
|
'@better-fetch/fetch': 1.3.1
|
||||||
'@simplewebauthn/browser': 13.3.0
|
'@simplewebauthn/browser': 13.3.0
|
||||||
'@simplewebauthn/server': 13.3.3
|
'@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)
|
better-call: 1.4.0(zod@4.5.4)
|
||||||
nanostores: 1.5.3
|
nanostores: 1.5.3
|
||||||
zod: 4.5.4
|
zod: 4.5.4
|
||||||
@@ -5698,6 +5789,54 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- 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)':
|
'@napi-rs/wasm-runtime@1.2.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@emnapi/core': 1.10.0
|
'@emnapi/core': 1.10.0
|
||||||
@@ -6954,14 +7093,14 @@ snapshots:
|
|||||||
'@unrs/resolver-binding-win32-x64-msvc@1.12.2':
|
'@unrs/resolver-binding-win32-x64-msvc@1.12.2':
|
||||||
optional: true
|
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:
|
dependencies:
|
||||||
'@jridgewell/trace-mapping': 0.3.31
|
'@jridgewell/trace-mapping': 0.3.31
|
||||||
'@vitest/spy': 5.0.0
|
'@vitest/spy': 5.0.0
|
||||||
estree-walker: 3.0.3
|
estree-walker: 3.0.3
|
||||||
magic-string: 1.2.3
|
magic-string: 1.2.3
|
||||||
optionalDependencies:
|
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': {}
|
'@vitest/spy@5.0.0': {}
|
||||||
|
|
||||||
@@ -7115,7 +7254,7 @@ snapshots:
|
|||||||
|
|
||||||
baseline-browser-mapping@2.11.21: {}
|
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:
|
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/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))
|
'@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)
|
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: 19.1.0
|
||||||
react-dom: 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:
|
transitivePeerDependencies:
|
||||||
- '@cloudflare/workers-types'
|
- '@cloudflare/workers-types'
|
||||||
- '@opentelemetry/api'
|
- '@opentelemetry/api'
|
||||||
@@ -8782,6 +8921,10 @@ snapshots:
|
|||||||
|
|
||||||
path-to-regexp@8.4.2: {}
|
path-to-regexp@8.4.2: {}
|
||||||
|
|
||||||
|
pdfjs-dist@6.3.289:
|
||||||
|
optionalDependencies:
|
||||||
|
'@napi-rs/canvas': 1.0.8
|
||||||
|
|
||||||
picocolors@1.1.1: {}
|
picocolors@1.1.1: {}
|
||||||
|
|
||||||
picomatch@2.3.2: {}
|
picomatch@2.3.2: {}
|
||||||
@@ -9111,6 +9254,8 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
server-only@0.0.1: {}
|
||||||
|
|
||||||
set-cookie-parser@3.1.2: {}
|
set-cookie-parser@3.1.2: {}
|
||||||
|
|
||||||
set-function-length@1.2.2:
|
set-function-length@1.2.2:
|
||||||
@@ -9564,7 +9709,7 @@ snapshots:
|
|||||||
|
|
||||||
vary@1.1.2: {}
|
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:
|
dependencies:
|
||||||
lightningcss: 1.33.0
|
lightningcss: 1.33.0
|
||||||
picomatch: 4.0.7
|
picomatch: 4.0.7
|
||||||
@@ -9573,15 +9718,15 @@ snapshots:
|
|||||||
tinyglobby: 0.2.17
|
tinyglobby: 0.2.17
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 20.19.43
|
'@types/node': 20.19.43
|
||||||
esbuild: 0.28.2
|
esbuild: 0.25.12
|
||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
jiti: 2.7.0
|
jiti: 2.7.0
|
||||||
tsx: 4.23.13
|
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:
|
dependencies:
|
||||||
'@types/chai': 5.2.3
|
'@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
|
chai: 6.2.2
|
||||||
es-module-lexer: 2.3.2
|
es-module-lexer: 2.3.2
|
||||||
expect-type: 1.4.0
|
expect-type: 1.4.0
|
||||||
@@ -9592,7 +9737,7 @@ snapshots:
|
|||||||
tinybench: 6.1.4
|
tinybench: 6.1.4
|
||||||
tinyexec: 1.3.0
|
tinyexec: 1.3.0
|
||||||
tinyglobby: 0.2.17
|
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
|
why-is-node-running: 2.3.0
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 20.19.43
|
'@types/node': 20.19.43
|
||||||
|
|||||||
@@ -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.`);
|
||||||
@@ -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.');
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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} />;
|
||||||
|
}
|
||||||
@@ -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} />;
|
||||||
|
}
|
||||||
@@ -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} />;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { toNextJsHandler } from 'better-auth/next-js';
|
||||||
|
import { auth } from '@/lib/auth';
|
||||||
|
|
||||||
|
export const { GET, POST } = toNextJsHandler(auth.handler);
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,130 +1,139 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@import "tw-animate-css";
|
@import "tw-animate-css";
|
||||||
@import "shadcn/tailwind.css";
|
@import "shadcn/tailwind.css";
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
@theme inline {
|
:root {
|
||||||
--color-background: var(--background);
|
--font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
|
||||||
--color-foreground: var(--foreground);
|
"Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji",
|
||||||
--font-sans: var(--font-sans);
|
"Segoe UI Emoji";
|
||||||
--font-mono: var(--font-geist-mono);
|
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
|
||||||
--font-heading: var(--font-sans);
|
"Liberation Mono", monospace;
|
||||||
--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);
|
@theme inline {
|
||||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
--color-background: var(--background);
|
||||||
--color-sidebar-primary: var(--sidebar-primary);
|
--color-foreground: var(--foreground);
|
||||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
--font-sans: var(--font-sans);
|
||||||
--color-sidebar: var(--sidebar);
|
--font-mono: var(--font-mono);
|
||||||
--color-chart-5: var(--chart-5);
|
--font-heading: var(--font-sans);
|
||||||
--color-chart-4: var(--chart-4);
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
--color-chart-3: var(--chart-3);
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
--color-chart-2: var(--chart-2);
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
--color-chart-1: var(--chart-1);
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
--color-ring: var(--ring);
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
--color-input: var(--input);
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
--color-border: var(--border);
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
--color-destructive: var(--destructive);
|
--color-sidebar: var(--sidebar);
|
||||||
--color-accent-foreground: var(--accent-foreground);
|
--color-chart-5: var(--chart-5);
|
||||||
--color-accent: var(--accent);
|
--color-chart-4: var(--chart-4);
|
||||||
--color-muted-foreground: var(--muted-foreground);
|
--color-chart-3: var(--chart-3);
|
||||||
--color-muted: var(--muted);
|
--color-chart-2: var(--chart-2);
|
||||||
--color-secondary-foreground: var(--secondary-foreground);
|
--color-chart-1: var(--chart-1);
|
||||||
--color-secondary: var(--secondary);
|
--color-ring: var(--ring);
|
||||||
--color-primary-foreground: var(--primary-foreground);
|
--color-input: var(--input);
|
||||||
--color-primary: var(--primary);
|
--color-border: var(--border);
|
||||||
--color-popover-foreground: var(--popover-foreground);
|
--color-destructive: var(--destructive);
|
||||||
--color-popover: var(--popover);
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
--color-card-foreground: var(--card-foreground);
|
--color-accent: var(--accent);
|
||||||
--color-card: var(--card);
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
--radius-sm: calc(var(--radius) * 0.6);
|
--color-muted: var(--muted);
|
||||||
--radius-md: calc(var(--radius) * 0.8);
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
--radius-lg: var(--radius);
|
--color-secondary: var(--secondary);
|
||||||
--radius-xl: calc(var(--radius) * 1.4);
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
--radius-2xl: calc(var(--radius) * 1.8);
|
--color-primary: var(--primary);
|
||||||
--radius-3xl: calc(var(--radius) * 2.2);
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
--radius-4xl: calc(var(--radius) * 2.6);
|
--color-popover: var(--popover);
|
||||||
}
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
:root {
|
--radius-sm: calc(var(--radius) * 0.6);
|
||||||
--background: oklch(1 0 0);
|
--radius-md: calc(var(--radius) * 0.8);
|
||||||
--foreground: oklch(0.145 0 0);
|
--radius-lg: var(--radius);
|
||||||
--card: oklch(1 0 0);
|
--radius-xl: calc(var(--radius) * 1.4);
|
||||||
--card-foreground: oklch(0.145 0 0);
|
--radius-2xl: calc(var(--radius) * 1.8);
|
||||||
--popover: oklch(1 0 0);
|
--radius-3xl: calc(var(--radius) * 2.2);
|
||||||
--popover-foreground: oklch(0.145 0 0);
|
--radius-4xl: calc(var(--radius) * 2.6);
|
||||||
--primary: oklch(0.205 0 0);
|
}
|
||||||
--primary-foreground: oklch(0.985 0 0);
|
|
||||||
--secondary: oklch(0.97 0 0);
|
:root {
|
||||||
--secondary-foreground: oklch(0.205 0 0);
|
--background: oklch(1 0 0);
|
||||||
--muted: oklch(0.97 0 0);
|
--foreground: oklch(0.145 0 0);
|
||||||
--muted-foreground: oklch(0.556 0 0);
|
--card: oklch(1 0 0);
|
||||||
--accent: oklch(0.97 0 0);
|
--card-foreground: oklch(0.145 0 0);
|
||||||
--accent-foreground: oklch(0.205 0 0);
|
--popover: oklch(1 0 0);
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
--popover-foreground: oklch(0.145 0 0);
|
||||||
--border: oklch(0.922 0 0);
|
--primary: oklch(0.205 0 0);
|
||||||
--input: oklch(0.922 0 0);
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
--ring: oklch(0.708 0 0);
|
--secondary: oklch(0.97 0 0);
|
||||||
--chart-1: oklch(0.87 0 0);
|
--secondary-foreground: oklch(0.205 0 0);
|
||||||
--chart-2: oklch(0.556 0 0);
|
--muted: oklch(0.97 0 0);
|
||||||
--chart-3: oklch(0.439 0 0);
|
--muted-foreground: oklch(0.556 0 0);
|
||||||
--chart-4: oklch(0.371 0 0);
|
--accent: oklch(0.97 0 0);
|
||||||
--chart-5: oklch(0.269 0 0);
|
--accent-foreground: oklch(0.205 0 0);
|
||||||
--radius: 0.625rem;
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
--sidebar: oklch(0.985 0 0);
|
--border: oklch(0.922 0 0);
|
||||||
--sidebar-foreground: oklch(0.145 0 0);
|
--input: oklch(0.922 0 0);
|
||||||
--sidebar-primary: oklch(0.205 0 0);
|
--ring: oklch(0.708 0 0);
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
--chart-1: oklch(0.87 0 0);
|
||||||
--sidebar-accent: oklch(0.97 0 0);
|
--chart-2: oklch(0.556 0 0);
|
||||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
--chart-3: oklch(0.439 0 0);
|
||||||
--sidebar-border: oklch(0.922 0 0);
|
--chart-4: oklch(0.371 0 0);
|
||||||
--sidebar-ring: oklch(0.708 0 0);
|
--chart-5: oklch(0.269 0 0);
|
||||||
}
|
--radius: 0.625rem;
|
||||||
|
--sidebar: oklch(0.985 0 0);
|
||||||
.dark {
|
--sidebar-foreground: oklch(0.145 0 0);
|
||||||
--background: oklch(0.145 0 0);
|
--sidebar-primary: oklch(0.205 0 0);
|
||||||
--foreground: oklch(0.985 0 0);
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
--card: oklch(0.205 0 0);
|
--sidebar-accent: oklch(0.97 0 0);
|
||||||
--card-foreground: oklch(0.985 0 0);
|
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||||
--popover: oklch(0.205 0 0);
|
--sidebar-border: oklch(0.922 0 0);
|
||||||
--popover-foreground: oklch(0.985 0 0);
|
--sidebar-ring: oklch(0.708 0 0);
|
||||||
--primary: oklch(0.922 0 0);
|
}
|
||||||
--primary-foreground: oklch(0.205 0 0);
|
|
||||||
--secondary: oklch(0.269 0 0);
|
.dark {
|
||||||
--secondary-foreground: oklch(0.985 0 0);
|
--background: oklch(0.145 0 0);
|
||||||
--muted: oklch(0.269 0 0);
|
--foreground: oklch(0.985 0 0);
|
||||||
--muted-foreground: oklch(0.708 0 0);
|
--card: oklch(0.205 0 0);
|
||||||
--accent: oklch(0.269 0 0);
|
--card-foreground: oklch(0.985 0 0);
|
||||||
--accent-foreground: oklch(0.985 0 0);
|
--popover: oklch(0.205 0 0);
|
||||||
--destructive: oklch(0.704 0.191 22.216);
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
--border: oklch(1 0 0 / 10%);
|
--primary: oklch(0.922 0 0);
|
||||||
--input: oklch(1 0 0 / 15%);
|
--primary-foreground: oklch(0.205 0 0);
|
||||||
--ring: oklch(0.556 0 0);
|
--secondary: oklch(0.269 0 0);
|
||||||
--chart-1: oklch(0.87 0 0);
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
--chart-2: oklch(0.556 0 0);
|
--muted: oklch(0.269 0 0);
|
||||||
--chart-3: oklch(0.439 0 0);
|
--muted-foreground: oklch(0.708 0 0);
|
||||||
--chart-4: oklch(0.371 0 0);
|
--accent: oklch(0.269 0 0);
|
||||||
--chart-5: oklch(0.269 0 0);
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
--sidebar: oklch(0.205 0 0);
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
--sidebar-foreground: oklch(0.985 0 0);
|
--border: oklch(1 0 0 / 10%);
|
||||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
--input: oklch(1 0 0 / 15%);
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
--ring: oklch(0.556 0 0);
|
||||||
--sidebar-accent: oklch(0.269 0 0);
|
--chart-1: oklch(0.87 0 0);
|
||||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
--chart-2: oklch(0.556 0 0);
|
||||||
--sidebar-border: oklch(1 0 0 / 10%);
|
--chart-3: oklch(0.439 0 0);
|
||||||
--sidebar-ring: oklch(0.556 0 0);
|
--chart-4: oklch(0.371 0 0);
|
||||||
}
|
--chart-5: oklch(0.269 0 0);
|
||||||
|
--sidebar: oklch(0.205 0 0);
|
||||||
@layer base {
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
* {
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
@apply border-border outline-ring/50;
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
}
|
--sidebar-accent: oklch(0.269 0 0);
|
||||||
body {
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
@apply bg-background text-foreground;
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
}
|
--sidebar-ring: oklch(0.556 0 0);
|
||||||
html {
|
}
|
||||||
@apply font-sans;
|
|
||||||
}
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
html {
|
||||||
|
@apply font-sans;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,33 +1,35 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata, Viewport } from 'next';
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Toaster } from '@/components/ui/sonner';
|
||||||
import "./globals.css";
|
import './globals.css';
|
||||||
|
|
||||||
const geistSans = Geist({
|
|
||||||
variable: "--font-geist-sans",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const geistMono = Geist_Mono({
|
|
||||||
variable: "--font-geist-mono",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: {
|
||||||
description: "Generated by create next app",
|
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({
|
export const viewport: Viewport = {
|
||||||
children,
|
width: 'device-width',
|
||||||
}: Readonly<{
|
initialScale: 1,
|
||||||
children: React.ReactNode;
|
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 (
|
return (
|
||||||
<html lang="en">
|
<html lang="de" suppressHydrationWarning>
|
||||||
<body
|
<body className="min-h-dvh bg-background text-foreground antialiased">
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
|
<Toaster position="top-center" richColors closeButton />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
„iPhone von Papa“.
|
||||||
|
</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';
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,66 +1,66 @@
|
|||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
import { cn } from "cn"
|
import { cn } from "cn"
|
||||||
import { Slot } from "radix-ui"
|
import { Slot } from "radix-ui"
|
||||||
|
|
||||||
const buttonVariants = cva(
|
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",
|
"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: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "bg-primary text-primary-foreground hover:bg-primary/80",
|
default: "bg-primary text-primary-foreground hover:bg-primary/80",
|
||||||
outline:
|
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",
|
"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:
|
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",
|
"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:
|
ghost:
|
||||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||||
destructive:
|
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",
|
"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",
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default:
|
default:
|
||||||
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
"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",
|
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",
|
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-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
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-8",
|
icon: "size-10",
|
||||||
"icon-xs":
|
"icon-xs":
|
||||||
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||||
"icon-sm":
|
"icon-sm":
|
||||||
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
"size-9 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||||
"icon-lg": "size-9",
|
"icon-lg": "size-11",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
variant: "default",
|
variant: "default",
|
||||||
size: "default",
|
size: "default",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
function Button({
|
function Button({
|
||||||
className,
|
className,
|
||||||
variant = "default",
|
variant = "default",
|
||||||
size = "default",
|
size = "default",
|
||||||
asChild = false,
|
asChild = false,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"button"> &
|
}: React.ComponentProps<"button"> &
|
||||||
VariantProps<typeof buttonVariants> & {
|
VariantProps<typeof buttonVariants> & {
|
||||||
asChild?: boolean
|
asChild?: boolean
|
||||||
}) {
|
}) {
|
||||||
const Comp = asChild ? Slot.Root : "button"
|
const Comp = asChild ? Slot.Root : "button"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
data-slot="button"
|
data-slot="button"
|
||||||
data-variant={variant}
|
data-variant={variant}
|
||||||
data-size={size}
|
data-size={size}
|
||||||
className={cn(buttonVariants({ variant, size, className }))}
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Button, buttonVariants }
|
export { Button, buttonVariants }
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,18 +1,18 @@
|
|||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { cn } from "cn"
|
import { cn } from "cn"
|
||||||
|
|
||||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
return (
|
return (
|
||||||
<input
|
<input
|
||||||
type={type}
|
type={type}
|
||||||
data-slot="input"
|
data-slot="input"
|
||||||
className={cn(
|
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",
|
"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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Input }
|
export { Input }
|
||||||
|
|||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,191 +1,191 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { cn } from "cn"
|
import { cn } from "cn"
|
||||||
import { Select as SelectPrimitive } from "radix-ui"
|
import { Select as SelectPrimitive } from "radix-ui"
|
||||||
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
|
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
|
||||||
|
|
||||||
function Select({
|
function Select({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectGroup({
|
function SelectGroup({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||||
return (
|
return (
|
||||||
<SelectPrimitive.Group
|
<SelectPrimitive.Group
|
||||||
data-slot="select-group"
|
data-slot="select-group"
|
||||||
className={cn("scroll-my-1 p-1", className)}
|
className={cn("scroll-my-1 p-1", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectValue({
|
function SelectValue({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectTrigger({
|
function SelectTrigger({
|
||||||
className,
|
className,
|
||||||
size = "default",
|
size = "default",
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||||
size?: "sm" | "default"
|
size?: "sm" | "default"
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<SelectPrimitive.Trigger
|
<SelectPrimitive.Trigger
|
||||||
data-slot="select-trigger"
|
data-slot="select-trigger"
|
||||||
data-size={size}
|
data-size={size}
|
||||||
className={cn(
|
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",
|
"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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<SelectPrimitive.Icon asChild>
|
<SelectPrimitive.Icon asChild>
|
||||||
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
|
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
|
||||||
</SelectPrimitive.Icon>
|
</SelectPrimitive.Icon>
|
||||||
</SelectPrimitive.Trigger>
|
</SelectPrimitive.Trigger>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectContent({
|
function SelectContent({
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
position = "item-aligned",
|
position = "item-aligned",
|
||||||
align = "center",
|
align = "center",
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||||
return (
|
return (
|
||||||
<SelectPrimitive.Portal>
|
<SelectPrimitive.Portal>
|
||||||
<SelectPrimitive.Content
|
<SelectPrimitive.Content
|
||||||
data-slot="select-content"
|
data-slot="select-content"
|
||||||
data-align-trigger={position === "item-aligned"}
|
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 )}
|
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}
|
position={position}
|
||||||
align={align}
|
align={align}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<SelectScrollUpButton />
|
<SelectScrollUpButton />
|
||||||
<SelectPrimitive.Viewport
|
<SelectPrimitive.Viewport
|
||||||
data-position={position}
|
data-position={position}
|
||||||
className={cn(
|
className={cn(
|
||||||
"data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)",
|
"data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)",
|
||||||
position === "popper" && ""
|
position === "popper" && ""
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</SelectPrimitive.Viewport>
|
</SelectPrimitive.Viewport>
|
||||||
<SelectScrollDownButton />
|
<SelectScrollDownButton />
|
||||||
</SelectPrimitive.Content>
|
</SelectPrimitive.Content>
|
||||||
</SelectPrimitive.Portal>
|
</SelectPrimitive.Portal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectLabel({
|
function SelectLabel({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||||
return (
|
return (
|
||||||
<SelectPrimitive.Label
|
<SelectPrimitive.Label
|
||||||
data-slot="select-label"
|
data-slot="select-label"
|
||||||
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
|
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectItem({
|
function SelectItem({
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||||
return (
|
return (
|
||||||
<SelectPrimitive.Item
|
<SelectPrimitive.Item
|
||||||
data-slot="select-item"
|
data-slot="select-item"
|
||||||
className={cn(
|
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",
|
"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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
|
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
|
||||||
<SelectPrimitive.ItemIndicator>
|
<SelectPrimitive.ItemIndicator>
|
||||||
<CheckIcon className="pointer-events-none" />
|
<CheckIcon className="pointer-events-none" />
|
||||||
</SelectPrimitive.ItemIndicator>
|
</SelectPrimitive.ItemIndicator>
|
||||||
</span>
|
</span>
|
||||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
</SelectPrimitive.Item>
|
</SelectPrimitive.Item>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectSeparator({
|
function SelectSeparator({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||||
return (
|
return (
|
||||||
<SelectPrimitive.Separator
|
<SelectPrimitive.Separator
|
||||||
data-slot="select-separator"
|
data-slot="select-separator"
|
||||||
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectScrollUpButton({
|
function SelectScrollUpButton({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||||
return (
|
return (
|
||||||
<SelectPrimitive.ScrollUpButton
|
<SelectPrimitive.ScrollUpButton
|
||||||
data-slot="select-scroll-up-button"
|
data-slot="select-scroll-up-button"
|
||||||
className={cn(
|
className={cn(
|
||||||
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<ChevronUpIcon
|
<ChevronUpIcon
|
||||||
/>
|
/>
|
||||||
</SelectPrimitive.ScrollUpButton>
|
</SelectPrimitive.ScrollUpButton>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectScrollDownButton({
|
function SelectScrollDownButton({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||||
return (
|
return (
|
||||||
<SelectPrimitive.ScrollDownButton
|
<SelectPrimitive.ScrollDownButton
|
||||||
data-slot="select-scroll-down-button"
|
data-slot="select-scroll-down-button"
|
||||||
className={cn(
|
className={cn(
|
||||||
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<ChevronDownIcon
|
<ChevronDownIcon
|
||||||
/>
|
/>
|
||||||
</SelectPrimitive.ScrollDownButton>
|
</SelectPrimitive.ScrollDownButton>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
SelectGroup,
|
SelectGroup,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
SelectLabel,
|
SelectLabel,
|
||||||
SelectScrollDownButton,
|
SelectScrollDownButton,
|
||||||
SelectScrollUpButton,
|
SelectScrollUpButton,
|
||||||
SelectSeparator,
|
SelectSeparator,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||