Author SHA1 Message Date
unknownandClaude Opus 5.5 78ae755761 chore(release): version 0.4.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 15:39:55 +01:00
unknownandClaude Opus 5.5 62045468ce fix(deps): abhaengigkeiten mit sicherheitsluecken aktualisieren
Next.js 15.5.28, nodemailer 10.2, better-auth 1.7.12, sharp 0.35.5,
zod 4.6.7 und drizzle-orm 0.45.4. Transitive Pakete der
Entwicklungswerkzeuge (postcss, vite, shadcn-CLI) per pnpm.overrides.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 15:39:55 +01:00
unknown bac948fcb8 Merge: Designauswahl mit Klemmbaustein-Look 2026-10-25 15:26:27 +01:00
unknown 2d3ddc4dae Merge: update.sh ausfuehrbar einchecken 2026-10-25 15:26:27 +01:00
unknownandClaude Opus 5.5 604a8ac113 fix(sammlung): status-badge gruen statt in der aktionsfarbe
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 15:26:27 +01:00
unknownandClaude Opus 5.5 f2304b28e5 fix(update): update.sh als ausfuehrbar einchecken
Bisher lag das Skript mit Modus 100644 im Repo. Ein chmod +x auf dem
Server galt dann als lokale Aenderung, und update.sh brach vor dem Pull
mit "Es gibt lokale Aenderungen" ab.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 15:20:53 +01:00
unknownandClaude Opus 5.5 d009230dc2 docs: anleitung, changelog und screenshots zum neuen design
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 15:01:33 +01:00
unknownandClaude Opus 5.5 1b825b44c4 feat(design): designauswahl mit klemmbaustein-look als standard
Sechs Designs (Klemmbaustein, Warm, Nachtblau, Bonbon, Bauklotz,
Schlicht) als Tokens je data-design, gewaehlt unter Konto und pro Geraet
im Cookie gespeichert. Dazu Noppen, Steinkontur, Platzhalter fuer Sets
ohne Bild, neues Logo, lokal gebuendelte Schriften und eine Startseite
mit Steinreihe und naechstem Termin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 15:01:33 +01:00
unknownandClaude Opus 5 e9093d0fad Merge: Phase 3 - Nice to have (v0.3.0)
Mehrere Sammler mit gemeinsamem Katalog und getrennter Sammlung,
Preisbeobachtung mit Wertentwicklung, E-Mail-Erinnerungen vor
Wunschlisten-Terminen und die Dublettenpruefung beim Anlegen von Sets.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 14:50:43 +02:00
56 changed files with 2080 additions and 920 deletions
+42 -1
View File
@@ -7,6 +7,46 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/).
## [Unreleased] ## [Unreleased]
## [0.4.0] - 2026-10-25
Ein neues Gesicht: Bricktrack sieht jetzt aus wie das, was es verwaltet.
Dazu alle bekannten Sicherheitslücken in den Abhängigkeiten geschlossen.
### Sicherheit
- Next.js 15.5.28: schließt unter anderem eine Denial-of-Service-Lücke über
Server Actions und Cache-Poisoning bei selbst gehosteten Instanzen. Die
kritische Lücke in der Bildoptimierung war hier nicht ausnutzbar
(`images.unoptimized`), ist aber ebenfalls behoben.
- nodemailer 10.2: mehrere Fehler im Adressparser (Erinnerungs-Mails).
- better-auth 1.7.12, sharp 0.35.5 (librsvg), zod 4.6.7 (Speicherverbrauch
bei übergroßen Eingaben), drizzle-orm 0.45.4.
- Entwicklungswerkzeuge: vitest, vite, postcss, Playwright und die
shadcn-CLI aktualisiert, transitive Pakete per `pnpm.overrides` angehoben.
Übrig bleibt ein mittlerer Fund in einem alten esbuild, das drizzle-kit
intern mitbringt - nur beim Entwickeln, nicht im Image.
### Hinzugefügt
- **Designauswahl** unter Konto → Design: Klemmbaustein (neuer Standard),
Warm, Nachtblau (dunkel), Bonbon, Bauklotz und Schlicht (der bisherige
Look). Die Wahl gilt pro Gerät und wirkt sofort.
- **Platzhalter für Sets ohne Bild**: Farbe des Herstellers, gezeichneter
Stein und Setnummer statt eines grauen „Bild fehlt".
- **Startseite als Überblick**: Steinreihe für den Fortschritt (bei mehreren
Sammlern in deren Farben), Teile, Wert, nächster Wunschlisten-Termin mit
Countdown und Abkürzungen.
- Neues Logo als Baustein, Schriften lokal gebündelt (kein CDN).
### Geändert
- Karten, Buttons und Badges tragen im Standard-Design Konturen, eine feste
Unterkante und Noppen. In der Sammlung und auf der öffentlichen
Wunschliste zeigen die Noppen den Sammler bzw. ob ein Wunsch reserviert ist.
- Die öffentliche Wunschliste hat einen farbigen Kopf mit Anlass und Datum.
- Screenshots der Anleitung zeigen die feste Navigation nicht mehr mitten
im Bild.
## [0.3.0] - 2026-09-07 ## [0.3.0] - 2026-09-07
Phase 3: mehrere Kinder, Wertentwicklung, Erinnerungen und ein Schutz vor Phase 3: mehrere Kinder, Wertentwicklung, Erinnerungen und ein Schutz vor
@@ -194,7 +234,8 @@ Erste lauffaehige Fassung: Phase 1 vollstaendig, im Docker-Stack verifiziert.
- Unter Windows scheitert `pnpm build` an fehlenden Symlink-Rechten. Der - Unter Windows scheitert `pnpm build` an fehlenden Symlink-Rechten. Der
Docker-Build unter Linux ist davon nicht betroffen. Docker-Build unter Linux ist davon nicht betroffen.
[Unreleased]: https://git.wilde-server.de/florian.wilde/bricktrack/compare/v0.3.0...HEAD [Unreleased]: https://git.wilde-server.de/florian.wilde/bricktrack/compare/v0.4.0...HEAD
[0.4.0]: https://git.wilde-server.de/florian.wilde/bricktrack/releases/tag/v0.4.0
[0.3.0]: https://git.wilde-server.de/florian.wilde/bricktrack/releases/tag/v0.3.0 [0.3.0]: https://git.wilde-server.de/florian.wilde/bricktrack/releases/tag/v0.3.0
[0.2.0]: https://git.wilde-server.de/florian.wilde/bricktrack/releases/tag/v0.2.0 [0.2.0]: https://git.wilde-server.de/florian.wilde/bricktrack/releases/tag/v0.2.0
[0.1.0]: https://git.wilde-server.de/florian.wilde/bricktrack/releases/tag/v0.1.0 [0.1.0]: https://git.wilde-server.de/florian.wilde/bricktrack/releases/tag/v0.1.0
+12
View File
@@ -63,6 +63,16 @@ Bild-URLs, die der Nutzer selbst hinterlegt hat.
| `src/lib/reminder-job.ts`, `src/lib/mailer.ts` | Versand und Zeitplan | | `src/lib/reminder-job.ts`, `src/lib/mailer.ts` | Versand und Zeitplan |
| `src/lib/export.ts` | Export und Import des gesamten Bestands | | `src/lib/export.ts` | Export und Import des gesamten Bestands |
| `src/lib/money.ts`, `src/lib/format.ts` | Cent-Umrechnung, dd.MM.yyyy | | `src/lib/money.ts`, `src/lib/format.ts` | Cent-Umrechnung, dd.MM.yyyy |
| `src/lib/design.ts`, `src/app/designs.css` | Designs: Namen und Cookie bzw. Tokens je `data-design` |
| `src/components/brick/` | Noppen, Logo, Platzhalter fuer Sets ohne Bild |
### Designs
Das Design steht als `data-design` am `<html>` (Cookie `bricktrack-design`,
pro Geraet, Standard `klemmbaustein`). Komponenten benutzen nur Tokens
(`bg-primary`, `border-edge`, `--edge-w`, `--lift`, `bg-tone-1` ...), nie feste
Farben - sonst stimmt ein Design nicht mehr. Karten mit Noppen: `<Studs />`
neben die Karte in einen `relative`-Wrapper, die Karte selbst mit `brick`.
## Befehle ## Befehle
@@ -203,6 +213,8 @@ Session.
Einladungen, Export/Import, PWA. *Fertig in 0.2.0.* Einladungen, Export/Import, PWA. *Fertig in 0.2.0.*
- **Phase 3 (Nice to have)** - mehrere Sammler, Preisbeobachtung, - **Phase 3 (Nice to have)** - mehrere Sammler, Preisbeobachtung,
E-Mail-Erinnerungen, Dublettenpruefung. *Fertig in 0.3.0.* E-Mail-Erinnerungen, Dublettenpruefung. *Fertig in 0.3.0.*
- **Design und Sicherheit** - Designauswahl (Standard Klemmbaustein),
Abhaengigkeiten ohne bekannte Luecken. *Fertig in 0.4.0.*
Details und Stand: `docs/ROADMAP.md`. Zeitaufwaende: `docs/DEVLOG.md`. Details und Stand: `docs/ROADMAP.md`. Zeitaufwaende: `docs/DEVLOG.md`.
+31 -6
View File
@@ -32,6 +32,7 @@ Kinderzimmer, nicht für den Schreibtisch.
18. [Erinnerung vor dem Termin](#18-erinnerungen) 18. [Erinnerung vor dem Termin](#18-erinnerungen)
19. [Schutz vor doppelten Einträgen](#19-dubletten) 19. [Schutz vor doppelten Einträgen](#19-dubletten)
20. [Antworten auf die zwei wichtigen Fragen](#20-die-zwei-fragen) 20. [Antworten auf die zwei wichtigen Fragen](#20-die-zwei-fragen)
21. [Ein anderes Design](#21-design)
--- ---
@@ -52,17 +53,20 @@ gleich danach unter „Konto" (siehe [Abschnitt 10](#10-passkeys-einrichten)).
![Startseite](bilder/02-start.png) ![Startseite](bilder/02-start.png)
Die Startseite ist die Abzweigung zu den vier Bereichen: Die Startseite zeigt auf einen Blick, wo die Sammlung steht:
| | | | | |
|---|---| |---|---|
| **Katalog** | Alle bekannten Sets – egal ob vorhanden oder nicht | | **Steinreihe** | Zehn Plätze für den ganzen Katalog. Jeder volle Stein ist ein Zehntel, das schon da ist – bei mehreren Kindern in deren Farbe |
| **Fehlende Sets** | Der direkte Weg zu allem, was noch fehlt | | **Teile und Wert** | Alle verbauten Teile und was die Sammlung heute wert ist |
| **Sammlung** | Was tatsächlich da ist | | **Nächster Termin** | Die nächste Wunschliste mit Datum, wie viele Tage noch bleiben und wie viel offen ist |
| **Wunschlisten** | Zum Teilen mit Familie und Freunden | | **Abkürzungen** | Direkt zu den fehlenden Sets, zu den Wunschlisten oder zu einem neuen Set |
Unten am Bildschirmrand liegt die Navigation – auf dem Handy mit dem Daumen Unten am Bildschirmrand liegt die Navigation – auf dem Handy mit dem Daumen
erreichbar. erreichbar. Der aktive Bereich sitzt auf einer kleinen Platte.
Sets ohne Bild bekommen einen farbigen Platzhalter mit der Setnummer. Jeder
Hersteller hat seine eigene Farbe – Lego gelb, Playmobil blau, Cobi grün.
--- ---
@@ -548,6 +552,27 @@ Haken dran, habt ihr es schon.
--- ---
## 21. Design
![Startseite im Design Nachtblau](bilder/25-design-nachtblau.png)
Unter **Konto → Design** gibt es sechs Looks zur Auswahl:
| Design | |
|---|---|
| **Klemmbaustein** | Der Standard: Karten mit Noppen, dunkle Konturen, kräftige Steinfarben |
| **Warm** | Ruhiger Sandton mit Ziegelrot |
| **Nachtblau** | Dunkel mit gelbem Akzent – angenehm am Abend |
| **Bonbon** | Weiches Lila mit Pastellfarben |
| **Bauklotz** | Primärfarben und dicke schwarze Konturen |
| **Schlicht** | Neutrales Grau, so wie Bricktrack bis Version 0.3 aussah |
Die Auswahl wirkt sofort und gilt **für das Gerät**, nicht für das Konto: Das
Tablet im Kinderzimmer kann bunt sein, während das Handy dunkel bleibt.
Öffentliche Wunschlisten sehen Gäste immer im Standard-Design.
---
## Kleinigkeiten, die den Alltag leichter machen ## Kleinigkeiten, die den Alltag leichter machen
- **Filter stecken in der URL.** Ein Lesezeichen auf - **Filter stecken in der URL.** Ein Lesezeichen auf
+10
View File
@@ -59,6 +59,16 @@ Laeuft vollstaendig im Docker-Stack.
rueckt und Eintraege unreserviert sind rueckt und Eintraege unreserviert sind
- [x] Dublettenpruefung beim Anlegen von Sets - [x] Dublettenpruefung beim Anlegen von Sets
## Design und Sicherheit (v0.4.0, abgeschlossen am 25.10.2026)
- [x] Abhängigkeiten mit bekannten Sicherheitslücken aktualisiert
- [x] Designauswahl pro Gerät, sechs Designs, Standard „Klemmbaustein"
- [x] Platzhalter für Sets ohne Bild, neues Logo, Schriften lokal
- [x] Startseite als Überblick mit Steinreihe und nächstem Termin
- [ ] Filter im Katalog als Reiter statt Auswahlfelder
- [ ] Scan-Button mittig in der unteren Navigation
## Danach ## Danach
Phase 1 bis 3 sind umgesetzt. Was noch offen ist, steht hier - ohne Termin Phase 1 bis 3 sind umgesetzt. Was noch offen ist, steht hier - ohne Termin
Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 294 KiB

After

Width:  |  Height:  |  Size: 435 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 204 KiB

After

Width:  |  Height:  |  Size: 322 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 211 KiB

After

Width:  |  Height:  |  Size: 361 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 212 KiB

After

Width:  |  Height:  |  Size: 446 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 224 KiB

After

Width:  |  Height:  |  Size: 329 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 198 KiB

After

Width:  |  Height:  |  Size: 322 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 326 KiB

After

Width:  |  Height:  |  Size: 475 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 155 KiB

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 244 KiB

After

Width:  |  Height:  |  Size: 597 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 343 KiB

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 328 KiB

After

Width:  |  Height:  |  Size: 476 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 283 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 211 KiB

After

Width:  |  Height:  |  Size: 299 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 257 KiB

After

Width:  |  Height:  |  Size: 297 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 238 KiB

After

Width:  |  Height:  |  Size: 368 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 224 KiB

After

Width:  |  Height:  |  Size: 336 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 185 KiB

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

+17
View File
@@ -19,6 +19,9 @@ function shotPath(name: string) {
async function shoot(page: Page, name: string) { async function shoot(page: Page, name: string) {
// Let fonts and images settle so the picture is not half-rendered. // Let fonts and images settle so the picture is not half-rendered.
await page.waitForLoadState('networkidle'); await page.waitForLoadState('networkidle');
// A fixed bottom bar would be painted where the first viewport ends, in the
// middle of a full-page picture. In flow it lands at the end, where it belongs.
await page.addStyleTag({ content: '.fixed.bottom-0 { position: static !important; }' });
await page.screenshot({ path: shotPath(name), fullPage: true }); await page.screenshot({ path: shotPath(name), fullPage: true });
} }
@@ -117,6 +120,8 @@ test('Screenshots fuer die Anleitung', async ({ page, browser }) => {
await visitor.getByRole('button', { name: 'Reservieren' }).click(); await visitor.getByRole('button', { name: 'Reservieren' }).click();
await expect(visitor.getByText('Reservierung gespeichert')).toBeVisible(); await expect(visitor.getByText('Reservierung gespeichert')).toBeVisible();
await visitor.getByRole('button', { name: 'Alles klar' }).click(); await visitor.getByRole('button', { name: 'Alles klar' }).click();
// Otherwise the picture catches the dialog halfway through fading out.
await expect(visitor.getByRole('dialog')).toBeHidden();
await shoot(visitor, '14-reserviert'); await shoot(visitor, '14-reserviert');
await visitorContext.close(); await visitorContext.close();
@@ -129,6 +134,18 @@ test('Screenshots fuer die Anleitung', async ({ page, browser }) => {
await expect(page.getByText('Passkeys', { exact: true })).toBeVisible(); await expect(page.getByText('Passkeys', { exact: true })).toBeVisible();
await shoot(page, '16-konto-passkeys'); await shoot(page, '16-konto-passkeys');
// The same start page in the dark design, then back to the default.
await page.getByText('Nachtblau').click();
await expect(page.locator('html')).toHaveAttribute('data-design', 'nacht');
// The look switches at once; the cookie follows with the server action.
await page.waitForLoadState('networkidle');
await visit(page, '/');
await expect(page.locator('html')).toHaveAttribute('data-design', 'nacht');
await shoot(page, '25-design-nachtblau');
await visit(page, '/konto');
await page.getByText('Klemmbaustein').click();
await expect(page.locator('html')).toHaveAttribute('data-design', 'klemmbaustein');
// --- 12. Lego import ------------------------------------------------------ // --- 12. Lego import ------------------------------------------------------
// The screenshot container runs with a placeholder API key so the manual // The screenshot container runs with a placeholder API key so the manual
// shows the form, not the "not configured" hint. Nothing is submitted. // shows the form, not the "not configured" hint. Nothing is submitted.
+36 -15
View File
@@ -1,6 +1,6 @@
{ {
"name": "bricktrack", "name": "bricktrack",
"version": "0.1.0", "version": "0.4.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",
@@ -23,33 +23,39 @@
"screenshots": "node scripts/screenshots-prepare.mjs && playwright test --config=playwright.screenshots.config.ts" "screenshots": "node scripts/screenshots-prepare.mjs && playwright test --config=playwright.screenshots.config.ts"
}, },
"dependencies": { "dependencies": {
"@better-auth/passkey": "^1.7.3", "@better-auth/passkey": "^1.7.12",
"@fontsource-variable/archivo": "^5.3.0",
"@fontsource-variable/bricolage-grotesque": "^5.3.0",
"@fontsource-variable/fredoka": "^5.3.0",
"@fontsource-variable/inter": "^5.3.0",
"@fontsource-variable/nunito": "^5.3.0",
"@fontsource-variable/outfit": "^5.3.0",
"@zxing/browser": "^0.2.1", "@zxing/browser": "^0.2.1",
"@zxing/library": "^0.23.0", "@zxing/library": "^0.23.0",
"better-auth": "^1.7.3", "better-auth": "^1.7.12",
"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.4",
"fflate": "^0.8.3", "fflate": "^0.8.3",
"lucide-react": "^1.41.0", "lucide-react": "^1.41.0",
"next": "15.5.25", "next": "15.5.28",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"nodemailer": "^10.0.0", "nodemailer": "^10.2.1",
"pdfjs-dist": "^6.3.289", "pdfjs-dist": "^6.3.289",
"radix-ui": "^1.6.7", "radix-ui": "^1.6.7",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"server-only": "^0.0.1", "server-only": "^0.0.1",
"sharp": "^0.35.4", "sharp": "^0.35.5",
"sonner": "^2.0.8", "sonner": "^2.0.8",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"zod": "^4.5.4" "zod": "^4.6.7"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3", "@eslint/eslintrc": "^3",
"@playwright/test": "^1.63.0", "@playwright/test": "^1.64.1",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/better-sqlite3": "^9.6.0", "@types/better-sqlite3": "^9.6.0",
"@types/node": "^20", "@types/node": "^20",
@@ -57,15 +63,16 @@
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"drizzle-kit": "^0.31.10", "drizzle-kit": "^0.31.11",
"esbuild": "^0.25.12", "esbuild": "^0.25.12",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "15.5.25", "eslint-config-next": "15.5.28",
"shadcn": "^4.21.0", "shadcn": "^4.22.1",
"tailwindcss": "^4", "tailwindcss": "^4",
"tsx": "^4.23.13", "tsx": "^4.23.15",
"typescript": "^5", "typescript": "^5",
"vitest": "^5.0.0" "vite": "^8.3.5",
"vitest": "^5.0.5"
}, },
"pnpm": { "pnpm": {
"onlyBuiltDependencies": [ "onlyBuiltDependencies": [
@@ -73,6 +80,20 @@
"esbuild", "esbuild",
"sharp", "sharp",
"unrs-resolver" "unrs-resolver"
] ],
"overrides": {
"postcss": "^8.5.31",
"vite": "^8.2.4",
"source-map-js": "^1.2.2",
"brace-expansion@<2": "^1.1.21",
"brace-expansion@>=4 <6": "^5.0.12",
"@modelcontextprotocol/sdk": "^1.32.2",
"undici@7": "^7.30.1",
"proxy-addr": "^2.0.8",
"body-parser@2": "^2.4.0",
"fast-uri@3": "^3.1.9",
"hono": "^4.13.11",
"ip-address": "^10.7.4"
}
} }
} }
+655 -638
View File
File diff suppressed because it is too large Load Diff
+13 -5
View File
@@ -1,7 +1,9 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import Link from 'next/link'; import Link from 'next/link';
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
import { Check, ExternalLink, Gift, ImageOff, Pencil } from 'lucide-react'; import { Check, ExternalLink, Gift, Pencil } from 'lucide-react';
import { SetPlaceholder } from '@/components/brick/set-placeholder';
import { Studs } from '@/components/brick/studs';
import { InstructionsSection } from '@/components/katalog/instructions-section'; import { InstructionsSection } from '@/components/katalog/instructions-section';
import { PhotosSection } from '@/components/katalog/photos-section'; import { PhotosSection } from '@/components/katalog/photos-section';
import { PriceSection } from '@/components/katalog/price-section'; import { PriceSection } from '@/components/katalog/price-section';
@@ -114,16 +116,22 @@ export default async function SetDetailPage({ params, searchParams }: Props) {
</div> </div>
<div className="grid gap-6 md:grid-cols-[minmax(0,320px)_1fr]"> <div className="grid gap-6 md:grid-cols-[minmax(0,320px)_1fr]">
<div className="overflow-hidden rounded-xl border border-border bg-muted"> <div className="relative self-start">
<Studs count={4} />
<div className="brick overflow-hidden rounded-xl bg-muted">
{image ? ( {image ? (
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
<img src={image} alt="" className="aspect-square w-full object-contain p-3" /> <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"> <SetPlaceholder
<ImageOff className="size-10" aria-hidden /> setNumber={set.setNumber}
</div> manufacturerName={set.manufacturerName}
size="lg"
className="aspect-square"
/>
)} )}
</div> </div>
</div>
<div className="space-y-4"> <div className="space-y-4">
<SetActions <SetActions
+28 -1
View File
@@ -1,12 +1,16 @@
'use server'; 'use server';
import { revalidatePath } from 'next/cache'; import { revalidatePath } from 'next/cache';
import { headers } from 'next/headers'; import { cookies, headers } from 'next/headers';
import { z } from 'zod'; import { z } from 'zod';
import { env } from '@/env';
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
import { DESIGN_COOKIE, DESIGNS } from '@/lib/design';
import { requireUser } from '@/lib/session';
const idSchema = z.object({ id: z.string().min(1) }); const idSchema = z.object({ id: z.string().min(1) });
const renameSchema = idSchema.extend({ name: z.string().trim().min(1).max(60) }); const renameSchema = idSchema.extend({ name: z.string().trim().min(1).max(60) });
const designSchema = z.object({ design: z.enum(DESIGNS) });
export type ActionResult = { ok: true } | { ok: false; error: string }; export type ActionResult = { ok: true } | { ok: false; error: string };
@@ -43,3 +47,26 @@ export async function renamePasskeyAction(input: unknown): Promise<ActionResult>
revalidatePath('/konto'); revalidatePath('/konto');
return { ok: true }; return { ok: true };
} }
/**
* The design is a per-device choice: a cookie, not a column on the user. That
* way it also covers the login page and lets two children's tablets look
* different.
*/
export async function setDesignAction(input: unknown): Promise<ActionResult> {
await requireUser();
const parsed = designSchema.safeParse(input);
if (!parsed.success) return { ok: false, error: 'Unbekanntes Design.' };
(await cookies()).set(DESIGN_COOKIE, parsed.data.design, {
httpOnly: true,
secure: env.isProduction,
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 24 * 365,
});
revalidatePath('/', 'layout');
return { ok: true };
}
+16 -1
View File
@@ -1,9 +1,11 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { headers } from 'next/headers'; import { cookies, headers } from 'next/headers';
import { PasskeyManager, type PasskeyRow } from '@/components/auth/passkey-manager'; import { PasskeyManager, type PasskeyRow } from '@/components/auth/passkey-manager';
import { DesignPicker } from '@/components/konto/design-picker';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
import { DESIGN_COOKIE, resolveDesign } from '@/lib/design';
import { requireUser } from '@/lib/session'; import { requireUser } from '@/lib/session';
export const metadata: Metadata = { title: 'Konto' }; export const metadata: Metadata = { title: 'Konto' };
@@ -17,6 +19,7 @@ export default async function AccountPage() {
name: passkey.name ?? null, name: passkey.name ?? null,
createdAt: passkey.createdAt ? new Date(passkey.createdAt) : null, createdAt: passkey.createdAt ? new Date(passkey.createdAt) : null,
})); }));
const design = resolveDesign((await cookies()).get(DESIGN_COOKIE)?.value);
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -45,6 +48,18 @@ export default async function AccountPage() {
</CardContent> </CardContent>
</Card> </Card>
<Card>
<CardHeader>
<CardTitle>Design</CardTitle>
<CardDescription>
Gilt für dieses Gerät. Öffentliche Wunschlisten sehen Gäste im Standard-Design.
</CardDescription>
</CardHeader>
<CardContent>
<DesignPicker current={design} />
</CardContent>
</Card>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Passkeys</CardTitle> <CardTitle>Passkeys</CardTitle>
+171 -43
View File
@@ -1,58 +1,186 @@
import Link from 'next/link'; import Link from 'next/link';
import { Boxes, Gift, LayoutGrid, PackageSearch } from 'lucide-react'; import { ChevronRight, Gift, PackageSearch, Plus } from 'lucide-react';
import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { cn } from 'cn';
import { Studs } from '@/components/brick/studs';
import { Button } from '@/components/ui/button';
import { brickRow } from '@/lib/brick-row';
import { collectorDotClass } from '@/lib/collector-colors';
import { formatDate, formatNumber, pluralize } from '@/lib/format';
import { formatEuro } from '@/lib/money';
import { countMissingSets, getCollectionStats } from '@/lib/queries/collection';
import { countItemsPerCollector, listCollectors } from '@/lib/queries/collectors';
import { listWishlists } from '@/lib/queries/wishlists';
import { daysUntil } from '@/lib/reminders';
import { requireUser } from '@/lib/session'; import { requireUser } from '@/lib/session';
const shortcuts = [ function daysLabel(days: number): string {
{ if (days === 0) return 'heute';
href: '/katalog', if (days === 1) return 'morgen';
icon: LayoutGrid, return `in ${days} Tagen`;
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() { export default async function DashboardPage() {
const user = await requireUser(); const user = await requireUser();
const stats = getCollectionStats();
const missing = countMissingSets();
const totalSets = stats.setCount + missing;
const collectors = listCollectors();
const perCollector = countItemsPerCollector();
const showCollectors = collectors.length > 1;
const bricks = brickRow(
stats.setCount,
totalSets,
showCollectors
? collectors.map((entry) => ({ key: entry.id, count: perCollector.get(entry.id) ?? 0 }))
: [],
);
const colorById = new Map(collectors.map((entry) => [entry.id, entry.color]));
// The next date someone could bring a present for.
const now = new Date();
const nextEvent =
listWishlists(user.id)
.filter((list) => list.eventDate && daysUntil(list.eventDate, now) >= 0)
.sort((a, b) => a.eventDate!.getTime() - b.eventDate!.getTime())[0] ?? null;
return ( return (
<div className="space-y-6"> <div className="mx-auto max-w-2xl space-y-7">
<div> <h1 className="text-3xl font-semibold tracking-tight">Hallo {user.name}!</h1>
<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> <section className="relative">
<Studs count={3} />
<div className="brick rounded-xl bg-card p-4 text-card-foreground">
<div className="flex items-baseline justify-between gap-3">
<h2 className="text-lg font-semibold">Unsere Sammlung</h2>
<p className="text-sm font-semibold text-muted-foreground">
{formatNumber(stats.setCount)} von {pluralize(totalSets, 'Set', 'Sets')}
</p>
</div> </div>
<div className="grid gap-3 sm:grid-cols-2"> <ol
{shortcuts.map((shortcut) => ( aria-label={`${formatNumber(stats.setCount)} von ${formatNumber(totalSets)} Sets vorhanden`}
<Link key={shortcut.href} href={shortcut.href} className="group"> className="mt-5 grid grid-cols-10 items-end gap-1.5"
<Card className="h-full transition-colors group-hover:border-primary/40"> >
<CardHeader> {bricks.map((key, index) => (
<shortcut.icon className="size-6 text-primary" aria-hidden /> <li key={index} aria-hidden className="relative">
<CardTitle>{shortcut.title}</CardTitle> {key === null ? (
<CardDescription>{shortcut.description}</CardDescription> <span className="block h-7 rounded-[6px] border-2 border-dashed border-muted-foreground/50" />
</CardHeader> ) : (
</Card> <>
</Link> <span
className={cn(
'stud mx-auto mb-[calc(var(--edge-w)*-1)] !h-1.5 !w-3 !rounded-t-[4px] [display:var(--studs)]',
key ? collectorDotClass(colorById.get(key)) : 'bg-secondary',
)}
/>
<span
className={cn(
'block h-7 rounded-[6px] border-[length:var(--edge-w)] border-edge',
key ? collectorDotClass(colorById.get(key)) : 'bg-secondary',
)}
/>
</>
)}
</li>
))} ))}
</ol>
{showCollectors ? (
<ul className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs font-semibold">
{collectors.map((entry) => (
<li key={entry.id} className="flex items-center gap-1.5">
<span
aria-hidden
className={cn(
'size-3 rounded-[3px] border-[length:var(--edge-w)] border-edge',
collectorDotClass(entry.color),
)}
/>
{entry.name}
</li>
))}
<li className="flex items-center gap-1.5 text-muted-foreground">
<span
aria-hidden
className="size-3 rounded-[3px] border-2 border-dashed border-muted-foreground/50"
/>
fehlt noch
</li>
</ul>
) : null}
</div> </div>
</section>
<section className="grid grid-cols-2 gap-3">
<div className="relative">
<Studs studClassName="bg-secondary" />
<div className="brick h-full rounded-xl bg-secondary p-4 text-secondary-foreground">
<p className="text-2xl font-bold tabular-nums [font-family:var(--font-display)]">
{formatNumber(stats.pieceCount)}
</p>
<p className="text-sm font-semibold">Teile verbaut</p>
</div>
</div>
<div className="relative">
<Studs studClassName="bg-owned" />
<div className="brick h-full rounded-xl bg-owned p-4 text-owned-foreground">
<p className="text-2xl font-bold tabular-nums [font-family:var(--font-display)]">
{formatEuro(stats.currentValueCents)}
</p>
<p className="text-sm font-semibold">
{stats.currentValueCents === stats.retailValueCents ? 'Wert nach UVP' : 'Aktueller Wert'}
</p>
</div>
</div>
</section>
{nextEvent?.eventDate ? (
<Link href={`/wunschlisten/${nextEvent.id}`} className="relative block rounded-xl">
<Studs count={4} studClassName="bg-primary" />
<div className="brick flex items-center gap-4 rounded-xl bg-primary p-4 text-primary-foreground">
<div className="flex size-14 shrink-0 flex-col items-center justify-center rounded-lg border-[length:var(--edge-w)] border-edge bg-card text-card-foreground">
<span className="text-xl leading-none font-bold [font-family:var(--font-display)]">
{formatDate(nextEvent.eventDate).slice(0, 5)}
</span>
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-lg font-semibold [font-family:var(--font-display)]">
{nextEvent.title}
</p>
<p className="text-sm font-semibold">
{daysLabel(daysUntil(nextEvent.eventDate, now))} ·{' '}
{nextEvent.openCount === 1
? '1 Wunsch noch offen'
: `${formatNumber(nextEvent.openCount)} Wünsche noch offen`}
</p>
</div>
<ChevronRight className="size-5 shrink-0" aria-hidden />
</div>
</Link>
) : null}
<section className="flex flex-wrap gap-3">
<Button asChild variant="outline" size="lg">
<Link href="/katalog?besitz=fehlt">
<PackageSearch aria-hidden />
{missing === 1 ? '1 Set fehlt noch' : `${formatNumber(missing)} Sets fehlen noch`}
</Link>
</Button>
<Button asChild variant="outline" size="lg">
<Link href="/wunschlisten">
<Gift aria-hidden />
Wunschlisten
</Link>
</Button>
<Button asChild size="lg">
<Link href="/katalog/neu">
<Plus aria-hidden />
Set anlegen
</Link>
</Button>
</section>
</div> </div>
); );
} }
+24 -10
View File
@@ -1,12 +1,14 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import Link from 'next/link'; import Link from 'next/link';
import { Boxes, Euro, ImageOff, Layers, PackageSearch } from 'lucide-react'; import { Boxes, Euro, Layers, PackageSearch } from 'lucide-react';
import { SetPlaceholder } from '@/components/brick/set-placeholder';
import { Studs } from '@/components/brick/studs';
import { CollectorFilter } from '@/components/layout/collector-filter'; import { CollectorFilter } from '@/components/layout/collector-filter';
import { CollectionFilters } from '@/components/sammlung/collection-filters'; import { CollectionFilters } from '@/components/sammlung/collection-filters';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { formatDate, formatNumber, pluralize } from '@/lib/format'; import { formatDate, formatNumber, pluralize } from '@/lib/format';
import { collectorBadgeClass } from '@/lib/collector-colors'; import { collectorBadgeClass, collectorDotClass } from '@/lib/collector-colors';
import { setImageSrc } from '@/lib/images'; import { setImageSrc } from '@/lib/images';
import { completenessLabel, statusLabel } from '@/lib/labels'; import { completenessLabel, statusLabel } from '@/lib/labels';
import { formatEuro } from '@/lib/money'; import { formatEuro } from '@/lib/money';
@@ -122,23 +124,31 @@ export default async function SammlungPage({ searchParams }: { searchParams: Sea
</Link> </Link>
</div> </div>
) : ( ) : (
<ul className="divide-y divide-border rounded-lg border border-border"> <ul className="space-y-5 pt-2">
{items.map((item) => { {items.map((item) => {
const image = setImageSrc(item); const image = setImageSrc(item);
return ( return (
<li key={item.id}> <li key={item.id} className="relative">
{/* The studs carry the collector's colour once there is more than one. */}
<Studs
studClassName={
collectors.length > 1 ? collectorDotClass(item.collectorColor) : undefined
}
/>
<Link <Link
href={`/katalog/${item.setId}`} href={`/katalog/${item.setId}`}
className="flex items-center gap-3 p-3 transition-colors hover:bg-muted/50" className="brick flex items-center gap-3 rounded-xl bg-card p-3 transition-colors hover:bg-muted/50"
> >
<div className="size-14 shrink-0 overflow-hidden rounded-lg border border-border bg-muted"> <div className="size-16 shrink-0 overflow-hidden rounded-lg border-[length:max(1px,var(--edge-w))] border-edge bg-muted">
{image ? ( {image ? (
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
<img src={image} alt="" loading="lazy" className="size-full object-contain" /> <img src={image} alt="" loading="lazy" className="size-full object-contain" />
) : ( ) : (
<div className="flex size-full items-center justify-center text-muted-foreground"> <SetPlaceholder
<ImageOff className="size-5" aria-hidden /> setNumber={item.setNumber}
</div> manufacturerName={item.manufacturerName}
size="sm"
/>
)} )}
</div> </div>
@@ -150,7 +160,11 @@ export default async function SammlungPage({ searchParams }: { searchParams: Sea
.join(' · ')} .join(' · ')}
</p> </p>
<div className="mt-1 flex flex-wrap items-center gap-1"> <div className="mt-1 flex flex-wrap items-center gap-1">
<Badge variant={item.status === 'sold' ? 'outline' : 'default'}> {/* Green like "Vorhanden" in the catalog; red stays for actions. */}
<Badge
variant={item.status === 'sold' ? 'outline' : 'default'}
className={item.status === 'sold' ? undefined : 'bg-owned text-owned-foreground'}
>
{statusLabel(item.status)} {statusLabel(item.status)}
</Badge> </Badge>
{item.completeness !== 'unknown' ? ( {item.completeness !== 'unknown' ? (
+6 -3
View File
@@ -1,11 +1,14 @@
import Link from 'next/link'; import Link from 'next/link';
import { Blocks } from 'lucide-react'; import { BrickLogo } from '@/components/brick/logo';
export default function AuthLayout({ children }: { children: React.ReactNode }) { export default function AuthLayout({ children }: { children: React.ReactNode }) {
return ( return (
<div className="flex min-h-dvh flex-col items-center justify-center gap-8 px-4 py-10"> <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"> <Link
<Blocks className="size-6 text-primary" aria-hidden /> href="/"
className="flex items-center gap-2 text-2xl font-bold tracking-tight [font-family:var(--font-display)]"
>
<BrickLogo className="h-8 w-9" />
Bricktrack Bricktrack
</Link> </Link>
<main className="w-full max-w-sm">{children}</main> <main className="w-full max-w-sm">{children}</main>
+301
View File
@@ -0,0 +1,301 @@
/*
* Selectable designs. Each one is a set of tokens under :root[data-design=...]
* (the attribute is set in the root layout from a cookie, see src/lib/design.ts).
* The neutral "schlicht" design uses the defaults from globals.css.
*
* Token additions on top of shadcn:
* --font-display headings and big numbers
* --edge, --edge-w outline of cards, buttons and badges
* --lift(-sm) the hard bottom edge that makes a card look like a brick
* --studs "flex" shows the studs on top of cards, "none" hides them
* --tone-1..5 placeholder colours for sets without an image
* --owned the "Vorhanden" badge
*/
/* --- Klemmbaustein (default) --------------------------------------------- */
:root[data-design='klemmbaustein'] {
--font-sans: 'Nunito Variable', ui-sans-serif, system-ui, sans-serif;
--font-display: 'Fredoka Variable', ui-sans-serif, system-ui, sans-serif;
--radius: 1rem;
--background: #fff6e5;
--foreground: #24203f;
--card: #ffffff;
--card-foreground: #24203f;
--popover: #ffffff;
--popover-foreground: #24203f;
--primary: #d63a30;
--primary-foreground: #ffffff;
--secondary: #ffc93c;
--secondary-foreground: #24203f;
--muted: #f6ead0;
--muted-foreground: #5f5a78;
--accent: #ffe9b0;
--accent-foreground: #24203f;
--destructive: #c62828;
--border: rgb(36 32 63 / 0.16);
--input: #24203f;
--ring: #2f6bea;
--logo: #ffc93c;
--edge: #24203f;
--edge-w: 2px;
--lift: 0 4px 0 #24203f;
--lift-sm: 0 3px 0 #24203f;
--studs: flex;
--owned: #2ebd85;
--owned-foreground: #24203f;
--tone-ink: #24203f;
--tone-1: #ffe08a;
--tone-2: #bfd6ff;
--tone-3: #bdebd3;
--tone-4: #ffc9d9;
--tone-5: #e2d6ff;
}
/* --- Warm ---------------------------------------------------------------- */
:root[data-design='warm'] {
--font-sans: 'Inter Variable', ui-sans-serif, system-ui, sans-serif;
--font-display: 'Bricolage Grotesque Variable', ui-sans-serif, system-ui, sans-serif;
--radius: 0.875rem;
--background: #f6f3ec;
--foreground: #1d1b17;
--card: #ffffff;
--card-foreground: #1d1b17;
--popover: #ffffff;
--popover-foreground: #1d1b17;
--primary: #c8381f;
--primary-foreground: #ffffff;
--secondary: #fbe3a6;
--secondary-foreground: #1d1b17;
--muted: #ece6da;
--muted-foreground: #5f594f;
--accent: #fbe3a6;
--accent-foreground: #1d1b17;
--destructive: #b42318;
--border: #e2dbcd;
--input: #d6cebf;
--ring: #c8381f;
--edge: transparent;
--edge-w: 0px;
--lift: 0 1px 2px rgb(29 27 23 / 0.06);
--lift-sm: none;
--studs: none;
--owned: #1f7a4d;
--owned-foreground: #ffffff;
--tone-ink: #1d1b17;
--tone-1: #fbe3a6;
--tone-2: #d6e6f5;
--tone-3: #dde8d2;
--tone-4: #f6d5cc;
--tone-5: #e5ddf0;
}
/* --- Nachtblau (dark) ---------------------------------------------------- */
:root[data-design='nacht'] {
color-scheme: dark;
--font-sans: 'Inter Variable', ui-sans-serif, system-ui, sans-serif;
--font-display: 'Outfit Variable', ui-sans-serif, system-ui, sans-serif;
--radius: 1.125rem;
--background: #0e1422;
--foreground: #f2f4f8;
--card: #182136;
--card-foreground: #f2f4f8;
--popover: #1b2540;
--popover-foreground: #f2f4f8;
--primary: #ffc93c;
--primary-foreground: #1a1300;
--secondary: #222d45;
--secondary-foreground: #f2f4f8;
--muted: #222d45;
--muted-foreground: #a3adc2;
--accent: #2a3654;
--accent-foreground: #f2f4f8;
--destructive: #f87171;
--border: #2c3854;
--input: #34405e;
--ring: #ffc93c;
--edge: transparent;
--edge-w: 0px;
--lift: none;
--lift-sm: none;
--studs: none;
--owned: #1e4d3a;
--owned-foreground: #8fe3b8;
--tone-ink: #f2f4f8;
--tone-1: #3a3218;
--tone-2: #1b2c44;
--tone-3: #22331e;
--tone-4: #3d2230;
--tone-5: #2c2546;
}
/* --- Bonbon -------------------------------------------------------------- */
:root[data-design='bonbon'] {
--font-sans: 'Nunito Variable', ui-sans-serif, system-ui, sans-serif;
--font-display: 'Fredoka Variable', ui-sans-serif, system-ui, sans-serif;
--radius: 1.5rem;
--background: #f4f0ff;
--foreground: #23194a;
--card: #ffffff;
--card-foreground: #23194a;
--popover: #ffffff;
--popover-foreground: #23194a;
--primary: #6d4aff;
--primary-foreground: #ffffff;
--secondary: #ede7ff;
--secondary-foreground: #4b2fc4;
--muted: #ede7ff;
--muted-foreground: #5e567c;
--accent: #ede7ff;
--accent-foreground: #23194a;
--destructive: #c62828;
--border: #e4ddf7;
--input: #d8cff3;
--ring: #6d4aff;
--edge: transparent;
--edge-w: 0px;
--lift: 0 8px 24px rgb(35 25 74 / 0.08);
--lift-sm: none;
--studs: none;
--owned: #157a57;
--owned-foreground: #ffffff;
--tone-ink: #23194a;
--tone-1: #fff1c7;
--tone-2: #dff0ff;
--tone-3: #ddf7ea;
--tone-4: #ffe1d1;
--tone-5: #ffddeb;
}
/* --- Bauklotz ------------------------------------------------------------ */
:root[data-design='bauklotz'] {
--font-sans: 'Archivo Variable', ui-sans-serif, system-ui, sans-serif;
--font-display: 'Archivo Variable', ui-sans-serif, system-ui, sans-serif;
--radius: 0.75rem;
--background: #fffbef;
--foreground: #111111;
--card: #ffffff;
--card-foreground: #111111;
--popover: #ffffff;
--popover-foreground: #111111;
--primary: #1e5eff;
--primary-foreground: #ffffff;
--secondary: #ffd500;
--secondary-foreground: #111111;
--muted: #f3eedc;
--muted-foreground: #4a4a4a;
--accent: #ffd500;
--accent-foreground: #111111;
--destructive: #c62828;
--border: rgb(17 17 17 / 0.2);
--input: #111111;
--ring: #1e5eff;
--logo: #ffd500;
--edge: #111111;
--edge-w: 2.5px;
--lift: 4px 4px 0 #111111;
--lift-sm: 3px 3px 0 #111111;
--studs: none;
--owned: #111111;
--owned-foreground: #ffffff;
--tone-ink: #111111;
--tone-1: #ffd500;
--tone-2: #7fb2ff;
--tone-3: #8bd17c;
--tone-4: #ff8a7a;
--tone-5: #c9a7ff;
}
:root[data-design='bauklotz'] :is(h1, h2) {
font-weight: 900;
text-transform: uppercase;
letter-spacing: -0.01em;
}
/*
* Surfaces. Unlayered on purpose: these must win over the utility classes of
* the shadcn components, which carry the neutral look. Focused elements keep
* their ring (also a box-shadow), so the lift steps aside for it.
*/
[data-slot='card'] {
border: var(--edge-w) solid var(--edge);
box-shadow: var(--lift);
}
:root:is([data-design='klemmbaustein'], [data-design='bauklotz']) {
& [data-slot='button']:is(
[data-variant='default'],
[data-variant='outline'],
[data-variant='secondary'],
[data-variant='destructive']
) {
border-width: var(--edge-w);
border-color: var(--edge);
font-weight: 700;
&:not(:focus-visible) {
box-shadow: var(--lift-sm);
}
&:active:not(:disabled) {
translate: 0 2px;
box-shadow: none;
}
}
& [data-slot='badge'] {
border-width: 2px;
border-color: var(--edge);
border-radius: calc(var(--radius) * 0.5);
font-weight: 800;
}
& :is([data-slot='input'], [data-slot='textarea'], [data-slot='native-select'], [data-slot='select-trigger']) {
border-width: var(--edge-w);
border-color: var(--edge);
}
& :is([data-slot='dialog-content'], [data-slot='alert-dialog-content'], [data-slot='popover-content'], [data-slot='dropdown-menu-content'], [data-slot='select-content']) {
border: var(--edge-w) solid var(--edge);
box-shadow: var(--lift);
}
}
/* A surface outside the shadcn components (set tiles, list entries). */
@utility brick {
border: var(--edge-w) solid var(--edge);
box-shadow: var(--lift);
}
/* --- Studs ---------------------------------------------------------------- */
/* In a layer, so a utility class on a stud (collector colour) still wins. */
@layer components {
.studs {
display: var(--studs);
position: absolute;
top: calc(var(--edge-w) - 10px);
left: 16px;
gap: 10px;
pointer-events: none;
}
.stud {
display: block;
width: 20px;
height: 10px;
box-sizing: border-box;
border: var(--edge-w) solid var(--edge);
border-bottom: 0;
border-radius: 7px 7px 0 0;
background: var(--card);
}
}
+32 -1
View File
@@ -1,6 +1,7 @@
@import "tailwindcss"; @import "tailwindcss";
@import "tw-animate-css"; @import "tw-animate-css";
@import "shadcn/tailwind.css"; @import "shadcn/tailwind.css";
@import "./designs.css";
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
@@ -10,6 +11,7 @@
"Segoe UI Emoji"; "Segoe UI Emoji";
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, --font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
"Liberation Mono", monospace; "Liberation Mono", monospace;
--font-display: var(--font-sans);
} }
@@ -18,7 +20,16 @@
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--font-sans: var(--font-sans); --font-sans: var(--font-sans);
--font-mono: var(--font-mono); --font-mono: var(--font-mono);
--font-heading: var(--font-sans); --font-heading: var(--font-display);
--color-edge: var(--edge);
--color-owned: var(--owned);
--color-owned-foreground: var(--owned-foreground);
--color-tone-ink: var(--tone-ink);
--color-tone-1: var(--tone-1);
--color-tone-2: var(--tone-2);
--color-tone-3: var(--tone-3);
--color-tone-4: var(--tone-4);
--color-tone-5: var(--tone-5);
--color-sidebar-ring: var(--sidebar-ring); --color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border); --color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
@@ -90,6 +101,21 @@
--sidebar-accent-foreground: oklch(0.205 0 0); --sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0); --sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0); --sidebar-ring: oklch(0.708 0 0);
/* Brick look: outline, "lift" (the hard bottom edge) and studs. The
neutral design has none of it; see designs.css for the others. */
--edge: oklch(0.145 0 0 / 10%);
--edge-w: 1px;
--lift: none;
--lift-sm: none;
--studs: none;
--owned: oklch(0.596 0.145 163.225);
--owned-foreground: oklch(1 0 0);
--tone-ink: oklch(0.556 0 0);
--tone-1: oklch(0.97 0 0);
--tone-2: oklch(0.97 0 0);
--tone-3: oklch(0.97 0 0);
--tone-4: oklch(0.97 0 0);
--tone-5: oklch(0.97 0 0);
} }
.dark { .dark {
@@ -136,4 +162,9 @@
html { html {
@apply font-sans; @apply font-sans;
} }
h1,
h2,
h3 {
font-family: var(--font-display);
}
}␍ }␍
+26 -7
View File
@@ -1,6 +1,14 @@
import type { Metadata, Viewport } from 'next'; import type { Metadata, Viewport } from 'next';
import { cookies } from 'next/headers';
import '@fontsource-variable/archivo';
import '@fontsource-variable/bricolage-grotesque';
import '@fontsource-variable/fredoka';
import '@fontsource-variable/inter';
import '@fontsource-variable/nunito';
import '@fontsource-variable/outfit';
import { ServiceWorkerRegistration } from '@/components/layout/service-worker'; import { ServiceWorkerRegistration } from '@/components/layout/service-worker';
import { Toaster } from '@/components/ui/sonner'; import { Toaster } from '@/components/ui/sonner';
import { DESIGN_COOKIE, DESIGN_INFO, resolveDesign } from '@/lib/design';
import './globals.css'; import './globals.css';
export const metadata: Metadata = { export const metadata: Metadata = {
@@ -25,19 +33,30 @@ export const metadata: Metadata = {
robots: { index: false, follow: false }, robots: { index: false, follow: false },
}; };
export const viewport: Viewport = { async function currentDesign() {
return resolveDesign((await cookies()).get(DESIGN_COOKIE)?.value);
}
export async function generateViewport(): Promise<Viewport> {
const design = await currentDesign();
return {
width: 'device-width', width: 'device-width',
initialScale: 1, initialScale: 1,
viewportFit: 'cover', viewportFit: 'cover',
themeColor: [ themeColor: DESIGN_INFO[design].themeColor,
{ media: '(prefers-color-scheme: light)', color: '#ffffff' },
{ media: '(prefers-color-scheme: dark)', color: '#0a0a0a' },
],
}; };
}
export default async function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
const design = await currentDesign();
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return ( return (
<html lang="de" suppressHydrationWarning> <html
lang="de"
data-design={design}
className={DESIGN_INFO[design].dark ? 'dark' : undefined}
suppressHydrationWarning
>
<body className="min-h-dvh bg-background text-foreground antialiased"> <body className="min-h-dvh bg-background text-foreground antialiased">
{children} {children}
<Toaster position="top-center" richColors closeButton /> <Toaster position="top-center" richColors closeButton />
+23 -15
View File
@@ -1,6 +1,8 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
import { Blocks, CalendarDays, Gift } from 'lucide-react'; import { CalendarDays, Gift } from 'lucide-react';
import { BrickLogo } from '@/components/brick/logo';
import { Studs } from '@/components/brick/studs';
import { PublicWishlist, type PublicItem } from '@/components/wunschlisten/public-wishlist'; import { PublicWishlist, type PublicItem } from '@/components/wunschlisten/public-wishlist';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { formatDate, pluralize } from '@/lib/format'; import { formatDate, pluralize } from '@/lib/format';
@@ -54,40 +56,45 @@ export default async function PublicWishlistPage({ params }: Props) {
const openCount = items.filter((item) => !item.isReserved).length; const openCount = items.filter((item) => !item.isReserved).length;
return ( return (
<div className="mx-auto min-h-dvh w-full max-w-2xl px-4 py-8"> <div className="min-h-dvh">
<header className="space-y-3 border-b border-border pb-6"> <header className="border-b-[length:max(1px,var(--edge-w))] border-edge bg-primary text-primary-foreground">
<div className="flex items-center gap-2 text-sm text-muted-foreground"> <div className="mx-auto w-full max-w-2xl space-y-3 px-4 pt-6 pb-10">
<Blocks className="size-4 text-primary" aria-hidden /> <div className="flex items-center gap-2 text-sm font-semibold">
<BrickLogo className="h-5 w-6" />
Bricktrack Bricktrack
</div> </div>
<h1 className="text-2xl font-semibold tracking-tight">{list.title}</h1> <h1 className="text-3xl font-semibold tracking-tight">{list.title}</h1>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary" className="gap-1"> <Badge className="gap-1 bg-card text-card-foreground">
<Gift className="size-3" aria-hidden /> <Gift className="size-3" aria-hidden />
{occasionLabel(list.occasion)} {occasionLabel(list.occasion)}
</Badge> </Badge>
{list.eventDate ? ( {list.eventDate ? (
<Badge variant="outline" className="gap-1"> <Badge className="gap-1 bg-card text-card-foreground">
<CalendarDays className="size-3" aria-hidden /> <CalendarDays className="size-3" aria-hidden />
{formatDate(list.eventDate)} {formatDate(list.eventDate)}
</Badge> </Badge>
) : null} ) : null}
<span className="text-sm text-muted-foreground"> <Badge variant="secondary">
{pluralize(openCount, 'Wunsch noch offen', 'Wünsche noch offen')} {pluralize(openCount, 'Wunsch noch offen', 'Wünsche noch offen')}
</span> </Badge>
</div> </div>
</div>
</header>
{list.note ? ( <div className="mx-auto w-full max-w-2xl px-4 pb-8">
<p className="rounded-lg bg-muted p-3 text-sm whitespace-pre-line">{list.note}</p> <div className="relative -mt-5">
) : null} <Studs />
<div className="brick space-y-2 rounded-xl bg-card p-4 text-card-foreground">
{list.note ? <p className="text-sm whitespace-pre-line">{list.note}</p> : null}
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Klick auf „Das schenke ich“, damit niemand dasselbe doppelt kauft. Eine Anmeldung Klick auf „Das schenke ich“, damit niemand dasselbe doppelt kauft. Eine Anmeldung
brauchst du dafür nicht. brauchst du dafür nicht.
</p> </p>
</header> </div>
</div>
<main className="py-6"> <main className="py-6">
{items.length === 0 ? ( {items.length === 0 ? (
@@ -99,5 +106,6 @@ export default async function PublicWishlistPage({ params }: Props) {
)} )}
</main> </main>
</div> </div>
</div>
); );
} }
+18
View File
@@ -0,0 +1,18 @@
/** The app mark: a 2x1 brick in the design's logo colour. */
export function BrickLogo({ className }: { className?: string }) {
return (
<svg viewBox="0 0 38 32" aria-hidden className={className}>
<g
style={{
fill: 'var(--logo, var(--primary))',
stroke: 'var(--edge)',
strokeWidth: 'var(--edge-w)',
}}
>
<rect x="9" y="2" width="8" height="8" rx="2.5" />
<rect x="21" y="2" width="8" height="8" rx="2.5" />
<rect x="2" y="8" width="34" height="22" rx="6" />
</g>
</svg>
);
}
+66
View File
@@ -0,0 +1,66 @@
import { cn } from 'cn';
import { toneIndex } from '@/lib/design';
const TONE_CLASSES: Record<number, string> = {
1: 'bg-tone-1',
2: 'bg-tone-2',
3: 'bg-tone-3',
4: 'bg-tone-4',
5: 'bg-tone-5',
};
/**
* Stand-in for a set without an image: the manufacturer's tone, a drawn
* brick and the set number. Reads as intended rather than as "image broken".
*/
export function SetPlaceholder({
setNumber,
manufacturerName,
size = 'md',
className,
}: {
setNumber: string;
manufacturerName: string;
size?: 'sm' | 'md' | 'lg';
className?: string;
}) {
const tone = TONE_CLASSES[toneIndex(manufacturerName)];
return (
<div
className={cn(
'relative flex size-full items-end overflow-hidden text-tone-ink',
size === 'sm' ? 'p-1.5' : 'p-3',
tone,
className,
)}
>
{size !== 'sm' ? (
<svg
viewBox="0 0 70 44"
aria-hidden
className={cn(
'absolute opacity-40',
size === 'lg' ? 'top-[18%] right-[12%] w-2/5' : 'top-3 right-3 w-1/3',
)}
>
<g fill="none" stroke="currentColor" strokeWidth="2.5">
<rect x="12" y="2" width="16" height="10" rx="3" />
<rect x="42" y="2" width="16" height="10" rx="3" />
<rect x="2" y="10" width="66" height="32" rx="7" />
</g>
</svg>
) : null}
<span
className={cn(
'relative leading-none font-bold tracking-tight [font-family:var(--font-display)] tabular-nums',
size === 'sm' && 'text-xs',
size === 'md' && (setNumber.length > 6 ? 'text-lg' : 'text-2xl'),
size === 'lg' && 'text-5xl',
)}
>
{setNumber}
</span>
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
import { cn } from 'cn';
/**
* The row of studs on top of a card. Purely decorative and only visible in
* designs that set `--studs: flex`. The parent must be `relative` and must not
* clip its overflow - place it next to the card, not inside.
*/
export function Studs({
count = 2,
className,
studClassName,
}: {
count?: number;
className?: string;
/** Colour of the studs, e.g. the collector's dot class. */
studClassName?: string;
}) {
return (
<span aria-hidden className={cn('studs', className)}>
{Array.from({ length: count }, (_, index) => (
<span key={index} className={cn('stud', studClassName)} />
))}
</span>
);
}
+2 -1
View File
@@ -217,7 +217,8 @@ export function CatalogGrid({
'relative rounded-xl text-left outline-none transition-opacity', 'relative rounded-xl text-left outline-none transition-opacity',
'focus-visible:ring-3 focus-visible:ring-ring/50', 'focus-visible:ring-3 focus-visible:ring-ring/50',
owned && 'cursor-not-allowed opacity-50', owned && 'cursor-not-allowed opacity-50',
isSelected && '[&>div]:border-primary [&>div]:ring-2 [&>div]:ring-primary/40', isSelected &&
'[&_.brick]:outline-3 [&_.brick]:outline-offset-2 [&_.brick]:outline-primary',
)} )}
> >
<SetCardContent set={item} /> <SetCardContent set={item} />
+15 -10
View File
@@ -1,6 +1,8 @@
import Link from 'next/link'; import Link from 'next/link';
import { Check, Gift, ImageOff } from 'lucide-react'; import { Check, Gift } from 'lucide-react';
import { cn } from 'cn'; import { cn } from 'cn';
import { SetPlaceholder } from '@/components/brick/set-placeholder';
import { Studs } from '@/components/brick/studs';
import type { CatalogRow } from '@/lib/queries/catalog'; import type { CatalogRow } from '@/lib/queries/catalog';
import { formatEuro } from '@/lib/money'; import { formatEuro } from '@/lib/money';
import { setImageSrc } from '@/lib/images'; import { setImageSrc } from '@/lib/images';
@@ -17,8 +19,10 @@ export function SetCardContent({ set }: { set: CatalogRow }) {
const owned = set.ownedCopies > 0; const owned = set.ownedCopies > 0;
return ( return (
<div className="flex h-full flex-col overflow-hidden rounded-xl border border-border bg-card transition-colors"> <div className="relative h-full">
<div className="relative aspect-square bg-muted"> <Studs count={3} />
<div className="brick flex h-full flex-col overflow-hidden rounded-xl bg-card transition-colors">
<div className="relative aspect-square border-b-[length:var(--edge-w)] border-edge bg-muted">
{image ? ( {image ? (
// Plain img: images are either local files or a URL the user entered, // Plain img: images are either local files or a URL the user entered,
// and the optimizer must never fetch arbitrary remote hosts. // and the optimizer must never fetch arbitrary remote hosts.
@@ -31,20 +35,18 @@ export function SetCardContent({ set }: { set: CatalogRow }) {
className="size-full object-contain p-2" className="size-full object-contain p-2"
/> />
) : ( ) : (
<div className="flex size-full items-center justify-center text-muted-foreground"> <SetPlaceholder setNumber={set.setNumber} manufacturerName={set.manufacturerName} />
<ImageOff className="size-8" aria-hidden />
</div>
)} )}
<div className="absolute top-2 left-2 flex flex-col items-start gap-1"> <div className="absolute top-2 left-2 flex flex-col items-start gap-1">
{owned ? ( {owned ? (
<Badge className="gap-1 bg-emerald-600 text-white hover:bg-emerald-600"> <Badge className="gap-1 bg-owned text-owned-foreground">
<Check className="size-3" aria-hidden /> <Check className="size-3" aria-hidden />
{set.ownedCopies > 1 ? `${set.ownedCopies}× vorhanden` : 'Vorhanden'} {set.ownedCopies > 1 ? `${set.ownedCopies}× vorhanden` : 'Vorhanden'}
</Badge> </Badge>
) : null} ) : null}
{set.wishlistCount > 0 ? ( {set.wishlistCount > 0 ? (
<Badge variant="secondary" className="gap-1"> <Badge className="gap-1">
<Gift className="size-3" aria-hidden /> <Gift className="size-3" aria-hidden />
Auf Wunschliste Auf Wunschliste
</Badge> </Badge>
@@ -61,7 +63,9 @@ export function SetCardContent({ set }: { set: CatalogRow }) {
</div> </div>
<p className="line-clamp-2 text-sm leading-snug font-medium">{set.name}</p> <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"> <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> <span className="truncate">
{[set.themeName, set.year].filter(Boolean).join(' · ')}
</span>
{set.retailPriceCents !== null ? ( {set.retailPriceCents !== null ? (
<span className="shrink-0 font-medium text-foreground"> <span className="shrink-0 font-medium text-foreground">
{formatEuro(set.retailPriceCents)} {formatEuro(set.retailPriceCents)}
@@ -70,6 +74,7 @@ export function SetCardContent({ set }: { set: CatalogRow }) {
</div> </div>
</div> </div>
</div> </div>
</div>
); );
} }
@@ -78,7 +83,7 @@ export function SetCard({ set, className }: { set: CatalogRow; className?: strin
<Link <Link
href={`/katalog/${set.id}`} href={`/katalog/${set.id}`}
className={cn( className={cn(
'rounded-xl outline-none focus-visible:ring-3 focus-visible:ring-ring/50 [&>div]:hover:border-primary/40', 'rounded-xl outline-none focus-visible:ring-3 focus-visible:ring-ring/50',
className, className,
)} )}
> >
+88
View File
@@ -0,0 +1,88 @@
'use client';
import { useRouter } from 'next/navigation';
import { useState, useTransition } from 'react';
import { Check } from 'lucide-react';
import { toast } from 'sonner';
import { cn } from 'cn';
import { setDesignAction } from '@/app/(app)/konto/actions';
import { DESIGN_INFO, DESIGNS, type Design } from '@/lib/design';
/** Switches the look right away and stores it in the cookie in the background. */
function applyDesign(design: Design) {
const root = document.documentElement;
root.dataset.design = design;
root.classList.toggle('dark', DESIGN_INFO[design].dark);
}
export function DesignPicker({ current }: { current: Design }) {
const router = useRouter();
const [selected, setSelected] = useState<Design>(current);
const [, startTransition] = useTransition();
function choose(design: Design) {
const previous = selected;
setSelected(design);
applyDesign(design);
startTransition(async () => {
const result = await setDesignAction({ design });
if (!result.ok) {
setSelected(previous);
applyDesign(previous);
toast.error(result.error);
return;
}
router.refresh();
});
}
return (
<fieldset>
<legend className="sr-only">Design</legend>
<div className="grid gap-3 sm:grid-cols-2">
{DESIGNS.map((design) => {
const info = DESIGN_INFO[design];
const active = design === selected;
return (
<label
key={design}
className={cn(
'relative flex cursor-pointer items-center gap-3 rounded-lg border-[length:max(1px,var(--edge-w))] p-3 transition-colors',
'has-focus-visible:ring-3 has-focus-visible:ring-ring/50',
active ? 'border-primary bg-accent' : 'border-border hover:bg-muted/60',
)}
>
<input
type="radio"
name="design"
value={design}
checked={active}
onChange={() => choose(design)}
className="sr-only"
/>
<span
aria-hidden
className="grid size-12 shrink-0 grid-cols-2 overflow-hidden rounded-md border border-black/15"
>
{info.swatch.map((color, index) => (
<span key={index} style={{ backgroundColor: color }} />
))}
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-semibold">
{info.label}
{design === 'klemmbaustein' ? (
<span className="font-normal text-muted-foreground"> · Standard</span>
) : null}
</span>
<span className="block text-xs text-muted-foreground">{info.description}</span>
</span>
{active ? <Check className="size-5 shrink-0 text-primary" aria-hidden /> : null}
</label>
);
})}
</div>
</fieldset>
);
}
+6 -3
View File
@@ -1,5 +1,5 @@
import Link from 'next/link'; import Link from 'next/link';
import { Blocks } from 'lucide-react'; import { BrickLogo } from '@/components/brick/logo';
import { DesktopNav } from './bottom-nav'; import { DesktopNav } from './bottom-nav';
import { UserMenu } from './user-menu'; import { UserMenu } from './user-menu';
import type { SessionUser } from '@/lib/session'; import type { SessionUser } from '@/lib/session';
@@ -8,8 +8,11 @@ export function AppHeader({ user }: { user: SessionUser }) {
return ( return (
<header className="sticky top-0 z-40 border-b border-border bg-background/95 backdrop-blur"> <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"> <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"> <Link
<Blocks className="size-5 text-primary" aria-hidden /> href="/"
className="flex items-center gap-2 text-lg font-bold tracking-tight [font-family:var(--font-display)]"
>
<BrickLogo className="h-6 w-7" />
<span>Bricktrack</span> <span>Bricktrack</span>
</Link> </Link>
<div className="flex-1" /> <div className="flex-1" />
+15 -3
View File
@@ -18,7 +18,7 @@ export function BottomNav() {
return ( return (
<nav <nav
aria-label="Hauptnavigation" 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" className="fixed inset-x-0 bottom-0 z-40 border-t-[length:max(1px,var(--edge-w))] border-border bg-card/95 pb-[env(safe-area-inset-bottom)] backdrop-blur md:hidden"
> >
<ul className="mx-auto flex max-w-lg"> <ul className="mx-auto flex max-w-lg">
{items.map((item) => { {items.map((item) => {
@@ -30,10 +30,22 @@ export function BottomNav() {
aria-current={active ? 'page' : undefined} aria-current={active ? 'page' : undefined}
className={cn( className={cn(
'flex h-16 flex-col items-center justify-center gap-1 text-xs transition-colors', 'flex h-16 flex-col items-center justify-center gap-1 text-xs transition-colors',
active ? 'text-primary' : 'text-muted-foreground hover:text-foreground', active
? 'font-bold text-foreground'
: 'font-medium text-muted-foreground hover:text-foreground',
)}
>
{/* The active tab sits on a small plate, like a tile on a baseplate. */}
<span
className={cn(
'flex h-7 w-12 items-center justify-center rounded-[calc(var(--radius)*0.6)] border-[length:var(--edge-w)]',
active
? 'border-edge bg-secondary text-secondary-foreground'
: 'border-transparent',
)} )}
> >
<item.icon className="size-5" aria-hidden /> <item.icon className="size-5" aria-hidden />
</span>
{item.label} {item.label}
</Link> </Link>
</li> </li>
@@ -59,7 +71,7 @@ export function DesktopNav() {
className={cn( className={cn(
'flex h-10 items-center gap-2 rounded-lg px-3 text-sm font-medium transition-colors', 'flex h-10 items-center gap-2 rounded-lg px-3 text-sm font-medium transition-colors',
active active
? 'bg-muted text-foreground' ? 'border-[length:var(--edge-w)] border-edge bg-secondary text-secondary-foreground'
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground', : 'text-muted-foreground hover:bg-muted/60 hover:text-foreground',
)} )}
> >
+15 -11
View File
@@ -1,10 +1,12 @@
'use client'; 'use client';
import { useEffect, useState, useTransition } from 'react'; import { useEffect, useState, useTransition } from 'react';
import { Check, ExternalLink, Gift, ImageOff, Loader2, Undo2 } from 'lucide-react'; import { Check, ExternalLink, Gift, Loader2, Undo2 } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { cancelReservation, reserveItem } from '@/app/w/actions'; import { cancelReservation, reserveItem } from '@/app/w/actions';
import { setImageSrc } from '@/lib/images'; import { setImageSrc } from '@/lib/images';
import { SetPlaceholder } from '@/components/brick/set-placeholder';
import { Studs } from '@/components/brick/studs';
import { formatEuro } from '@/lib/money'; import { formatEuro } from '@/lib/money';
import { priorityLabel } from '@/lib/wishlist-logic'; import { priorityLabel } from '@/lib/wishlist-logic';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@@ -125,24 +127,25 @@ export function PublicWishlist({
} }
return ( return (
<div className="space-y-3"> <div className="space-y-5 pt-2">
{items.map((item) => { {items.map((item) => {
const image = setImageSrc(item); const image = setImageSrc(item);
const mine = Boolean(tokens[item.id]); const mine = Boolean(tokens[item.id]);
return ( return (
<article <article key={item.id} className="relative">
key={item.id} {/* Studs turn green once someone has taken the wish. */}
className="flex gap-3 rounded-xl border border-border bg-card p-3" <Studs count={3} studClassName={mine || item.isReserved ? 'bg-owned' : undefined} />
> <div className="brick flex gap-3 rounded-xl bg-card p-3">
<div className="size-20 shrink-0 overflow-hidden rounded-lg border border-border bg-muted sm:size-28"> <div className="size-20 shrink-0 overflow-hidden rounded-lg border-[length:max(1px,var(--edge-w))] border-edge bg-muted sm:size-28">
{image ? ( {image ? (
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
<img src={image} alt="" loading="lazy" className="size-full object-contain" /> <img src={image} alt="" loading="lazy" className="size-full object-contain" />
) : ( ) : (
<div className="flex size-full items-center justify-center text-muted-foreground"> <SetPlaceholder
<ImageOff className="size-6" aria-hidden /> setNumber={item.setNumber}
</div> manufacturerName={item.manufacturerName}
/>
)} )}
</div> </div>
@@ -172,7 +175,7 @@ export function PublicWishlist({
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{mine ? ( {mine ? (
<> <>
<Badge className="gap-1 bg-emerald-600 text-white hover:bg-emerald-600"> <Badge className="gap-1 bg-owned text-owned-foreground">
<Check className="size-3" aria-hidden /> <Check className="size-3" aria-hidden />
Das schenkst du Das schenkst du
</Badge> </Badge>
@@ -211,6 +214,7 @@ export function PublicWishlist({
) : null} ) : null}
</div> </div>
</div> </div>
</div>
</article> </article>
); );
})} })}
@@ -3,7 +3,8 @@
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useState, useTransition } from 'react'; import { useState, useTransition } from 'react';
import { ArrowDown, ArrowUp, Check, ImageOff, Loader2, Trash2 } from 'lucide-react'; import { ArrowDown, ArrowUp, Check, Loader2, Trash2 } from 'lucide-react';
import { SetPlaceholder } from '@/components/brick/set-placeholder';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { import {
removeWishlistItem, removeWishlistItem,
@@ -122,15 +123,17 @@ export function WishlistItems({
<Link <Link
href={`/katalog/${item.setId}`} href={`/katalog/${item.setId}`}
className="size-16 shrink-0 overflow-hidden rounded-lg border border-border bg-muted" className="size-16 shrink-0 overflow-hidden rounded-lg border-[length:max(1px,var(--edge-w))] border-edge bg-muted"
> >
{image ? ( {image ? (
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
<img src={image} alt="" loading="lazy" className="size-full object-contain" /> <img src={image} alt="" loading="lazy" className="size-full object-contain" />
) : ( ) : (
<div className="flex size-full items-center justify-center text-muted-foreground"> <SetPlaceholder
<ImageOff className="size-5" aria-hidden /> setNumber={item.setNumber}
</div> manufacturerName={item.manufacturerName}
size="sm"
/>
)} )}
</Link> </Link>
+48
View File
@@ -0,0 +1,48 @@
/**
* The progress row on the dashboard: a fixed number of slots, filled with one
* brick per share of the catalog that is already in the collection. Each
* brick takes the colour of a collector, in proportion to what they own.
*/
export type BrickShare = { key: string; count: number };
/**
* Returns `slots` entries: a collector key for a filled slot, `null` for an
* empty one. Filled slots come first, grouped by collector in input order.
*/
export function brickRow(
ownedSets: number,
totalSets: number,
shares: BrickShare[],
slots = 10,
): Array<string | null> {
const empty = Array<string | null>(slots).fill(null);
if (totalSets <= 0 || ownedSets <= 0) return empty;
// Never round a non-empty collection down to an empty row, and only show a
// full row when everything is really there.
const ratio = Math.min(ownedSets, totalSets) / totalSets;
let filled = Math.round(ratio * slots);
filled = Math.max(1, filled);
if (ratio < 1) filled = Math.min(filled, slots - 1);
const positive = shares.filter((share) => share.count > 0);
const shareTotal = positive.reduce((sum, share) => sum + share.count, 0);
if (shareTotal === 0) return [...Array<string | null>(filled).fill(''), ...empty].slice(0, slots);
// Largest remainder: floor first, then hand out the rest by biggest fraction.
const exact = positive.map((share) => (share.count / shareTotal) * filled);
const counts = exact.map(Math.floor);
let rest = filled - counts.reduce((sum, value) => sum + value, 0);
const order = exact
.map((value, index) => ({ index, fraction: value - Math.floor(value) }))
.sort((a, b) => b.fraction - a.fraction || a.index - b.index);
for (const { index } of order) {
if (rest === 0) break;
counts[index]! += 1;
rest -= 1;
}
const row = positive.flatMap((share, index) => Array<string>(counts[index]!).fill(share.key));
return [...row, ...empty].slice(0, slots);
}
+110
View File
@@ -0,0 +1,110 @@
/**
* The selectable looks of the app.
*
* A design is nothing but a set of CSS tokens under `[data-design=...]` in
* `src/app/designs.css`; this module only knows the names. The choice lives in
* a cookie, so it is per device and also applies to pages without a session
* (login, public wishlist).
*/
export const DESIGNS = ['klemmbaustein', 'warm', 'nacht', 'bonbon', 'bauklotz', 'schlicht'] as const;
export type Design = (typeof DESIGNS)[number];
export const DEFAULT_DESIGN: Design = 'klemmbaustein';
export const DESIGN_COOKIE = 'bricktrack-design';
export type DesignInfo = {
label: string;
description: string;
/** Dark designs also set the `.dark` class so `dark:` variants apply. */
dark: boolean;
/** Browser chrome colour (address bar, PWA title bar). */
themeColor: string;
/** Four colours for the preview swatch on the account page. */
swatch: [string, string, string, string];
};
export const DESIGN_INFO: Record<Design, DesignInfo> = {
klemmbaustein: {
label: 'Klemmbaustein',
description: 'Noppen, Konturen und kräftige Steinfarben.',
dark: false,
themeColor: '#fff6e5',
swatch: ['#fff6e5', '#d63a30', '#ffc93c', '#2f6bea'],
},
warm: {
label: 'Warm',
description: 'Ruhiger Sandton mit Ziegelrot.',
dark: false,
themeColor: '#f6f3ec',
swatch: ['#f6f3ec', '#c8381f', '#fbe3a6', '#1d1b17'],
},
nacht: {
label: 'Nachtblau',
description: 'Dunkel mit gelbem Akzent, schont abends die Augen.',
dark: true,
themeColor: '#0e1422',
swatch: ['#0e1422', '#ffc93c', '#182136', '#60a5fa'],
},
bonbon: {
label: 'Bonbon',
description: 'Weiches Lila mit Pastellfarben.',
dark: false,
themeColor: '#f4f0ff',
swatch: ['#f4f0ff', '#6d4aff', '#d3f5e7', '#ffe1d1'],
},
bauklotz: {
label: 'Bauklotz',
description: 'Primärfarben und dicke schwarze Konturen.',
dark: false,
themeColor: '#ffd500',
swatch: ['#fffbef', '#1e5eff', '#ffd500', '#e63b2e'],
},
schlicht: {
label: 'Schlicht',
description: 'Neutrales Grau, wie bis Version 0.3.',
dark: false,
themeColor: '#ffffff',
swatch: ['#ffffff', '#171717', '#f5f5f5', '#a3a3a3'],
},
};
export function isDesign(value: unknown): value is Design {
return typeof value === 'string' && (DESIGNS as readonly string[]).includes(value);
}
/** Cookie value to design; anything unknown falls back to the default. */
export function resolveDesign(value: string | null | undefined): Design {
return isDesign(value) ? value : DEFAULT_DESIGN;
}
/** Number of placeholder tones every design defines (`--tone-1` ...). */
export const TONE_COUNT = 5;
/** The manufacturers named in the brief get a fixed, recognisable tone. */
const KNOWN_TONES: Record<string, number> = {
lego: 1,
playmobil: 2,
cobi: 3,
bluebrixx: 4,
megabloks: 5,
};
/**
* Stable tone for a set without an image, derived from its manufacturer, so
* all Lego placeholders share a colour and Playmobil gets another one.
* Returns 1 ... TONE_COUNT.
*/
export function toneIndex(manufacturerName: string): number {
const key = manufacturerName.toLowerCase().replace(/[^a-z0-9]/g, '');
const known = KNOWN_TONES[key];
if (known) return known;
let hash = 0;
for (const char of key) {
hash = (hash * 31 + char.codePointAt(0)!) >>> 0;
}
return (hash % TONE_COUNT) + 1;
}
+80
View File
@@ -0,0 +1,80 @@
import { describe, expect, it } from 'vitest';
import { brickRow } from '@/lib/brick-row';
import { DEFAULT_DESIGN, DESIGNS, DESIGN_INFO, resolveDesign, TONE_COUNT, toneIndex } from '@/lib/design';
describe('Design aus dem Cookie', () => {
it('nimmt ein bekanntes Design', () => {
expect(resolveDesign('nacht')).toBe('nacht');
expect(resolveDesign('schlicht')).toBe('schlicht');
});
it('faellt bei Unbekanntem auf Klemmbaustein zurueck', () => {
expect(DEFAULT_DESIGN).toBe('klemmbaustein');
expect(resolveDesign(undefined)).toBe('klemmbaustein');
expect(resolveDesign('')).toBe('klemmbaustein');
expect(resolveDesign('"><script>')).toBe('klemmbaustein');
});
it('beschreibt jedes Design', () => {
for (const design of DESIGNS) {
expect(DESIGN_INFO[design].label.length).toBeGreaterThan(0);
expect(DESIGN_INFO[design].themeColor).toMatch(/^#[0-9a-f]{6}$/);
}
expect(DESIGNS.filter((design) => DESIGN_INFO[design].dark)).toEqual(['nacht']);
});
});
describe('Farbton fuer Sets ohne Bild', () => {
it('gibt den bekannten Herstellern feste, verschiedene Toene', () => {
const tones = ['Lego', 'Playmobil', 'Cobi', 'BlueBrixx', 'Mega Bloks'].map(toneIndex);
expect(new Set(tones).size).toBe(5);
expect(toneIndex('Lego')).toBe(1);
expect(toneIndex('LEGO')).toBe(1);
});
it('bleibt fuer andere Hersteller stabil und im Bereich', () => {
expect(toneIndex('Sluban')).toBe(toneIndex('Sluban'));
for (const name of ['Sluban', 'Qman', 'Wange', '', 'Ölfabrik']) {
const tone = toneIndex(name);
expect(tone).toBeGreaterThanOrEqual(1);
expect(tone).toBeLessThanOrEqual(TONE_COUNT);
}
});
});
describe('Steinreihe auf der Startseite', () => {
it('ist leer ohne Katalog oder ohne Sammlung', () => {
expect(brickRow(0, 10, [])).toEqual(Array(10).fill(null));
expect(brickRow(3, 0, [])).toEqual(Array(10).fill(null));
});
it('verteilt die Steine nach Anteil auf die Sammler', () => {
const row = brickRow(4, 10, [
{ key: 'max', count: 2 },
{ key: 'mia', count: 2 },
]);
expect(row).toEqual(['max', 'max', 'mia', 'mia', null, null, null, null, null, null]);
});
it('zeigt mindestens einen Stein, aber nie eine volle Reihe vor dem Ziel', () => {
expect(brickRow(1, 500, [{ key: 'max', count: 1 }]).filter(Boolean)).toHaveLength(1);
expect(brickRow(499, 500, [{ key: 'max', count: 499 }]).filter(Boolean)).toHaveLength(9);
expect(brickRow(500, 500, [{ key: 'max', count: 500 }]).filter(Boolean)).toHaveLength(10);
});
it('vergibt Reststeine nach dem groessten Bruchteil', () => {
const row = brickRow(10, 20, [
{ key: 'a', count: 1 },
{ key: 'b', count: 1 },
{ key: 'c', count: 1 },
]);
// 5 Steine auf drei gleiche Anteile: 2, 2, 1 - die vorderen zuerst.
expect(row.filter((key) => key === 'a')).toHaveLength(2);
expect(row.filter((key) => key === 'b')).toHaveLength(2);
expect(row.filter((key) => key === 'c')).toHaveLength(1);
});
it('fuellt ohne Sammler-Zuordnung neutral', () => {
expect(brickRow(5, 10, []).slice(0, 5)).toEqual(['', '', '', '', '']);
});
});
Regular → Executable
View File