Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d578eac743 | ||
|
|
95a28640e1 | ||
|
|
498a5c6226 | ||
|
|
78ae755761 | ||
|
|
62045468ce | ||
|
|
bac948fcb8 | ||
|
|
2d3ddc4dae | ||
|
|
604a8ac113 | ||
|
|
f2304b28e5 | ||
|
|
d009230dc2 | ||
|
|
1b825b44c4 |
@@ -7,6 +7,61 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
- **Set aus einem Link übernehmen**, für alle Hersteller: Amazon, idealo,
|
||||
geizhals, Hersteller-Shops, Brickfact oder Merlins Bricks. Bricktrack liest
|
||||
Produktdaten (schema.org, Open Graph, bei Amazon die Produktseite) und, wenn
|
||||
die Seite blockt, den Link selbst. Lego wird über Rebrickable ergänzt,
|
||||
BlueBrixx und Cobi über Merlins Bricks. Das Ergebnis füllt das Formular vor,
|
||||
das Bild wird beim Speichern lokal abgelegt.
|
||||
|
||||
### Sicherheit
|
||||
|
||||
- Abrufe im Auftrag eines Nutzers gehen nur an öffentliche Adressen: Jede
|
||||
Adresse wird vor dem Verbindungsaufbau geprüft, Weiterleitungen ebenso,
|
||||
Größe und Zeit sind begrenzt.
|
||||
|
||||
## [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
|
||||
|
||||
Phase 3: mehrere Kinder, Wertentwicklung, Erinnerungen und ein Schutz vor
|
||||
@@ -194,7 +249,8 @@ Erste lauffaehige Fassung: Phase 1 vollstaendig, im Docker-Stack verifiziert.
|
||||
- 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.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.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
|
||||
|
||||
@@ -63,6 +63,18 @@ Bild-URLs, die der Nutzer selbst hinterlegt hat.
|
||||
| `src/lib/reminder-job.ts`, `src/lib/mailer.ts` | Versand und Zeitplan |
|
||||
| `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/link-import.ts` | Set-Daten aus Link und Seite lesen (rein, testbar) |
|
||||
| `src/lib/safe-fetch.ts`, `src/lib/net-guard.ts` | Abruf fremder URLs nur an oeffentliche Adressen |
|
||||
| `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
|
||||
|
||||
@@ -185,7 +197,9 @@ Session.
|
||||
nichts preis ausser den Wunschlisten-Daten - keine Nutzernamen,
|
||||
keine Sammlungsdaten, keine IDs anderer Objekte.
|
||||
4. Externe API-Aufrufe (Rebrickable, ab Phase 2) laufen ausschliesslich
|
||||
serverseitig.
|
||||
serverseitig. URLs, die ein Nutzer eingibt (Link-Import, Bild-Uebernahme),
|
||||
werden nur ueber `safeFetch` abgerufen - nie mit `fetch` direkt, sonst
|
||||
erreicht der Server auch Geraete im Heimnetz.
|
||||
5. Session-Cookies: `httpOnly`, `secure`, `sameSite=lax`.
|
||||
Rate-Limiting auf den Login-Endpunkten.
|
||||
6. Der Export enthaelt **keine** Konten, Passwoerter oder Passkeys - und
|
||||
@@ -203,6 +217,8 @@ Session.
|
||||
Einladungen, Export/Import, PWA. *Fertig in 0.2.0.*
|
||||
- **Phase 3 (Nice to have)** - mehrere Sammler, Preisbeobachtung,
|
||||
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`.
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ Kinderzimmer, nicht für den Schreibtisch.
|
||||
18. [Erinnerung vor dem Termin](#18-erinnerungen)
|
||||
19. [Schutz vor doppelten Einträgen](#19-dubletten)
|
||||
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)).
|
||||
|
||||

|
||||
|
||||
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 |
|
||||
| **Fehlende Sets** | Der direkte Weg zu allem, was noch fehlt |
|
||||
| **Sammlung** | Was tatsächlich da ist |
|
||||
| **Wunschlisten** | Zum Teilen mit Familie und Freunden |
|
||||
| **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 |
|
||||
| **Teile und Wert** | Alle verbauten Teile und was die Sammlung heute wert ist |
|
||||
| **Nächster Termin** | Die nächste Wunschliste mit Datum, wie viele Tage noch bleiben und wie viel offen ist |
|
||||
| **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
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
@@ -302,8 +306,35 @@ gib ihnen sprechende Namen.
|
||||
|
||||
## 11. Import und Scanner
|
||||
|
||||
Sets von Hand einzutippen ist der langsame Weg. Für Lego gibt es zwei
|
||||
schnellere.
|
||||
Sets von Hand einzutippen ist der langsame Weg. Es gibt drei schnellere.
|
||||
|
||||
### Aus einem Link übernehmen – für alle Hersteller
|
||||
|
||||
Auf **Katalog → Set anlegen** steht das Feld „Aus einem Link übernehmen".
|
||||
Dort passt jeder Link zu einem Set hinein: Amazon, idealo, geizhals, der Shop
|
||||
des Herstellers oder eine Set-Datenbank wie
|
||||
[Brickfact](https://brickfact.com) (Lego) und
|
||||
[Merlins Bricks](https://www.merlinsbricks.com) (BlueBrixx, Cobi und viele
|
||||
mehr). „Übernehmen" füllt das Formular darunter aus – **gespeichert wird erst,
|
||||
wenn du „Set anlegen" drückst**. Ein Blick auf Name und Nummer lohnt sich,
|
||||
denn Shops schreiben gern das halbe Sortiment in den Titel.
|
||||
|
||||
Was dabei passiert:
|
||||
|
||||
1. Bricktrack liest die Seite und übernimmt Name, Setnummer, EAN, Bild,
|
||||
Teile und Jahr, soweit sie dort stehen.
|
||||
2. Manche Shops lassen sich nicht abfragen. Dann reicht oft schon der Link
|
||||
selbst: In `amazon.de/LEGO-60398-Familienhaus-…` stecken Hersteller,
|
||||
Nummer und Name.
|
||||
3. Mit Hersteller und Nummer ergänzt Bricktrack den Rest: Lego über
|
||||
Rebrickable (mit API-Key), BlueBrixx und Cobi über Merlins Bricks.
|
||||
|
||||
Das Bild wird beim Speichern lokal abgelegt, die Quelle steht in den Notizen.
|
||||
Gibt es den Hersteller noch nicht, wird er angelegt. Steht das Set schon im
|
||||
Katalog, kommt statt des Formulars ein Link zum vorhandenen Eintrag.
|
||||
|
||||
> Bricktrack ruft dafür nur öffentliche Adressen ab. Links ins eigene Netz –
|
||||
> Router, NAS, andere Container – werden abgewiesen.
|
||||
|
||||
### Lego-Import über die Setnummer
|
||||
|
||||
@@ -548,6 +579,27 @@ Haken dran, habt ihr es schon.
|
||||
|
||||
---
|
||||
|
||||
## 21. Design
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||
- **Filter stecken in der URL.** Ein Lesezeichen auf
|
||||
|
||||
@@ -59,6 +59,16 @@ Laeuft vollstaendig im Docker-Stack.
|
||||
rueckt und Eintraege unreserviert sind
|
||||
- [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
|
||||
|
||||
Phase 1 bis 3 sind umgesetzt. Was noch offen ist, steht hier - ohne Termin
|
||||
|
||||
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 162 KiB |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 294 KiB After Width: | Height: | Size: 435 KiB |
|
Before Width: | Height: | Size: 204 KiB After Width: | Height: | Size: 322 KiB |
|
Before Width: | Height: | Size: 211 KiB After Width: | Height: | Size: 361 KiB |
|
Before Width: | Height: | Size: 212 KiB After Width: | Height: | Size: 326 KiB |
|
Before Width: | Height: | Size: 224 KiB After Width: | Height: | Size: 329 KiB |
|
Before Width: | Height: | Size: 198 KiB After Width: | Height: | Size: 320 KiB |
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 326 KiB After Width: | Height: | Size: 476 KiB |
|
Before Width: | Height: | Size: 155 KiB After Width: | Height: | Size: 234 KiB |
|
Before Width: | Height: | Size: 244 KiB After Width: | Height: | Size: 426 KiB |
|
Before Width: | Height: | Size: 343 KiB After Width: | Height: | Size: 248 KiB |
|
Before Width: | Height: | Size: 328 KiB After Width: | Height: | Size: 477 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 283 KiB |
|
Before Width: | Height: | Size: 211 KiB After Width: | Height: | Size: 361 KiB |
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 206 KiB |
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 208 KiB |
|
Before Width: | Height: | Size: 257 KiB After Width: | Height: | Size: 297 KiB |
|
Before Width: | Height: | Size: 129 KiB After Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 238 KiB After Width: | Height: | Size: 368 KiB |
|
Before Width: | Height: | Size: 224 KiB After Width: | Height: | Size: 336 KiB |
|
Before Width: | Height: | Size: 185 KiB After Width: | Height: | Size: 222 KiB |
|
After Width: | Height: | Size: 136 KiB |
@@ -19,6 +19,9 @@ function shotPath(name: string) {
|
||||
async function shoot(page: Page, name: string) {
|
||||
// Let fonts and images settle so the picture is not half-rendered.
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -117,6 +120,8 @@ test('Screenshots fuer die Anleitung', async ({ page, browser }) => {
|
||||
await visitor.getByRole('button', { name: 'Reservieren' }).click();
|
||||
await expect(visitor.getByText('Reservierung gespeichert')).toBeVisible();
|
||||
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 visitorContext.close();
|
||||
|
||||
@@ -129,6 +134,18 @@ test('Screenshots fuer die Anleitung', async ({ page, browser }) => {
|
||||
await expect(page.getByText('Passkeys', { exact: true })).toBeVisible();
|
||||
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 ------------------------------------------------------
|
||||
// The screenshot container runs with a placeholder API key so the manual
|
||||
// shows the form, not the "not configured" hint. Nothing is submitted.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bricktrack",
|
||||
"version": "0.1.0",
|
||||
"version": "0.4.0",
|
||||
"private": true,
|
||||
"description": "Self-hosted Verwaltung fuer Spielzeug-Set-Sammlungen (Lego, Playmobil, Cobi, ...)",
|
||||
"packageManager": "pnpm@10.34.5",
|
||||
@@ -23,33 +23,39 @@
|
||||
"screenshots": "node scripts/screenshots-prepare.mjs && playwright test --config=playwright.screenshots.config.ts"
|
||||
},
|
||||
"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/library": "^0.23.0",
|
||||
"better-auth": "^1.7.3",
|
||||
"better-auth": "^1.7.12",
|
||||
"better-sqlite3": "^13.0.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"cn": "^0.2.6",
|
||||
"date-fns": "^4.4.0",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"drizzle-orm": "^0.45.4",
|
||||
"fflate": "^0.8.3",
|
||||
"lucide-react": "^1.41.0",
|
||||
"next": "15.5.25",
|
||||
"next": "15.5.28",
|
||||
"next-themes": "^0.4.6",
|
||||
"nodemailer": "^10.0.0",
|
||||
"nodemailer": "^10.2.1",
|
||||
"pdfjs-dist": "^6.3.289",
|
||||
"radix-ui": "^1.6.7",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"server-only": "^0.0.1",
|
||||
"sharp": "^0.35.4",
|
||||
"sharp": "^0.35.5",
|
||||
"sonner": "^2.0.8",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"zod": "^4.5.4"
|
||||
"zod": "^4.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@playwright/test": "^1.63.0",
|
||||
"@playwright/test": "^1.64.1",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/better-sqlite3": "^9.6.0",
|
||||
"@types/node": "^20",
|
||||
@@ -57,15 +63,16 @@
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"dotenv": "^17.4.2",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"drizzle-kit": "^0.31.11",
|
||||
"esbuild": "^0.25.12",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.5.25",
|
||||
"shadcn": "^4.21.0",
|
||||
"eslint-config-next": "15.5.28",
|
||||
"shadcn": "^4.22.1",
|
||||
"tailwindcss": "^4",
|
||||
"tsx": "^4.23.13",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "^5",
|
||||
"vitest": "^5.0.0"
|
||||
"vite": "^8.3.5",
|
||||
"vitest": "^5.0.5"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
@@ -73,6 +80,20 @@
|
||||
"esbuild",
|
||||
"sharp",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
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 { 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 { PhotosSection } from '@/components/katalog/photos-section';
|
||||
import { PriceSection } from '@/components/katalog/price-section';
|
||||
@@ -114,15 +116,21 @@ export default async function SetDetailPage({ params, searchParams }: Props) {
|
||||
</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 className="relative self-start">
|
||||
<Studs count={4} />
|
||||
<div className="brick overflow-hidden rounded-xl 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" />
|
||||
) : (
|
||||
<SetPlaceholder
|
||||
setNumber={set.setNumber}
|
||||
manufacturerName={set.manufacturerName}
|
||||
size="lg"
|
||||
className="aspect-square"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
|
||||
@@ -5,8 +5,10 @@ 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 { env } from '@/env';
|
||||
import { safeFetch, SafeFetchError } from '@/lib/safe-fetch';
|
||||
import { requireUser } from '@/lib/session';
|
||||
import { deleteStoredFile, storeImage, storePdf, UploadError } from '@/lib/uploads';
|
||||
import { deleteStoredFile, storeImage, storeImageBytes, storePdf, UploadError } from '@/lib/uploads';
|
||||
import { optionalText, optionalUrl, setSchema } from '@/lib/validation';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -20,6 +22,23 @@ function revalidateSet(id?: string) {
|
||||
if (id) revalidatePath(`/katalog/${id}`);
|
||||
}
|
||||
|
||||
async function cacheRemoteImage(url: string, setId: string): Promise<string | null> {
|
||||
try {
|
||||
const response = await safeFetch(url, {
|
||||
accept: 'image/webp,image/png,image/jpeg;q=0.9,*/*;q=0.5',
|
||||
maxBytes: env.MAX_UPLOAD_BYTES,
|
||||
});
|
||||
if (response.status !== 200) return null;
|
||||
return (await storeImageBytes(response.body, 'set-image', setId)).filePath;
|
||||
} catch (error) {
|
||||
if (error instanceof SafeFetchError || error instanceof UploadError) {
|
||||
console.error('[katalog] Bild konnte nicht übernommen werden', url, error.message);
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function fileFrom(formData: FormData, field: string): File | null {
|
||||
const value = formData.get(field);
|
||||
return value instanceof File && value.size > 0 ? value : null;
|
||||
@@ -60,12 +79,17 @@ export async function createSet(formData: FormData): Promise<ActionResult<{ id:
|
||||
if (error instanceof UploadError) return actionError(error.message);
|
||||
throw error;
|
||||
}
|
||||
} else if (formData.get('cacheImage') === 'on' && parsed.data.imageUrl) {
|
||||
// A picture found by the link import is kept locally, like the Lego
|
||||
// import does. If that fails, the URL stays as the fallback.
|
||||
imagePath = await cacheRemoteImage(parsed.data.imageUrl, id);
|
||||
}
|
||||
|
||||
db.insert(set)
|
||||
.values({
|
||||
id,
|
||||
...parsed.data,
|
||||
imageUrl: imagePath ? null : parsed.data.imageUrl,
|
||||
imagePath,
|
||||
createdAt: now(),
|
||||
updatedAt: now(),
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
'use server';
|
||||
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import { db } from '@/db';
|
||||
import { manufacturer, set } from '@/db/schema';
|
||||
import { actionData, actionError, type ActionResult } from '@/lib/action-result';
|
||||
import {
|
||||
cleanSetName,
|
||||
hintsFromHtml,
|
||||
hintsFromUrl,
|
||||
mergeHints,
|
||||
sourceLabel,
|
||||
type LinkHints,
|
||||
} from '@/lib/link-import';
|
||||
import { fetchMinifigCount, fetchSet, isRebrickableEnabled, RebrickableError } from '@/lib/rebrickable';
|
||||
import { safeFetch, SafeFetchError } from '@/lib/safe-fetch';
|
||||
import { requireUser } from '@/lib/session';
|
||||
import { uniqueSlug } from '@/lib/slug';
|
||||
|
||||
/**
|
||||
* "Aus einem Link übernehmen": reads a set out of a shop, price-comparison or
|
||||
* database link and returns a draft for the form. Nothing is saved here except
|
||||
* a missing manufacturer - the form needs it to be selectable.
|
||||
*
|
||||
* Amazon, idealo and geizhals usually refuse requests from a server; then the
|
||||
* link itself (brand, number, words in the address) is the starting point,
|
||||
* and Rebrickable (Lego) or Merlins Bricks (BlueBrixx, Cobi) fill in the rest.
|
||||
*/
|
||||
|
||||
export type LinkPreview = {
|
||||
manufacturerId: string | null;
|
||||
manufacturerName: string | null;
|
||||
setNumber: string;
|
||||
name: string;
|
||||
year: number | null;
|
||||
pieceCount: number | null;
|
||||
minifigCount: number | null;
|
||||
ean: string;
|
||||
imageUrl: string;
|
||||
sourceUrl: string;
|
||||
/** Where the data came from, for the confirmation message. */
|
||||
sources: string[];
|
||||
/** The shop did not let us read the page; only the address was used. */
|
||||
pageBlocked: boolean;
|
||||
/** The set is already in the catalog. */
|
||||
existingSetId: string | null;
|
||||
};
|
||||
|
||||
const inputSchema = z.object({
|
||||
url: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, 'Bitte einen Link einfügen.')
|
||||
.max(2000, 'Der Link ist zu lang.')
|
||||
.url('Das ist kein gültiger Link.')
|
||||
.refine((value) => /^https?:\/\//i.test(value), 'Nur http- und https-Links.'),
|
||||
});
|
||||
|
||||
/** Manufacturer website for the ones we create on the fly. */
|
||||
const WEBSITES: Record<string, string> = {
|
||||
Lego: 'https://www.lego.com/de-de',
|
||||
Playmobil: 'https://www.playmobil.de',
|
||||
Cobi: 'https://cobi.pl',
|
||||
BlueBrixx: 'https://www.bluebrixx.com',
|
||||
'Mega Bloks': 'https://www.megabrands.com',
|
||||
};
|
||||
|
||||
/** Merlins Bricks keys its set pages by a brand prefix. */
|
||||
const MERLIN_PREFIX: Record<string, string> = { BlueBrixx: 'bb', Cobi: 'cobi' };
|
||||
|
||||
function key(value: string): string {
|
||||
return value.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
}
|
||||
|
||||
function ensureManufacturer(name: string): string {
|
||||
const rows = db
|
||||
.select({ id: manufacturer.id, name: manufacturer.name, slug: manufacturer.slug })
|
||||
.from(manufacturer)
|
||||
.all();
|
||||
|
||||
const match = rows.find((row) => key(row.name) === key(name) || row.slug === key(name));
|
||||
if (match) return match.id;
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const now = new Date();
|
||||
db.insert(manufacturer)
|
||||
.values({
|
||||
id,
|
||||
name,
|
||||
slug: uniqueSlug(name, rows.map((row) => row.slug)),
|
||||
website: WEBSITES[name] ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.run();
|
||||
return id;
|
||||
}
|
||||
|
||||
async function readPage(url: URL): Promise<{ hints: LinkHints | null; finalUrl: URL; blocked: boolean }> {
|
||||
const response = await safeFetch(url);
|
||||
const finalUrl = new URL(response.url);
|
||||
const isHtml = /html|xml/i.test(response.contentType);
|
||||
|
||||
if (response.status !== 200 || !isHtml) return { hints: null, finalUrl, blocked: true };
|
||||
return { hints: hintsFromHtml(response.body.toString('utf8'), finalUrl), finalUrl, blocked: false };
|
||||
}
|
||||
|
||||
async function fromRebrickable(setNumber: string): Promise<(LinkHints & { minifigCount?: number }) | null> {
|
||||
if (!isRebrickableEnabled()) return null;
|
||||
try {
|
||||
const remote = await fetchSet(setNumber);
|
||||
if (!remote) return null;
|
||||
const minifigs = await fetchMinifigCount(remote.setNumber);
|
||||
return {
|
||||
name: remote.name,
|
||||
year: remote.year ?? undefined,
|
||||
pieceCount: remote.pieceCount ?? undefined,
|
||||
imageUrl: remote.imageUrl ?? undefined,
|
||||
minifigCount: minifigs ?? undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof RebrickableError) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function fromMerlinsBricks(brand: string, setNumber: string): Promise<LinkHints | null> {
|
||||
const prefix = MERLIN_PREFIX[brand];
|
||||
if (!prefix || !/^[\w-]+$/.test(setNumber)) return null;
|
||||
try {
|
||||
const page = await readPage(new URL(`https://www.merlinsbricks.com/sets/${prefix}-${setNumber}/`));
|
||||
return page.hints;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function previewSetFromLink(input: unknown): Promise<ActionResult<LinkPreview>> {
|
||||
await requireUser();
|
||||
|
||||
const parsed = inputSchema.safeParse(input);
|
||||
if (!parsed.success) return actionError(parsed.error.issues[0]?.message ?? 'Ungültiger Link.');
|
||||
|
||||
const url = new URL(parsed.data.url);
|
||||
const sources: string[] = [];
|
||||
|
||||
// 1. The page itself, if it lets us in. A short link (amzn.eu/...) still
|
||||
// tells us where it leads, even when the target then refuses.
|
||||
let pageHints: LinkHints | null = null;
|
||||
let finalUrl = url;
|
||||
let pageBlocked = true;
|
||||
try {
|
||||
const page = await readPage(url);
|
||||
pageHints = page.hints;
|
||||
finalUrl = page.finalUrl;
|
||||
pageBlocked = page.blocked;
|
||||
} catch (error) {
|
||||
if (!(error instanceof SafeFetchError)) throw error;
|
||||
if (/intern/i.test(error.message)) return actionError(error.message);
|
||||
}
|
||||
if (pageHints) sources.push(sourceLabel(finalUrl));
|
||||
|
||||
// 2. The address.
|
||||
const addressHints = mergeHints(hintsFromUrl(finalUrl), hintsFromUrl(url));
|
||||
if (!pageHints || pageBlocked) sources.push(`Link (${sourceLabel(finalUrl)})`);
|
||||
|
||||
let hints = mergeHints(pageHints, addressHints);
|
||||
let minifigCount: number | null = null;
|
||||
|
||||
// 3. A set database for the brand.
|
||||
if (hints.manufacturer === 'Lego' && hints.setNumber) {
|
||||
const remote = await fromRebrickable(hints.setNumber);
|
||||
if (remote) {
|
||||
// The shop's German name beats Rebrickable's English one; Rebrickable's
|
||||
// facts and clean picture beat the shop's.
|
||||
hints = mergeHints({ name: pageHints?.name ?? addressHints.name, ean: hints.ean }, remote, hints);
|
||||
minifigCount = remote.minifigCount ?? null;
|
||||
sources.push('Rebrickable');
|
||||
}
|
||||
} else if (hints.manufacturer && hints.setNumber && !/merlinsbricks\./.test(finalUrl.hostname)) {
|
||||
const merlin = await fromMerlinsBricks(hints.manufacturer, hints.setNumber);
|
||||
if (merlin) {
|
||||
hints = mergeHints(hints, merlin);
|
||||
sources.push('Merlins Bricks');
|
||||
}
|
||||
}
|
||||
|
||||
if (!hints.name && !hints.setNumber) {
|
||||
return actionError(
|
||||
pageBlocked
|
||||
? `${sourceLabel(finalUrl)} lässt keine Abfrage zu, und der Link selbst verrät nichts über das Set. Versuch es mit einem anderen Link oder trag das Set von Hand ein.`
|
||||
: 'Auf der Seite stehen keine Produktdaten, mit denen sich etwas anfangen lässt.',
|
||||
);
|
||||
}
|
||||
|
||||
const manufacturerName = hints.manufacturer ?? null;
|
||||
const manufacturerId = manufacturerName ? ensureManufacturer(manufacturerName) : null;
|
||||
const setNumber = hints.setNumber ?? '';
|
||||
|
||||
const existing =
|
||||
manufacturerId && setNumber
|
||||
? db
|
||||
.select({ id: set.id })
|
||||
.from(set)
|
||||
.where(and(eq(set.manufacturerId, manufacturerId), eq(set.setNumber, setNumber)))
|
||||
.get()
|
||||
: undefined;
|
||||
|
||||
return actionData({
|
||||
manufacturerId,
|
||||
manufacturerName,
|
||||
setNumber,
|
||||
name: hints.name ? cleanSetName(hints.name, manufacturerName ?? undefined, setNumber) : '',
|
||||
year: hints.year ?? null,
|
||||
pieceCount: hints.pieceCount ?? null,
|
||||
minifigCount,
|
||||
ean: hints.ean ?? '',
|
||||
imageUrl: hints.imageUrl ?? '',
|
||||
sourceUrl: url.toString(),
|
||||
sources,
|
||||
pageBlocked,
|
||||
existingSetId: existing?.id ?? null,
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { NewSetEntry } from '@/components/katalog/link-import';
|
||||
import { RebrickableImport } from '@/components/katalog/rebrickable-import';
|
||||
import { SetForm } from '@/components/katalog/set-form';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { listManufacturers, listThemes } from '@/lib/queries/master-data';
|
||||
import { isRebrickableEnabled } from '@/lib/rebrickable';
|
||||
@@ -25,26 +25,8 @@ export default async function NeuesSetPage({ searchParams }: { searchParams: Sea
|
||||
|
||||
const importEnabled = isRebrickableEnabled();
|
||||
|
||||
if (manufacturers.length === 0) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Neues Set</h1>
|
||||
|
||||
{/* The Lego import creates its manufacturer itself, so it works even
|
||||
on a completely empty instance. */}
|
||||
<RebrickableImport enabled={importEnabled} />
|
||||
|
||||
<p className="rounded-lg border border-dashed border-border p-6 text-sm text-muted-foreground">
|
||||
Für ein Set von Hand brauchst du zuerst einen Hersteller – Lego, Playmobil, Cobi oder
|
||||
was auch immer im Kinderzimmer steht.
|
||||
</p>
|
||||
<Button asChild size="lg">
|
||||
<Link href="/verwaltung/hersteller">Hersteller anlegen</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// One layout for both cases: the link import may create the first
|
||||
// manufacturer, and its draft must survive the refresh that follows.
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
@@ -54,15 +36,19 @@ export default async function NeuesSetPage({ searchParams }: { searchParams: Sea
|
||||
Sammlung hinzufügen“ fest.
|
||||
</p>
|
||||
</div>
|
||||
{/* The Lego import creates its manufacturer itself, so it works even
|
||||
on a completely empty instance. */}
|
||||
<RebrickableImport enabled={importEnabled} />
|
||||
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span className="h-px flex-1 bg-border" />
|
||||
oder von Hand
|
||||
<span className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
|
||||
<SetForm manufacturers={manufacturers} themes={themes} defaultEan={ean} />
|
||||
<NewSetEntry manufacturers={manufacturers} themes={themes} defaultEan={ean}>
|
||||
<p className="rounded-lg border border-dashed border-border p-6 text-sm text-muted-foreground">
|
||||
Für ein Set von Hand brauchst du zuerst einen Hersteller – Lego, Playmobil, Cobi oder
|
||||
was auch immer im Kinderzimmer steht.
|
||||
</p>
|
||||
<Button asChild size="lg">
|
||||
<Link href="/verwaltung/hersteller">Hersteller anlegen</Link>
|
||||
</Button>
|
||||
</NewSetEntry>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { headers } from 'next/headers';
|
||||
import { cookies, headers } from 'next/headers';
|
||||
import { z } from 'zod';
|
||||
import { env } from '@/env';
|
||||
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 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 };
|
||||
|
||||
@@ -43,3 +47,26 @@ export async function renamePasskeyAction(input: unknown): Promise<ActionResult>
|
||||
revalidatePath('/konto');
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
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 { DesignPicker } from '@/components/konto/design-picker';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { DESIGN_COOKIE, resolveDesign } from '@/lib/design';
|
||||
import { requireUser } from '@/lib/session';
|
||||
|
||||
export const metadata: Metadata = { title: 'Konto' };
|
||||
@@ -17,6 +19,7 @@ export default async function AccountPage() {
|
||||
name: passkey.name ?? null,
|
||||
createdAt: passkey.createdAt ? new Date(passkey.createdAt) : null,
|
||||
}));
|
||||
const design = resolveDesign((await cookies()).get(DESIGN_COOKIE)?.value);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -45,6 +48,18 @@ export default async function AccountPage() {
|
||||
</CardContent>
|
||||
</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>
|
||||
<CardHeader>
|
||||
<CardTitle>Passkeys</CardTitle>
|
||||
|
||||
@@ -1,58 +1,186 @@
|
||||
import Link from 'next/link';
|
||||
import { Boxes, Gift, LayoutGrid, PackageSearch } from 'lucide-react';
|
||||
import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { ChevronRight, Gift, PackageSearch, Plus } from 'lucide-react';
|
||||
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';
|
||||
|
||||
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;
|
||||
function daysLabel(days: number): string {
|
||||
if (days === 0) return 'heute';
|
||||
if (days === 1) return 'morgen';
|
||||
return `in ${days} Tagen`;
|
||||
}
|
||||
|
||||
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>
|
||||
const stats = getCollectionStats();
|
||||
const missing = countMissingSets();
|
||||
const totalSets = stats.setCount + missing;
|
||||
|
||||
<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>
|
||||
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 (
|
||||
<div className="mx-auto max-w-2xl space-y-7">
|
||||
<h1 className="text-3xl font-semibold tracking-tight">Hallo {user.name}!</h1>
|
||||
|
||||
<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>
|
||||
|
||||
<ol
|
||||
aria-label={`${formatNumber(stats.setCount)} von ${formatNumber(totalSets)} Sets vorhanden`}
|
||||
className="mt-5 grid grid-cols-10 items-end gap-1.5"
|
||||
>
|
||||
{bricks.map((key, index) => (
|
||||
<li key={index} aria-hidden className="relative">
|
||||
{key === null ? (
|
||||
<span className="block h-7 rounded-[6px] border-2 border-dashed border-muted-foreground/50" />
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
</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>
|
||||
))}
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import type { Metadata } from 'next';
|
||||
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 { 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 { collectorBadgeClass } from '@/lib/collector-colors';
|
||||
import { collectorBadgeClass, collectorDotClass } from '@/lib/collector-colors';
|
||||
import { setImageSrc } from '@/lib/images';
|
||||
import { completenessLabel, statusLabel } from '@/lib/labels';
|
||||
import { formatEuro } from '@/lib/money';
|
||||
@@ -122,23 +124,31 @@ export default async function SammlungPage({ searchParams }: { searchParams: Sea
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-border rounded-lg border border-border">
|
||||
<ul className="space-y-5 pt-2">
|
||||
{items.map((item) => {
|
||||
const image = setImageSrc(item);
|
||||
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
|
||||
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 ? (
|
||||
// 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>
|
||||
<SetPlaceholder
|
||||
setNumber={item.setNumber}
|
||||
manufacturerName={item.manufacturerName}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -150,7 +160,11 @@ export default async function SammlungPage({ searchParams }: { searchParams: Sea
|
||||
.join(' · ')}
|
||||
</p>
|
||||
<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)}
|
||||
</Badge>
|
||||
{item.completeness !== 'unknown' ? (
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import Link from 'next/link';
|
||||
import { Blocks } from 'lucide-react';
|
||||
import { BrickLogo } from '@/components/brick/logo';
|
||||
|
||||
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 />
|
||||
<Link
|
||||
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
|
||||
</Link>
|
||||
<main className="w-full max-w-sm">{children}</main>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
@import "./designs.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@@ -10,6 +11,7 @@
|
||||
"Segoe UI Emoji";
|
||||
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
|
||||
"Liberation Mono", monospace;
|
||||
--font-display: var(--font-sans);
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +20,16 @@
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-sans);
|
||||
--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-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
@@ -90,6 +101,21 @@
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 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 {
|
||||
@@ -136,4 +162,9 @@
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
}
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
}␍
|
||||
@@ -1,6 +1,14 @@
|
||||
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 { Toaster } from '@/components/ui/sonner';
|
||||
import { DESIGN_COOKIE, DESIGN_INFO, resolveDesign } from '@/lib/design';
|
||||
import './globals.css';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -25,19 +33,30 @@ export const metadata: Metadata = {
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
viewportFit: 'cover',
|
||||
themeColor: [
|
||||
{ media: '(prefers-color-scheme: light)', color: '#ffffff' },
|
||||
{ media: '(prefers-color-scheme: dark)', color: '#0a0a0a' },
|
||||
],
|
||||
};
|
||||
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',
|
||||
initialScale: 1,
|
||||
viewportFit: 'cover',
|
||||
themeColor: DESIGN_INFO[design].themeColor,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||
const design = await currentDesign();
|
||||
|
||||
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||
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">
|
||||
{children}
|
||||
<Toaster position="top-center" richColors closeButton />
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Metadata } from 'next';
|
||||
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 { Badge } from '@/components/ui/badge';
|
||||
import { formatDate, pluralize } from '@/lib/format';
|
||||
@@ -54,50 +56,56 @@ export default async function PublicWishlistPage({ params }: Props) {
|
||||
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>
|
||||
<div className="min-h-dvh">
|
||||
<header className="border-b-[length:max(1px,var(--edge-w))] border-edge bg-primary text-primary-foreground">
|
||||
<div className="mx-auto w-full max-w-2xl space-y-3 px-4 pt-6 pb-10">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<BrickLogo className="h-5 w-6" />
|
||||
Bricktrack
|
||||
</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">
|
||||
<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)}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge className="gap-1 bg-card text-card-foreground">
|
||||
<Gift className="size-3" aria-hidden />
|
||||
{occasionLabel(list.occasion)}
|
||||
</Badge>
|
||||
) : null}
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{pluralize(openCount, 'Wunsch noch offen', 'Wünsche noch offen')}
|
||||
</span>
|
||||
{list.eventDate ? (
|
||||
<Badge className="gap-1 bg-card text-card-foreground">
|
||||
<CalendarDays className="size-3" aria-hidden />
|
||||
{formatDate(list.eventDate)}
|
||||
</Badge>
|
||||
) : null}
|
||||
<Badge variant="secondary">
|
||||
{pluralize(openCount, 'Wunsch noch offen', 'Wünsche noch offen')}
|
||||
</Badge>
|
||||
</div>
|
||||
</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 className="mx-auto w-full max-w-2xl px-4 pb-8">
|
||||
<div className="relative -mt-5">
|
||||
<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">
|
||||
Klick auf „Das schenke ich“, damit niemand dasselbe doppelt kauft. Eine Anmeldung
|
||||
brauchst du dafür nicht.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -217,7 +217,8 @@ export function CatalogGrid({
|
||||
'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',
|
||||
isSelected &&
|
||||
'[&_.brick]:outline-3 [&_.brick]:outline-offset-2 [&_.brick]:outline-primary',
|
||||
)}
|
||||
>
|
||||
<SetCardContent set={item} />
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState, useTransition } from 'react';
|
||||
import { Link2, Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { previewSetFromLink, type LinkPreview } from '@/app/(app)/katalog/link-actions';
|
||||
import { SetForm, type SetPrefill } from '@/components/katalog/set-form';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Field } from '@/components/ui/field';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
function toPrefill(preview: LinkPreview): SetPrefill {
|
||||
return {
|
||||
manufacturerId: preview.manufacturerId ?? undefined,
|
||||
setNumber: preview.setNumber || undefined,
|
||||
name: preview.name || undefined,
|
||||
year: preview.year,
|
||||
pieceCount: preview.pieceCount,
|
||||
minifigCount: preview.minifigCount,
|
||||
ean: preview.ean || null,
|
||||
imageUrl: preview.imageUrl || null,
|
||||
cacheImage: Boolean(preview.imageUrl),
|
||||
notes: `Übernommen aus: ${preview.sourceUrl}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* "Neues Set" with the link import on top: paste a link from a shop, a price
|
||||
* comparison or a set database, check the prefilled form, save.
|
||||
*/
|
||||
export function NewSetEntry({
|
||||
manufacturers,
|
||||
themes,
|
||||
defaultEan,
|
||||
children,
|
||||
}: {
|
||||
manufacturers: Array<{ id: string; name: string }>;
|
||||
themes: Array<{ id: string; name: string; manufacturerId: string }>;
|
||||
defaultEan?: string;
|
||||
/** Shown instead of the form while there is no manufacturer yet. */
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [url, setUrl] = useState('');
|
||||
const [prefill, setPrefill] = useState<SetPrefill | undefined>(undefined);
|
||||
const [version, setVersion] = useState(0);
|
||||
const [existing, setExisting] = useState<{ id: string; label: string } | null>(null);
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
function submit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setExisting(null);
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await previewSetFromLink({ url });
|
||||
if (!result.ok) {
|
||||
toast.error(result.error);
|
||||
return;
|
||||
}
|
||||
|
||||
const preview = result.data;
|
||||
if (preview.existingSetId) {
|
||||
setExisting({
|
||||
id: preview.existingSetId,
|
||||
label: [preview.setNumber, preview.name].filter(Boolean).join(' · '),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setPrefill(toPrefill(preview));
|
||||
setVersion((value) => value + 1);
|
||||
// A manufacturer may just have been created for the draft.
|
||||
router.refresh();
|
||||
|
||||
const missing = [
|
||||
preview.manufacturerId ? null : 'Hersteller',
|
||||
preview.setNumber ? null : 'Setnummer',
|
||||
preview.name ? null : 'Name',
|
||||
].filter(Boolean);
|
||||
|
||||
toast.success(
|
||||
missing.length === 0
|
||||
? `Übernommen aus ${preview.sources.join(', ')}. Bitte kurz prüfen und speichern.`
|
||||
: `Übernommen aus ${preview.sources.join(', ')} – ${missing.join(', ')} bitte ergänzen.`,
|
||||
);
|
||||
if (preview.pageBlocked) {
|
||||
toast.info('Die Seite selbst ließ sich nicht lesen – die Angaben stammen aus dem Link.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Aus einem Link übernehmen</CardTitle>
|
||||
<CardDescription>
|
||||
Link zu Amazon, idealo, geizhals, zum Shop des Herstellers oder zu einer Set-Datenbank
|
||||
einfügen. Die Angaben landen im Formular unten – gespeichert wird erst, wenn du es
|
||||
sagst.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<form onSubmit={submit} className="flex items-end gap-2">
|
||||
<Field label="Link" htmlFor="import-link" className="flex-1">
|
||||
<Input
|
||||
id="import-link"
|
||||
type="url"
|
||||
inputMode="url"
|
||||
value={url}
|
||||
onChange={(event) => setUrl(event.target.value)}
|
||||
placeholder="https://…"
|
||||
maxLength={2000}
|
||||
/>
|
||||
</Field>
|
||||
<Button type="submit" size="lg" disabled={pending || url.trim().length === 0}>
|
||||
{pending ? <Loader2 className="animate-spin" /> : <Link2 />}
|
||||
Übernehmen
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{existing ? (
|
||||
<p className="rounded-lg bg-muted p-3 text-sm">
|
||||
Das Set ist schon im Katalog:{' '}
|
||||
<Link href={`/katalog/${existing.id}`} className="font-semibold underline">
|
||||
{existing.label}
|
||||
</Link>
|
||||
</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span className="h-px flex-1 bg-border" />
|
||||
{prefill ? 'Bitte prüfen und speichern' : 'oder von Hand'}
|
||||
<span className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
|
||||
{manufacturers.length === 0 ? (
|
||||
children
|
||||
) : (
|
||||
<SetForm
|
||||
key={`${version}-${manufacturers.length}`}
|
||||
manufacturers={manufacturers}
|
||||
themes={themes}
|
||||
defaultEan={defaultEan}
|
||||
prefill={prefill}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import Link from 'next/link';
|
||||
import { Check, Gift, ImageOff } from 'lucide-react';
|
||||
import { Check, Gift } from 'lucide-react';
|
||||
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 { formatEuro } from '@/lib/money';
|
||||
import { setImageSrc } from '@/lib/images';
|
||||
@@ -17,56 +19,59 @@ export function SetCardContent({ set }: { set: CatalogRow }) {
|
||||
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 className="relative h-full">
|
||||
<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 ? (
|
||||
// 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"
|
||||
/>
|
||||
) : (
|
||||
<SetPlaceholder setNumber={set.setNumber} manufacturerName={set.manufacturerName} />
|
||||
)}
|
||||
|
||||
<div className="absolute top-2 left-2 flex flex-col items-start gap-1">
|
||||
{owned ? (
|
||||
<Badge className="gap-1 bg-owned text-owned-foreground">
|
||||
<Check className="size-3" aria-hidden />
|
||||
{set.ownedCopies > 1 ? `${set.ownedCopies}× vorhanden` : 'Vorhanden'}
|
||||
</Badge>
|
||||
) : null}
|
||||
{set.wishlistCount > 0 ? (
|
||||
<Badge className="gap-1">
|
||||
<Gift className="size-3" aria-hidden />
|
||||
Auf Wunschliste
|
||||
</Badge>
|
||||
) : null}
|
||||
</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)}
|
||||
<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>
|
||||
) : null}
|
||||
{set.retailPriceCents !== null ? (
|
||||
<span className="shrink-0 font-medium text-foreground">
|
||||
{formatEuro(set.retailPriceCents)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -78,7 +83,7 @@ export function SetCard({ set, className }: { set: CatalogRow; className?: strin
|
||||
<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',
|
||||
'rounded-xl outline-none focus-visible:ring-3 focus-visible:ring-ring/50',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -47,19 +47,26 @@ export type SetFormValues = {
|
||||
notes: string | null;
|
||||
};
|
||||
|
||||
/** A draft for a new set, e.g. from the link import. */
|
||||
export type SetPrefill = Partial<Omit<SetFormValues, 'id' | 'imagePath'>> & {
|
||||
/** Download imageUrl on save and keep it locally. */
|
||||
cacheImage?: boolean;
|
||||
};
|
||||
|
||||
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;
|
||||
prefill?: SetPrefill;
|
||||
};
|
||||
|
||||
export function SetForm({ manufacturers, themes, set, defaultEan }: Props) {
|
||||
export function SetForm({ manufacturers, themes, set, defaultEan, prefill }: Props) {
|
||||
const router = useRouter();
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [manufacturerId, setManufacturerId] = useState(
|
||||
set?.manufacturerId ?? manufacturers[0]?.id ?? '',
|
||||
set?.manufacturerId ?? prefill?.manufacturerId ?? manufacturers[0]?.id ?? '',
|
||||
);
|
||||
const [duplicates, setDuplicates] = useState<DuplicateCandidate[] | null>(null);
|
||||
const pendingForm = useRef<FormData | null>(null);
|
||||
@@ -167,7 +174,7 @@ export function SetForm({ manufacturers, themes, set, defaultEan }: Props) {
|
||||
<Input
|
||||
id="setNumber"
|
||||
name="setNumber"
|
||||
defaultValue={set?.setNumber ?? ''}
|
||||
defaultValue={set?.setNumber ?? prefill?.setNumber ?? ''}
|
||||
required
|
||||
maxLength={40}
|
||||
placeholder="60398"
|
||||
@@ -179,7 +186,7 @@ export function SetForm({ manufacturers, themes, set, defaultEan }: Props) {
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
defaultValue={set?.name ?? ''}
|
||||
defaultValue={set?.name ?? prefill?.name ?? ''}
|
||||
required
|
||||
maxLength={160}
|
||||
placeholder="Familienhaus und Elektroauto"
|
||||
@@ -192,7 +199,7 @@ export function SetForm({ manufacturers, themes, set, defaultEan }: Props) {
|
||||
name="year"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
defaultValue={set?.year ?? ''}
|
||||
defaultValue={set?.year ?? prefill?.year ?? ''}
|
||||
min={1930}
|
||||
max={new Date().getFullYear() + 5}
|
||||
/>
|
||||
@@ -204,7 +211,7 @@ export function SetForm({ manufacturers, themes, set, defaultEan }: Props) {
|
||||
name="pieceCount"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
defaultValue={set?.pieceCount ?? ''}
|
||||
defaultValue={set?.pieceCount ?? prefill?.pieceCount ?? ''}
|
||||
min={0}
|
||||
/>
|
||||
</Field>
|
||||
@@ -215,7 +222,7 @@ export function SetForm({ manufacturers, themes, set, defaultEan }: Props) {
|
||||
name="minifigCount"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
defaultValue={set?.minifigCount ?? ''}
|
||||
defaultValue={set?.minifigCount ?? prefill?.minifigCount ?? ''}
|
||||
min={0}
|
||||
/>
|
||||
</Field>
|
||||
@@ -235,7 +242,7 @@ export function SetForm({ manufacturers, themes, set, defaultEan }: Props) {
|
||||
id="ean"
|
||||
name="ean"
|
||||
inputMode="numeric"
|
||||
defaultValue={set?.ean ?? defaultEan ?? ''}
|
||||
defaultValue={set?.ean ?? prefill?.ean ?? defaultEan ?? ''}
|
||||
maxLength={20}
|
||||
className="font-mono"
|
||||
/>
|
||||
@@ -256,10 +263,18 @@ export function SetForm({ manufacturers, themes, set, defaultEan }: Props) {
|
||||
name="imageUrl"
|
||||
type="url"
|
||||
inputMode="url"
|
||||
defaultValue={set?.imageUrl ?? ''}
|
||||
defaultValue={set?.imageUrl ?? prefill?.imageUrl ?? ''}
|
||||
placeholder="https://…"
|
||||
/>
|
||||
</Field>
|
||||
{prefill?.cacheImage && prefill.imageUrl ? (
|
||||
<>
|
||||
<input type="hidden" name="cacheImage" value="on" />
|
||||
<p className="-mt-4 text-xs text-muted-foreground">
|
||||
Das Bild aus dem Link wird beim Speichern heruntergeladen und lokal abgelegt.
|
||||
</p>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="image">Bild hochladen</Label>
|
||||
@@ -286,7 +301,12 @@ export function SetForm({ manufacturers, themes, set, defaultEan }: Props) {
|
||||
</div>
|
||||
|
||||
<Field label="Notizen" htmlFor="notes">
|
||||
<Textarea id="notes" name="notes" defaultValue={set?.notes ?? ''} rows={4} />
|
||||
<Textarea
|
||||
id="notes"
|
||||
name="notes"
|
||||
defaultValue={set?.notes ?? prefill?.notes ?? ''}
|
||||
rows={4}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="flex gap-3">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import Link from 'next/link';
|
||||
import { Blocks } from 'lucide-react';
|
||||
import { BrickLogo } from '@/components/brick/logo';
|
||||
import { DesktopNav } from './bottom-nav';
|
||||
import { UserMenu } from './user-menu';
|
||||
import type { SessionUser } from '@/lib/session';
|
||||
@@ -8,8 +8,11 @@ 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 />
|
||||
<Link
|
||||
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>
|
||||
</Link>
|
||||
<div className="flex-1" />
|
||||
|
||||
@@ -18,7 +18,7 @@ export function BottomNav() {
|
||||
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"
|
||||
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">
|
||||
{items.map((item) => {
|
||||
@@ -30,10 +30,22 @@ export function BottomNav() {
|
||||
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',
|
||||
active
|
||||
? 'font-bold text-foreground'
|
||||
: 'font-medium text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<item.icon className="size-5" aria-hidden />
|
||||
{/* 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 />
|
||||
</span>
|
||||
{item.label}
|
||||
</Link>
|
||||
</li>
|
||||
@@ -59,7 +71,7 @@ export function DesktopNav() {
|
||||
className={cn(
|
||||
'flex h-10 items-center gap-2 rounded-lg px-3 text-sm font-medium transition-colors',
|
||||
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',
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
'use client';
|
||||
|
||||
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 { cancelReservation, reserveItem } from '@/app/w/actions';
|
||||
import { setImageSrc } from '@/lib/images';
|
||||
import { SetPlaceholder } from '@/components/brick/set-placeholder';
|
||||
import { Studs } from '@/components/brick/studs';
|
||||
import { formatEuro } from '@/lib/money';
|
||||
import { priorityLabel } from '@/lib/wishlist-logic';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -125,90 +127,92 @@ export function PublicWishlist({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-5 pt-2">
|
||||
{items.map((item) => {
|
||||
const image = setImageSrc(item);
|
||||
const mine = Boolean(tokens[item.id]);
|
||||
|
||||
return (
|
||||
<article
|
||||
key={item.id}
|
||||
className="flex gap-3 rounded-xl border border-border bg-card p-3"
|
||||
>
|
||||
<div className="size-20 shrink-0 overflow-hidden rounded-lg border border-border bg-muted sm:size-28">
|
||||
{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-6" aria-hidden />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Badge variant="outline" className="font-mono text-xs">
|
||||
{item.setNumber}
|
||||
</Badge>
|
||||
<Badge variant="secondary">{item.manufacturerName}</Badge>
|
||||
{item.priority === 1 ? <Badge>{priorityLabel(1)}</Badge> : null}
|
||||
</div>
|
||||
<h2 className="mt-1 font-medium">{item.setName}</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{[
|
||||
item.themeName,
|
||||
item.year,
|
||||
item.pieceCount ? `${item.pieceCount} Teile` : null,
|
||||
item.retailPriceCents !== null ? formatEuro(item.retailPriceCents) : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</p>
|
||||
{item.note ? <p className="mt-1 text-sm">{item.note}</p> : null}
|
||||
<article key={item.id} className="relative">
|
||||
{/* Studs turn green once someone has taken the wish. */}
|
||||
<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-[length:max(1px,var(--edge-w))] border-edge bg-muted sm:size-28">
|
||||
{image ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={image} alt="" loading="lazy" className="size-full object-contain" />
|
||||
) : (
|
||||
<SetPlaceholder
|
||||
setNumber={item.setNumber}
|
||||
manufacturerName={item.manufacturerName}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{mine ? (
|
||||
<>
|
||||
<Badge className="gap-1 bg-emerald-600 text-white hover:bg-emerald-600">
|
||||
<Check className="size-3" aria-hidden />
|
||||
Das schenkst du
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Badge variant="outline" className="font-mono text-xs">
|
||||
{item.setNumber}
|
||||
</Badge>
|
||||
<Button variant="ghost" size="sm" onClick={() => undo(item)} disabled={pending}>
|
||||
<Undo2 />
|
||||
Doch nicht
|
||||
</Button>
|
||||
</>
|
||||
) : item.isReserved ? (
|
||||
<Badge variant="secondary">
|
||||
{item.reservedByName
|
||||
? `Übernimmt ${item.reservedByName}`
|
||||
: 'Schon reserviert'}
|
||||
</Badge>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setReserving(item);
|
||||
setCancelLink(null);
|
||||
}}
|
||||
disabled={pending}
|
||||
>
|
||||
<Gift />
|
||||
Das schenke ich
|
||||
</Button>
|
||||
)}
|
||||
<Badge variant="secondary">{item.manufacturerName}</Badge>
|
||||
{item.priority === 1 ? <Badge>{priorityLabel(1)}</Badge> : null}
|
||||
</div>
|
||||
<h2 className="mt-1 font-medium">{item.setName}</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{[
|
||||
item.themeName,
|
||||
item.year,
|
||||
item.pieceCount ? `${item.pieceCount} Teile` : null,
|
||||
item.retailPriceCents !== null ? formatEuro(item.retailPriceCents) : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</p>
|
||||
{item.note ? <p className="mt-1 text-sm">{item.note}</p> : null}
|
||||
</div>
|
||||
|
||||
{item.manufacturerWebsite ? (
|
||||
<Button asChild variant="link" size="sm" className="px-0">
|
||||
<a href={item.manufacturerWebsite} target="_blank" rel="noreferrer noopener">
|
||||
Zum Shop
|
||||
<ExternalLink />
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{mine ? (
|
||||
<>
|
||||
<Badge className="gap-1 bg-owned text-owned-foreground">
|
||||
<Check className="size-3" aria-hidden />
|
||||
Das schenkst du
|
||||
</Badge>
|
||||
<Button variant="ghost" size="sm" onClick={() => undo(item)} disabled={pending}>
|
||||
<Undo2 />
|
||||
Doch nicht
|
||||
</Button>
|
||||
</>
|
||||
) : item.isReserved ? (
|
||||
<Badge variant="secondary">
|
||||
{item.reservedByName
|
||||
? `Übernimmt ${item.reservedByName}`
|
||||
: 'Schon reserviert'}
|
||||
</Badge>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setReserving(item);
|
||||
setCancelLink(null);
|
||||
}}
|
||||
disabled={pending}
|
||||
>
|
||||
<Gift />
|
||||
Das schenke ich
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{item.manufacturerWebsite ? (
|
||||
<Button asChild variant="link" size="sm" className="px-0">
|
||||
<a href={item.manufacturerWebsite} target="_blank" rel="noreferrer noopener">
|
||||
Zum Shop
|
||||
<ExternalLink />
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
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 {
|
||||
removeWishlistItem,
|
||||
@@ -122,15 +123,17 @@ export function WishlistItems({
|
||||
|
||||
<Link
|
||||
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 ? (
|
||||
// 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>
|
||||
<SetPlaceholder
|
||||
setNumber={item.setNumber}
|
||||
manufacturerName={item.manufacturerName}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
import { isValidEan, normalizeEan } from './ean';
|
||||
|
||||
/**
|
||||
* Reads set data out of a pasted link and, where the page can be fetched, out
|
||||
* of its structured data (schema.org JSON-LD, Open Graph).
|
||||
*
|
||||
* Pure: no network, no database. The server action in katalog/link-actions.ts
|
||||
* does the fetching and decides what to trust.
|
||||
*/
|
||||
|
||||
export type LinkHints = {
|
||||
manufacturer?: string;
|
||||
setNumber?: string;
|
||||
name?: string;
|
||||
ean?: string;
|
||||
imageUrl?: string;
|
||||
pieceCount?: number;
|
||||
year?: number;
|
||||
};
|
||||
|
||||
/** Brands we recognise in links and page data, with how we spell them. */
|
||||
const BRANDS: Array<{ name: string; aliases: string[] }> = [
|
||||
{ name: 'Lego', aliases: ['lego'] },
|
||||
{ name: 'Playmobil', aliases: ['playmobil'] },
|
||||
{ name: 'Cobi', aliases: ['cobi'] },
|
||||
{ name: 'BlueBrixx', aliases: ['bluebrixx', 'bluebrixxpro', 'bb'] },
|
||||
{ name: 'Mega Bloks', aliases: ['megabloks', 'megaconstrux', 'mega'] },
|
||||
{ name: 'Mould King', aliases: ['mouldking'] },
|
||||
{ name: 'Cada', aliases: ['cada'] },
|
||||
{ name: 'Sluban', aliases: ['sluban'] },
|
||||
{ name: 'Qman', aliases: ['qman', 'keeppley'] },
|
||||
{ name: 'Xingbao', aliases: ['xingbao'] },
|
||||
];
|
||||
|
||||
function key(value: string): string {
|
||||
return value.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
}
|
||||
|
||||
/** "LEGO®", "lego", "BlueBrixx Pro" -> "Lego", "BlueBrixx". */
|
||||
export function canonicalBrand(value: string | undefined | null): string | undefined {
|
||||
if (!value) return undefined;
|
||||
const wanted = key(value);
|
||||
if (!wanted) return undefined;
|
||||
for (const brand of BRANDS) {
|
||||
if (brand.aliases.some((alias) => wanted === alias || (alias.length > 3 && wanted.startsWith(alias)))) {
|
||||
return brand.name;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Where a link points, in words for the UI. */
|
||||
export function sourceLabel(url: URL): string {
|
||||
const host = url.hostname.replace(/^www\./, '');
|
||||
if (/(^|\.)amazon\./.test(host) || /^amzn\./.test(host)) return 'Amazon';
|
||||
if (/(^|\.)idealo\./.test(host)) return 'idealo';
|
||||
if (/(^|\.)geizhals\./.test(host)) return 'geizhals';
|
||||
if (/(^|\.)brickmerge\./.test(host)) return 'brickmerge';
|
||||
if (/(^|\.)brickfact\./.test(host)) return 'Brickfact';
|
||||
if (/(^|\.)merlinsbricks\./.test(host)) return 'Merlins Bricks';
|
||||
if (/(^|\.)ebay\./.test(host)) return 'eBay';
|
||||
return host;
|
||||
}
|
||||
|
||||
// Words that sit in shop slugs but never in a set name.
|
||||
const STOPWORDS = new Set([
|
||||
'set', 'sets', 'bausatz', 'bausteine', 'klemmbausteine', 'baustein', 'spielzeug', 'spielset',
|
||||
'kinder', 'jungen', 'maedchen', 'mädchen', 'ab', 'jahre', 'jahren', 'jahr', 'teile', 'teilig',
|
||||
'geschenk', 'geschenkidee', 'the', 'and', 'for', 'kids', 'boys',
|
||||
'girls', 'toy', 'toys', 'building', 'kit', 'bricks', 'brick', 'preisvergleich', 'dp', 'html',
|
||||
'neu', 'ovp', 'new', 'sealed',
|
||||
]);
|
||||
|
||||
const KEEP_LOWER = new Set(['und', 'mit', 'der', 'die', 'das', 'von', 'im', 'am', 'zum', 'zur']);
|
||||
|
||||
function looksLikeYear(value: string): boolean {
|
||||
const year = Number(value);
|
||||
return year >= 1950 && year <= 2040;
|
||||
}
|
||||
|
||||
/**
|
||||
* A set number in a list of slug tokens: 4 to 7 digits, longer ones first,
|
||||
* and four-digit years only as a last resort. Cobi's "2-30500" style is kept.
|
||||
*/
|
||||
function findSetNumber(tokens: string[]): string | undefined {
|
||||
const joined = tokens.join('-');
|
||||
const cobi = /(?:^|-)(\d-\d{5})(?:-|$)/.exec(joined);
|
||||
if (cobi) return cobi[1];
|
||||
|
||||
const numbers = tokens.filter((token) => /^\d{4,7}$/.test(token));
|
||||
const best =
|
||||
numbers.find((token) => token.length >= 5) ?? numbers.find((token) => !looksLikeYear(token));
|
||||
return best;
|
||||
}
|
||||
|
||||
function titleCase(word: string): string {
|
||||
if (KEEP_LOWER.has(word)) return word;
|
||||
return word.charAt(0).toUpperCase() + word.slice(1);
|
||||
}
|
||||
|
||||
/** The descriptive part of a shop URL, split into words. */
|
||||
function slugTokens(url: URL): string[] {
|
||||
const segments = decodeURIComponent(url.pathname)
|
||||
.split('/')
|
||||
.filter(Boolean)
|
||||
.map((segment) => segment.replace(/\.html?$/i, ''));
|
||||
|
||||
let slug = '';
|
||||
const host = url.hostname;
|
||||
|
||||
if (/amazon\./.test(host)) {
|
||||
// amazon.de/LEGO-60398-Familienhaus-Elektroauto/dp/B0...: the part before /dp/.
|
||||
const dp = segments.findIndex((segment) => segment === 'dp' || segment === 'gp');
|
||||
slug = dp > 0 ? segments[dp - 1]! : '';
|
||||
} else if (/idealo\./.test(host)) {
|
||||
// .../OffersOfProduct/202595377_-familienhaus-mit-elektroauto-60398-lego
|
||||
slug = (segments.at(-1) ?? '').replace(/^\d+_-?/, '');
|
||||
} else if (/geizhals\./.test(host)) {
|
||||
// geizhals.de/lego-city-familienhaus-...-60398-a2875000
|
||||
slug = (segments.at(-1) ?? '').replace(/-a\d+$/, '');
|
||||
} else {
|
||||
// Generic: the longest segment with dashes is usually the product slug.
|
||||
slug = [...segments].sort((a, b) => b.split(/[-_]/).length - a.split(/[-_]/).length)[0] ?? '';
|
||||
}
|
||||
|
||||
return slug
|
||||
.toLowerCase()
|
||||
.split(/[-_+\s]+/)
|
||||
.map((token) => token.replace(/[^\p{L}\p{N}]/gu, ''))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/** Everything the address alone tells us - useful when the page blocks us. */
|
||||
export function hintsFromUrl(url: URL): LinkHints {
|
||||
const host = url.hostname.replace(/^www\./, '');
|
||||
|
||||
// merlinsbricks.com/sets/bb-108782, /sets/cobi-2400: brand and number in one.
|
||||
const merlin = /merlinsbricks\./.test(host)
|
||||
? /\/sets\/([a-z]+)-([\w-]+?)\/?$/i.exec(url.pathname)
|
||||
: null;
|
||||
if (merlin) {
|
||||
return { manufacturer: canonicalBrand(merlin[1]), setNumber: merlin[2] };
|
||||
}
|
||||
|
||||
const tokens = slugTokens(url);
|
||||
const setNumber = findSetNumber(tokens);
|
||||
|
||||
let manufacturer: string | undefined;
|
||||
for (const token of tokens) {
|
||||
manufacturer = canonicalBrand(token);
|
||||
if (manufacturer) break;
|
||||
}
|
||||
manufacturer ??= canonicalBrand(host.split('.').at(-2));
|
||||
|
||||
const words = tokens.filter(
|
||||
(token) =>
|
||||
token !== setNumber &&
|
||||
!/^\d+$/.test(token) &&
|
||||
!STOPWORDS.has(token) &&
|
||||
!canonicalBrand(token) &&
|
||||
token.length > 1,
|
||||
);
|
||||
const name = words.length > 0 ? words.slice(0, 8).map(titleCase).join(' ') : undefined;
|
||||
|
||||
return { manufacturer, setNumber, name };
|
||||
}
|
||||
|
||||
// --- page data ------------------------------------------------------------
|
||||
|
||||
const ENTITIES: Record<string, string> = {
|
||||
amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', '#39': "'",
|
||||
auml: 'ä', ouml: 'ö', uuml: 'ü', Auml: 'Ä', Ouml: 'Ö', Uuml: 'Ü', szlig: 'ß',
|
||||
};
|
||||
|
||||
function decodeEntities(value: string): string {
|
||||
return value
|
||||
.replace(/&#(\d+);/g, (_, code: string) => String.fromCodePoint(Number(code)))
|
||||
.replace(/&#x([\da-f]+);/gi, (_, code: string) => String.fromCodePoint(parseInt(code, 16)))
|
||||
.replace(/&([a-z]+|#39);/gi, (match, name: string) => ENTITIES[name] ?? match);
|
||||
}
|
||||
|
||||
function metaContent(html: string, property: string): string | undefined {
|
||||
const escaped = property.replace(/[.:]/g, '\\$&');
|
||||
const pattern = new RegExp(
|
||||
`<meta[^>]+(?:property|name)=["']${escaped}["'][^>]*content=["']([^"']*)["']|<meta[^>]+content=["']([^"']*)["'][^>]*(?:property|name)=["']${escaped}["']`,
|
||||
'i',
|
||||
);
|
||||
const match = pattern.exec(html);
|
||||
const value = match?.[1] ?? match?.[2];
|
||||
return value ? decodeEntities(value).trim() : undefined;
|
||||
}
|
||||
|
||||
type JsonObject = Record<string, unknown>;
|
||||
|
||||
function isObject(value: unknown): value is JsonObject {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/** All schema.org Product objects on the page, however they are nested. */
|
||||
function jsonLdProducts(html: string): JsonObject[] {
|
||||
const products: JsonObject[] = [];
|
||||
const pattern = /<script[^>]+application\/ld\+json[^>]*>([\s\S]*?)<\/script>/gi;
|
||||
|
||||
const visit = (node: unknown) => {
|
||||
if (Array.isArray(node)) return node.forEach(visit);
|
||||
if (!isObject(node)) return;
|
||||
const type = node['@type'];
|
||||
const types = Array.isArray(type) ? type : [type];
|
||||
if (types.includes('Product') || types.includes('ProductGroup')) products.push(node);
|
||||
if (node['@graph']) visit(node['@graph']);
|
||||
};
|
||||
|
||||
for (const match of html.matchAll(pattern)) {
|
||||
try {
|
||||
visit(JSON.parse(match[1]!.trim()));
|
||||
} catch {
|
||||
// A broken block on someone else's page is not our problem.
|
||||
}
|
||||
}
|
||||
return products;
|
||||
}
|
||||
|
||||
function text(value: unknown): string | undefined {
|
||||
if (typeof value === 'string') return decodeEntities(value).trim() || undefined;
|
||||
if (typeof value === 'number') return String(value);
|
||||
if (isObject(value)) return text(value.name) ?? text(value.url);
|
||||
if (Array.isArray(value)) return text(value[0]);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function cleanEan(value: unknown): string | undefined {
|
||||
const raw = text(value);
|
||||
if (!raw) return undefined;
|
||||
const ean = normalizeEan(raw);
|
||||
return isValidEan(ean) ? ean : undefined;
|
||||
}
|
||||
|
||||
function absoluteUrl(value: string | undefined, base: URL): string | undefined {
|
||||
if (!value) return undefined;
|
||||
try {
|
||||
const url = new URL(value, base);
|
||||
return url.protocol === 'https:' || url.protocol === 'http:' ? url.toString() : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** "462 Teile", "450 pieces" in a description. */
|
||||
function piecesFrom(value: string | undefined): number | undefined {
|
||||
const match = value && /(\d{1,3}(?:[.,]\d{3})*|\d+)\s*(?:teilen?|pieces|pcs|steinen?|bausteinen?|parts)\b/i.exec(value);
|
||||
if (!match) return undefined;
|
||||
const pieces = Number(match[1]!.replace(/[.,]/g, ''));
|
||||
return pieces > 0 && pieces < 100_000 ? pieces : undefined;
|
||||
}
|
||||
|
||||
/** "from 2025", "aus 2023", "Jahr: 2021" in a description. */
|
||||
function yearFrom(value: string | undefined): number | undefined {
|
||||
const match = value && /\b(?:from|aus|jahr:?|released|erschienen)\s+((?:19|20)\d{2})\b/i.exec(value);
|
||||
return match ? Number(match[1]) : undefined;
|
||||
}
|
||||
|
||||
function stripTags(value: string): string {
|
||||
return decodeEntities(value.replace(/<[^>]+>/g, ' '))
|
||||
.replace(/[]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Amazon has no schema.org data, but a stable page skeleton: the title in
|
||||
* #productTitle, the brand in the byline, pictures as "hiRes" in a script and
|
||||
* the facts in the "Produktinformation" table or bullet list.
|
||||
*/
|
||||
function hintsFromAmazon(html: string): LinkHints {
|
||||
const facts = new Map<string, string>();
|
||||
for (const match of html.matchAll(/<th[^>]*>([\s\S]*?)<\/th>\s*<td[^>]*>([\s\S]*?)<\/td>/gi)) {
|
||||
facts.set(stripTags(match[1]!).toLowerCase(), stripTags(match[2]!));
|
||||
}
|
||||
for (const match of html.matchAll(
|
||||
/<span class="a-text-bold">([\s\S]*?)<\/span>\s*<span[^>]*>([\s\S]*?)<\/span>/gi,
|
||||
)) {
|
||||
facts.set(stripTags(match[1]!).replace(/[:\s]+$/, '').toLowerCase(), stripTags(match[2]!));
|
||||
}
|
||||
const fact = (...labels: string[]) => {
|
||||
for (const [label, value] of facts) {
|
||||
if (labels.some((wanted) => label.includes(wanted))) return value;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const title = /id="productTitle"[^>]*>([\s\S]*?)<\/span>/i.exec(html);
|
||||
const byline = /id="bylineInfo"[^>]*>([\s\S]*?)<\/a>/i.exec(html);
|
||||
const brandText = byline
|
||||
? stripTags(byline[1]!).replace(/^(marke|brand):\s*/i, '').replace(/^besuche den\s+|-store$|\s+store$/gi, '')
|
||||
: fact('marke', 'hersteller');
|
||||
const image = /"hiRes":"(https:[^"]+)"/.exec(html) ?? /data-old-hires="(https:[^"]+)"/.exec(html);
|
||||
const pieces = Number((fact('anzahl der teile', 'teileanzahl', 'anzahl teile') ?? '').replace(/\D/g, ''));
|
||||
|
||||
return {
|
||||
manufacturer: canonicalBrand(brandText),
|
||||
setNumber: fact('modellnummer', 'herstellerreferenz')?.replace(/\s/g, ''),
|
||||
name: title ? stripTags(title[1]!) : undefined,
|
||||
ean: cleanEan(fact('ean', 'gtin')),
|
||||
imageUrl: image?.[1],
|
||||
pieceCount: pieces > 0 && pieces < 100_000 ? pieces : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function hintsFromHtml(html: string, pageUrl: URL): LinkHints {
|
||||
const hints = /(^|\.)amazon\./.test(pageUrl.hostname) ? hintsFromAmazon(html) : hintsFromStructuredData(html, pageUrl);
|
||||
|
||||
// A shop's own article number (an ASIN, a long SKU) is not a set number.
|
||||
if (hints.setNumber && !/^[\d-]{3,12}$/.test(hints.setNumber)) delete hints.setNumber;
|
||||
|
||||
// Titles usually carry the number: "LEGO City 60398 Familienhaus".
|
||||
if (!hints.setNumber && hints.name) {
|
||||
const fromName = findSetNumber(hints.name.toLowerCase().split(/[\s()[\]:,/|]+/).filter(Boolean));
|
||||
if (fromName) hints.setNumber = fromName;
|
||||
}
|
||||
return hints;
|
||||
}
|
||||
|
||||
function hintsFromStructuredData(html: string, pageUrl: URL): LinkHints {
|
||||
const product = jsonLdProducts(html)[0];
|
||||
const description = text(product?.description) ?? metaContent(html, 'og:description');
|
||||
|
||||
const hints: LinkHints = {
|
||||
manufacturer: canonicalBrand(text(product?.brand)) ?? canonicalBrand(metaContent(html, 'product:brand')),
|
||||
setNumber: text(product?.mpn) ?? text(product?.sku),
|
||||
name: text(product?.name) ?? metaContent(html, 'og:title'),
|
||||
ean: cleanEan(product?.gtin13) ?? cleanEan(product?.gtin) ?? cleanEan(product?.gtin12) ?? cleanEan(product?.gtin8),
|
||||
imageUrl: absoluteUrl(text(product?.image) ?? metaContent(html, 'og:image'), pageUrl),
|
||||
pieceCount: piecesFrom(description),
|
||||
year: yearFrom(description),
|
||||
};
|
||||
return hints;
|
||||
}
|
||||
|
||||
/** First value wins per field: pass the most trusted source first. */
|
||||
export function mergeHints(...sources: Array<LinkHints | null | undefined>): LinkHints {
|
||||
const merged: LinkHints = {};
|
||||
for (const source of sources) {
|
||||
if (!source) continue;
|
||||
for (const [field, value] of Object.entries(source) as Array<[keyof LinkHints, never]>) {
|
||||
if (merged[field] === undefined && value !== undefined && value !== '') merged[field] = value;
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shop titles carry everything: "LEGO® City Familienhaus mit Elektroauto
|
||||
* 60398", "BlueBrixx - Technisches Hilfswerk™ Vehicle | Set 108782". Keeps
|
||||
* the part that is the set's own name.
|
||||
*/
|
||||
export function cleanSetName(name: string, manufacturer?: string, setNumber?: string): string {
|
||||
let result = name.replace(/[®™©]/g, '');
|
||||
|
||||
// Site names and set references after a separator.
|
||||
result = result.split(/\s+[|•·]\s+/)[0]!;
|
||||
|
||||
// Price-comparison titles: "... ab € 4,20 (2026)", "... ab 52,99 €".
|
||||
result = result.replace(/\s+ab\s+(?:€\s*)?\d[\d.,]*\s*(?:€|EUR)?.*$/i, '');
|
||||
|
||||
if (setNumber) {
|
||||
const escaped = setNumber.replace(/[-]/g, '\\-');
|
||||
result = result.replace(new RegExp(`\\(?\\b${escaped}\\b\\)?`, 'g'), '');
|
||||
}
|
||||
|
||||
if (manufacturer) {
|
||||
for (const brand of BRANDS.filter((entry) => entry.name === manufacturer)) {
|
||||
for (const alias of [brand.name, ...brand.aliases]) {
|
||||
if (alias.length < 3) continue;
|
||||
result = result.replace(new RegExp(`^\\s*${alias}\\b\\s*[-–:]?\\s*`, 'i'), '');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result.replace(/\s{2,}/g, ' ').replace(/^[\s\-–:]+|[\s\-–:,]+$/g, '').trim();
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { BlockList, isIP } from 'node:net';
|
||||
|
||||
/**
|
||||
* Which addresses the server may fetch on a user's behalf.
|
||||
*
|
||||
* The link import downloads pages and pictures from URLs people paste. Without
|
||||
* a guard, a link to http://192.168.1.1/ would make the server talk to the
|
||||
* router - or to any other device in the home network. Only public unicast
|
||||
* addresses pass. Pure, so it is unit tested.
|
||||
*/
|
||||
|
||||
const blocked = new BlockList();
|
||||
|
||||
for (const [network, prefix] of [
|
||||
['0.0.0.0', 8], // "this" network
|
||||
['10.0.0.0', 8], // private
|
||||
['100.64.0.0', 10], // carrier-grade NAT
|
||||
['127.0.0.0', 8], // loopback
|
||||
['169.254.0.0', 16], // link-local, cloud metadata
|
||||
['172.16.0.0', 12], // private
|
||||
['192.0.0.0', 24], // IETF protocol assignments
|
||||
['192.0.2.0', 24], // documentation
|
||||
['192.168.0.0', 16], // private
|
||||
['198.18.0.0', 15], // benchmarking
|
||||
['198.51.100.0', 24], // documentation
|
||||
['203.0.113.0', 24], // documentation
|
||||
['224.0.0.0', 4], // multicast
|
||||
['240.0.0.0', 4], // reserved, broadcast
|
||||
] as const) {
|
||||
blocked.addSubnet(network, prefix, 'ipv4');
|
||||
}
|
||||
|
||||
for (const [network, prefix] of [
|
||||
['::', 128], // unspecified
|
||||
['::1', 128], // loopback
|
||||
['64:ff9b::', 96], // NAT64 - would reach IPv4 space behind it
|
||||
['100::', 64], // discard
|
||||
['2001:db8::', 32], // documentation
|
||||
['fc00::', 7], // unique local
|
||||
['fe80::', 10], // link-local
|
||||
['ff00::', 8], // multicast
|
||||
] as const) {
|
||||
blocked.addSubnet(network, prefix, 'ipv6');
|
||||
}
|
||||
|
||||
export function isPublicAddress(address: string): boolean {
|
||||
const version = isIP(address);
|
||||
if (version === 0) return false;
|
||||
|
||||
if (version === 6) {
|
||||
// An IPv4 address in IPv6 clothing is judged as what it really is.
|
||||
const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(address);
|
||||
if (mapped) return isPublicAddress(mapped[1]!);
|
||||
return !blocked.check(address, 'ipv6');
|
||||
}
|
||||
|
||||
return !blocked.check(address, 'ipv4');
|
||||
}
|
||||
|
||||
/** Only plain web URLs on the standard ports; no credentials in the URL. */
|
||||
export function isAllowedWebUrl(url: URL): boolean {
|
||||
if (url.protocol !== 'https:' && url.protocol !== 'http:') return false;
|
||||
if (url.username || url.password) return false;
|
||||
if (url.port && url.port !== '80' && url.port !== '443') return false;
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import 'server-only';
|
||||
import { lookup as dnsLookup, type LookupAddress } from 'node:dns';
|
||||
import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
import { isIP, type LookupFunction } from 'node:net';
|
||||
import { Readable } from 'node:stream';
|
||||
import zlib from 'node:zlib';
|
||||
import { isAllowedWebUrl, isPublicAddress } from './net-guard';
|
||||
|
||||
/**
|
||||
* Fetches a URL a user pasted - and nothing else.
|
||||
*
|
||||
* - Every hostname is resolved first and refused if any of its addresses is
|
||||
* not public (see net-guard.ts). The check happens inside the socket's DNS
|
||||
* lookup, so a second resolution cannot swap in a private address.
|
||||
* - Redirects are followed by hand, and every hop passes the same checks.
|
||||
* - Size and time are capped, compressed bodies included.
|
||||
*/
|
||||
|
||||
export class SafeFetchError extends Error {}
|
||||
|
||||
export type SafeResponse = {
|
||||
status: number;
|
||||
url: string;
|
||||
contentType: string;
|
||||
body: Buffer;
|
||||
};
|
||||
|
||||
type Options = {
|
||||
maxBytes?: number;
|
||||
timeoutMs?: number;
|
||||
accept?: string;
|
||||
};
|
||||
|
||||
const MAX_REDIRECTS = 4;
|
||||
|
||||
// A regular browser identity: some shops answer anything else with an error
|
||||
// page. It is one request per paste, the same a browser preview would make.
|
||||
const USER_AGENT =
|
||||
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0 Safari/537.36';
|
||||
|
||||
const guardedLookup = ((
|
||||
hostname: string,
|
||||
options: { all?: boolean; family?: number },
|
||||
callback: (error: Error | null, address: string | LookupAddress[], family?: number) => void,
|
||||
) => {
|
||||
dnsLookup(hostname, { family: options.family ?? 0, all: true }, (error, addresses) => {
|
||||
if (error) return callback(error, '', 4);
|
||||
if (addresses.length === 0 || addresses.some((entry) => !isPublicAddress(entry.address))) {
|
||||
return callback(new SafeFetchError(`${hostname} zeigt auf eine interne Adresse.`), '', 4);
|
||||
}
|
||||
if (options.all) return callback(null, addresses);
|
||||
callback(null, addresses[0]!.address, addresses[0]!.family);
|
||||
});
|
||||
}) as unknown as LookupFunction;
|
||||
|
||||
function checkUrl(url: URL) {
|
||||
if (!isAllowedWebUrl(url)) throw new SafeFetchError('Nur normale http- und https-Links.');
|
||||
|
||||
// Literal IPs never reach the DNS lookup, so they are checked here.
|
||||
const host = url.hostname.replace(/^\[|\]$/g, '');
|
||||
if (isIP(host) && !isPublicAddress(host)) {
|
||||
throw new SafeFetchError('Interne Adressen werden nicht abgerufen.');
|
||||
}
|
||||
}
|
||||
|
||||
function decode(response: http.IncomingMessage): Readable {
|
||||
switch ((response.headers['content-encoding'] ?? '').toLowerCase()) {
|
||||
case 'gzip':
|
||||
case 'x-gzip':
|
||||
return response.pipe(zlib.createGunzip());
|
||||
case 'deflate':
|
||||
return response.pipe(zlib.createInflate());
|
||||
case 'br':
|
||||
return response.pipe(zlib.createBrotliDecompress());
|
||||
default:
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
function requestOnce(url: URL, options: Required<Options>, signal: AbortSignal) {
|
||||
return new Promise<{ status: number; location: string | null; contentType: string; body: Buffer }>(
|
||||
(resolve, reject) => {
|
||||
const client = url.protocol === 'https:' ? https : http;
|
||||
const request = client.get(
|
||||
url,
|
||||
{
|
||||
lookup: guardedLookup,
|
||||
signal,
|
||||
headers: {
|
||||
'user-agent': USER_AGENT,
|
||||
accept: options.accept,
|
||||
'accept-language': 'de-DE,de;q=0.9,en;q=0.6',
|
||||
'accept-encoding': 'gzip, deflate, br',
|
||||
},
|
||||
},
|
||||
(response) => {
|
||||
const status = response.statusCode ?? 0;
|
||||
const location = response.headers.location ?? null;
|
||||
const contentType = response.headers['content-type'] ?? '';
|
||||
|
||||
if (status >= 300 && status < 400 && location) {
|
||||
response.resume();
|
||||
resolve({ status, location, contentType, body: Buffer.alloc(0) });
|
||||
return;
|
||||
}
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
let size = 0;
|
||||
const stream = decode(response);
|
||||
stream.on('data', (chunk: Buffer) => {
|
||||
size += chunk.byteLength;
|
||||
if (size > options.maxBytes) {
|
||||
request.destroy();
|
||||
stream.destroy();
|
||||
reject(new SafeFetchError('Die Seite ist zu groß.'));
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
stream.on('end', () =>
|
||||
resolve({ status, location: null, contentType, body: Buffer.concat(chunks) }),
|
||||
);
|
||||
stream.on('error', reject);
|
||||
},
|
||||
);
|
||||
request.on('error', reject);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function safeFetch(rawUrl: string | URL, options: Options = {}): Promise<SafeResponse> {
|
||||
const settings: Required<Options> = {
|
||||
maxBytes: options.maxBytes ?? 3 * 1024 * 1024,
|
||||
timeoutMs: options.timeoutMs ?? 10_000,
|
||||
accept: options.accept ?? 'text/html,application/xhtml+xml;q=0.9,*/*;q=0.5',
|
||||
};
|
||||
const signal = AbortSignal.timeout(settings.timeoutMs);
|
||||
|
||||
let url = new URL(rawUrl);
|
||||
for (let hop = 0; hop <= MAX_REDIRECTS; hop += 1) {
|
||||
checkUrl(url);
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await requestOnce(url, settings, signal);
|
||||
} catch (error) {
|
||||
if (error instanceof SafeFetchError) throw error;
|
||||
if (signal.aborted) throw new SafeFetchError('Die Seite hat zu lange gebraucht.');
|
||||
throw new SafeFetchError('Die Seite war nicht erreichbar.');
|
||||
}
|
||||
|
||||
if (result.location) {
|
||||
url = new URL(result.location, url);
|
||||
continue;
|
||||
}
|
||||
|
||||
return {
|
||||
status: result.status,
|
||||
url: url.toString(),
|
||||
contentType: result.contentType,
|
||||
body: result.body,
|
||||
};
|
||||
}
|
||||
|
||||
throw new SafeFetchError('Zu viele Weiterleitungen.');
|
||||
}
|
||||
@@ -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(['', '', '', '', '']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
canonicalBrand,
|
||||
cleanSetName,
|
||||
hintsFromHtml,
|
||||
hintsFromUrl,
|
||||
mergeHints,
|
||||
sourceLabel,
|
||||
} from '@/lib/link-import';
|
||||
import { isAllowedWebUrl, isPublicAddress } from '@/lib/net-guard';
|
||||
|
||||
describe('Hersteller erkennen', () => {
|
||||
it('kennt die gaengigen Schreibweisen', () => {
|
||||
expect(canonicalBrand('LEGO®')).toBe('Lego');
|
||||
expect(canonicalBrand('Blue Brixx')).toBe('BlueBrixx');
|
||||
expect(canonicalBrand('BlueBrixx Pro')).toBe('BlueBrixx');
|
||||
expect(canonicalBrand('Mega Construx')).toBe('Mega Bloks');
|
||||
expect(canonicalBrand('COBI')).toBe('Cobi');
|
||||
});
|
||||
|
||||
it('raet nicht bei Unbekanntem', () => {
|
||||
expect(canonicalBrand('Ravensburger')).toBeUndefined();
|
||||
expect(canonicalBrand('')).toBeUndefined();
|
||||
expect(canonicalBrand('legoland')).toBe('Lego');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Daten aus dem Link', () => {
|
||||
it('liest Amazon-Links mit Produktnamen', () => {
|
||||
const url = new URL(
|
||||
'https://www.amazon.de/LEGO-60398-Familienhaus-Elektroauto-Spielzeug/dp/B0BYZJXGZQ/ref=sr_1_1?keywords=lego',
|
||||
);
|
||||
expect(sourceLabel(url)).toBe('Amazon');
|
||||
expect(hintsFromUrl(url)).toEqual({
|
||||
manufacturer: 'Lego',
|
||||
setNumber: '60398',
|
||||
name: 'Familienhaus Elektroauto',
|
||||
});
|
||||
});
|
||||
|
||||
it('nimmt aus einem Amazon-Link ohne Namen nicht die ASIN', () => {
|
||||
const hints = hintsFromUrl(new URL('https://www.amazon.de/dp/B0BYZJXGZQ'));
|
||||
expect(hints.setNumber).toBeUndefined();
|
||||
});
|
||||
|
||||
it('liest idealo und geizhals', () => {
|
||||
expect(
|
||||
hintsFromUrl(
|
||||
new URL(
|
||||
'https://www.idealo.de/preisvergleich/OffersOfProduct/202595377_-familienhaus-mit-elektroauto-60398-lego.html',
|
||||
),
|
||||
),
|
||||
).toEqual({ manufacturer: 'Lego', setNumber: '60398', name: 'Familienhaus mit Elektroauto' });
|
||||
|
||||
expect(
|
||||
hintsFromUrl(new URL('https://geizhals.de/bluebrixx-burg-blaustein-101400-a2101234.html')),
|
||||
).toEqual({ manufacturer: 'BlueBrixx', setNumber: '101400', name: 'Burg Blaustein' });
|
||||
});
|
||||
|
||||
it('erkennt Cobi-Nummern mit Bindestrich und Jahreszahlen nicht als Setnummer', () => {
|
||||
expect(hintsFromUrl(new URL('https://example.com/shop/cobi-2-30500-ford-mustang-gt')).setNumber).toBe(
|
||||
'2-30500',
|
||||
);
|
||||
expect(
|
||||
hintsFromUrl(new URL('https://example.com/p/playmobil-adventskalender-2024-71472')).setNumber,
|
||||
).toBe('71472');
|
||||
});
|
||||
|
||||
it('liest Marke und Nummer aus Merlins-Bricks-Adressen', () => {
|
||||
expect(hintsFromUrl(new URL('https://www.merlinsbricks.com/sets/bb-108782/'))).toEqual({
|
||||
manufacturer: 'BlueBrixx',
|
||||
setNumber: '108782',
|
||||
});
|
||||
expect(hintsFromUrl(new URL('https://www.merlinsbricks.com/sets/cobi-2400'))).toEqual({
|
||||
manufacturer: 'Cobi',
|
||||
setNumber: '2400',
|
||||
});
|
||||
});
|
||||
|
||||
it('erkennt den Hersteller am Shop', () => {
|
||||
expect(hintsFromUrl(new URL('https://www.bluebrixx.com/de/star-trek/108123/raumstation')).manufacturer).toBe(
|
||||
'BlueBrixx',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Daten aus der Seite', () => {
|
||||
const page = new URL('https://brickfact.com/de-de/sets/lego-city-familienhaus-60398');
|
||||
|
||||
it('nimmt schema.org-Produktdaten', () => {
|
||||
const html = `<html><head>
|
||||
<script type="application/ld+json">{"@context":"https://schema.org","@graph":[
|
||||
{"@type":"WebSite","name":"Brickfact"},
|
||||
{"@type":"Product","name":"LEGO® City Familienhaus mit Elektroauto 60398",
|
||||
"sku":"60398","mpn":"60398","gtin13":"5702017462363",
|
||||
"brand":{"@type":"Brand","name":"LEGO"},
|
||||
"image":["/img/60398.webp"],
|
||||
"description":"Ein Set mit 462 Teilen aus 2023."}]}
|
||||
</script></head></html>`;
|
||||
|
||||
expect(hintsFromHtml(html, page)).toEqual({
|
||||
manufacturer: 'Lego',
|
||||
setNumber: '60398',
|
||||
name: 'LEGO® City Familienhaus mit Elektroauto 60398',
|
||||
ean: '5702017462363',
|
||||
imageUrl: 'https://brickfact.com/img/60398.webp',
|
||||
pieceCount: 462,
|
||||
year: 2023,
|
||||
});
|
||||
});
|
||||
|
||||
it('faellt auf Open Graph zurueck und verwirft ungueltige EAN', () => {
|
||||
const html = `<meta property="og:title" content="Burg Blaustein & Zubehör">
|
||||
<meta content="https://cdn.example.com/burg.jpg" property="og:image">
|
||||
<script type="application/ld+json">{"@type":"Product","gtin13":"1234567890123"}</script>`;
|
||||
|
||||
const hints = hintsFromHtml(html, new URL('https://example.com/burg'));
|
||||
expect(hints.name).toBe('Burg Blaustein & Zubehör');
|
||||
expect(hints.imageUrl).toBe('https://cdn.example.com/burg.jpg');
|
||||
expect(hints.ean).toBeUndefined();
|
||||
});
|
||||
|
||||
it('haelt Shop-Artikelnummern nicht fuer Setnummern', () => {
|
||||
const html = '<script type="application/ld+json">{"@type":"Product","sku":"B0BYZJXGZQ"}</script>';
|
||||
expect(hintsFromHtml(html, page).setNumber).toBeUndefined();
|
||||
});
|
||||
|
||||
it('liest Amazon-Seiten ohne schema.org', () => {
|
||||
const html = `
|
||||
<span id="productTitle" class="a-size-large"> LEGO City Familienhaus mit Elektroauto, 60398 </span>
|
||||
<a id="bylineInfo" href="/stores/LEGO">Besuche den LEGO-Store</a>
|
||||
<script>var data = {"hiRes":"https://m.media-amazon.com/images/I/81abc.jpg","thumb":"x"};</script>
|
||||
<table><tr><th class="prodDetSectionEntry"> Modellnummer </th><td> ‎60398 </td></tr>
|
||||
<tr><th class="prodDetSectionEntry"> Anzahl der Teile </th><td> 462 </td></tr></table>`;
|
||||
|
||||
expect(hintsFromHtml(html, new URL('https://www.amazon.de/dp/B0BYZJXGZQ'))).toEqual({
|
||||
manufacturer: 'Lego',
|
||||
setNumber: '60398',
|
||||
name: 'LEGO City Familienhaus mit Elektroauto, 60398',
|
||||
ean: undefined,
|
||||
imageUrl: 'https://m.media-amazon.com/images/I/81abc.jpg',
|
||||
pieceCount: 462,
|
||||
});
|
||||
});
|
||||
|
||||
it('findet die Setnummer im Produktnamen', () => {
|
||||
const html = '<meta property="og:title" content="Cobi 2400 Ford GP ab € 12,90 (2026)">';
|
||||
const hints = hintsFromHtml(html, new URL('https://geizhals.de/x-a1.html'));
|
||||
expect(hints.setNumber).toBe('2400');
|
||||
expect(cleanSetName(hints.name!, 'Cobi', hints.setNumber)).toBe('Ford GP');
|
||||
});
|
||||
|
||||
it('uebersteht kaputtes JSON', () => {
|
||||
const html = '<script type="application/ld+json">{ kaputt </script><meta property="og:title" content="X">';
|
||||
expect(hintsFromHtml(html, page).name).toBe('X');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Zusammenfuehren und Namen putzen', () => {
|
||||
it('nimmt pro Feld die erste Quelle mit Wert', () => {
|
||||
expect(
|
||||
mergeHints({ name: 'Seite', pieceCount: undefined }, { name: 'Link', pieceCount: 450 }, null),
|
||||
).toEqual({ name: 'Seite', pieceCount: 450 });
|
||||
});
|
||||
|
||||
it('entfernt Marke, Nummer und Seitennamen', () => {
|
||||
expect(cleanSetName('LEGO® City Familienhaus mit Elektroauto 60398', 'Lego', '60398')).toBe(
|
||||
'City Familienhaus mit Elektroauto',
|
||||
);
|
||||
expect(
|
||||
cleanSetName('BlueBrixx - Technisches Hilfswerk™ Bi-Directional Vehicle | Set 108782', 'BlueBrixx', '108782'),
|
||||
).toBe('Technisches Hilfswerk Bi-Directional Vehicle');
|
||||
expect(cleanSetName('Cobi Ford GP • Set 2400 • SetDB • Merlins Bricks', 'Cobi', '2400')).toBe('Ford GP');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Nur oeffentliche Adressen', () => {
|
||||
it('sperrt Heimnetz, Loopback und Metadaten-Adressen', () => {
|
||||
for (const address of [
|
||||
'127.0.0.1',
|
||||
'10.0.0.5',
|
||||
'172.16.3.4',
|
||||
'192.168.178.1',
|
||||
'169.254.169.254',
|
||||
'100.64.0.1',
|
||||
'0.0.0.0',
|
||||
'::1',
|
||||
'fd00::1',
|
||||
'fe80::1',
|
||||
'::ffff:192.168.1.1',
|
||||
]) {
|
||||
expect(isPublicAddress(address), address).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('laesst oeffentliche Adressen durch', () => {
|
||||
expect(isPublicAddress('93.184.216.34')).toBe(true);
|
||||
expect(isPublicAddress('2a00:1450:4001:80b::200e')).toBe(true);
|
||||
expect(isPublicAddress('::ffff:93.184.216.34')).toBe(true);
|
||||
});
|
||||
|
||||
it('erlaubt nur normale Web-Links', () => {
|
||||
expect(isAllowedWebUrl(new URL('https://example.com/a'))).toBe(true);
|
||||
expect(isAllowedWebUrl(new URL('http://example.com:80/a'))).toBe(true);
|
||||
expect(isAllowedWebUrl(new URL('file:///etc/passwd'))).toBe(false);
|
||||
expect(isAllowedWebUrl(new URL('https://example.com:8443/'))).toBe(false);
|
||||
expect(isAllowedWebUrl(new URL('https://user:pw@example.com/'))).toBe(false);
|
||||
});
|
||||
});
|
||||