Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76af719fac | ||
|
|
ebab5627de | ||
|
|
01bbb3ba9a | ||
|
|
ebdd6c313a | ||
|
|
7d75e4345a | ||
|
|
7d7711eb8d | ||
|
|
a8d933bf5b | ||
|
|
295da414bb | ||
|
|
d5ffcbae60 | ||
|
|
d578eac743 | ||
|
|
95a28640e1 | ||
|
|
498a5c6226 |
@@ -33,9 +33,9 @@ TZ=Europe/Berlin
|
|||||||
# Kostenlos anlegen: https://rebrickable.com/api/ (Konto -> Settings -> API)
|
# Kostenlos anlegen: https://rebrickable.com/api/ (Konto -> Settings -> API)
|
||||||
# REBRICKABLE_API_KEY=
|
# REBRICKABLE_API_KEY=
|
||||||
|
|
||||||
# Optional: Mailversand fuer Erinnerungen an Wunschlisten-Termine. Ohne
|
# Optional: Mailversand fuer Erinnerungen an Wunschlisten-Termine. Einfacher
|
||||||
# SMTP_HOST und SMTP_FROM sind Erinnerungen komplett aus - alles andere
|
# geht es in der App unter Mehr -> Erinnerungen -> Mailserver. Steht der
|
||||||
# funktioniert unveraendert.
|
# Server hier, hat die .env Vorrang und die App zeigt ihn nur an.
|
||||||
# SMTP_HOST=mail.example.de
|
# SMTP_HOST=mail.example.de
|
||||||
# SMTP_PORT=587
|
# SMTP_PORT=587
|
||||||
# SMTP_SECURE=false
|
# SMTP_SECURE=false
|
||||||
|
|||||||
@@ -7,6 +7,47 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
|
||||||
|
- **Mailserver in der App einrichten**: Unter Mehr → Erinnerungen → Mailserver
|
||||||
|
lassen sich Server, Port, Verschlüsselung, Zugang und Absender eintragen –
|
||||||
|
ohne `.env` und ohne Neustart. Das Passwort wird verschlüsselt gespeichert
|
||||||
|
(AES-256-GCM, Schlüssel aus `BETTER_AUTH_SECRET`), nie wieder angezeigt und
|
||||||
|
nicht exportiert. Steht der Server in der `.env`, hat sie weiter Vorrang.
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
|
||||||
|
- Der Zeitplan für Erinnerungen läuft immer und prüft bei jedem Durchlauf, ob
|
||||||
|
ein Mailserver eingerichtet ist. Vorher wurde er nur gestartet, wenn SMTP
|
||||||
|
schon beim Containerstart in der `.env` stand.
|
||||||
|
- **Neue Navigation**: unten Start, Katalog, Sammlung, Wünsche und in der
|
||||||
|
Mitte ein +-Stein mit allen Wegen zu einem neuen Set (Scannen, aus Link,
|
||||||
|
Lego-Nummer, von Hand).
|
||||||
|
- **„Mehr" statt Konto-Menü und Verwaltungsreitern**: Konto, Design,
|
||||||
|
Sammler, Einladungen, Erinnerungen, Hersteller, Themen, Standorte und
|
||||||
|
Export/Import als Kacheln in Gruppen, hinter dem Button „Mehr" oben rechts.
|
||||||
|
- **„Für wen?" in der Kopfzeile**: Bei mehreren Sammlern wird das Kind einmal
|
||||||
|
oben gewählt und gilt für Startseite, Katalog und Sammlung. Jedes Gerät
|
||||||
|
merkt sich die Wahl; ein `?sammler=` im Link hat weiterhin Vorrang.
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
|
||||||
|
- Mitglieder (nicht Admin) kamen nicht an Sammler, Hersteller, Themen und
|
||||||
|
Standorte heran, obwohl sie diese pflegen dürfen: Der Eintrag
|
||||||
|
„Verwaltung" stand nur im Menü des Administrators.
|
||||||
|
|
||||||
|
### 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
|
## [0.4.0] - 2026-10-25
|
||||||
|
|
||||||
Ein neues Gesicht: Bricktrack sieht jetzt aus wie das, was es verwaltet.
|
Ein neues Gesicht: Bricktrack sieht jetzt aus wie das, was es verwaltet.
|
||||||
|
|||||||
@@ -61,8 +61,13 @@ Bild-URLs, die der Nutzer selbst hinterlegt hat.
|
|||||||
| `src/lib/prices.ts` | Wertentwicklung aus notierten Preisen (rein) |
|
| `src/lib/prices.ts` | Wertentwicklung aus notierten Preisen (rein) |
|
||||||
| `src/lib/reminders.ts` | Regeln, wann eine Erinnerung faellig ist (rein) |
|
| `src/lib/reminders.ts` | Regeln, wann eine Erinnerung faellig ist (rein) |
|
||||||
| `src/lib/reminder-job.ts`, `src/lib/mailer.ts` | Versand und Zeitplan |
|
| `src/lib/reminder-job.ts`, `src/lib/mailer.ts` | Versand und Zeitplan |
|
||||||
|
| `src/lib/mail-settings.ts`, `src/lib/secret-box.ts` | SMTP aus .env oder App (`app_setting`), Passwort verschluesselt |
|
||||||
| `src/lib/export.ts` | Export und Import des gesamten Bestands |
|
| `src/lib/export.ts` | Export und Import des gesamten Bestands |
|
||||||
| `src/lib/money.ts`, `src/lib/format.ts` | Cent-Umrechnung, dd.MM.yyyy |
|
| `src/lib/money.ts`, `src/lib/format.ts` | Cent-Umrechnung, dd.MM.yyyy |
|
||||||
|
| `src/components/layout/more-items.ts` | Eintraege der Seite „Mehr" (Konto, Design, Stammdaten), auch Titel der Verwaltungsseiten |
|
||||||
|
| `src/lib/collector-choice.ts` | Aktiver Sammler: `?sammler=` vor Cookie aus dem Umschalter „Fuer wen?" |
|
||||||
|
| `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/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 |
|
| `src/components/brick/` | Noppen, Logo, Platzhalter fuer Sets ohne Bild |
|
||||||
|
|
||||||
@@ -195,11 +200,13 @@ Session.
|
|||||||
nichts preis ausser den Wunschlisten-Daten - keine Nutzernamen,
|
nichts preis ausser den Wunschlisten-Daten - keine Nutzernamen,
|
||||||
keine Sammlungsdaten, keine IDs anderer Objekte.
|
keine Sammlungsdaten, keine IDs anderer Objekte.
|
||||||
4. Externe API-Aufrufe (Rebrickable, ab Phase 2) laufen ausschliesslich
|
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`.
|
5. Session-Cookies: `httpOnly`, `secure`, `sameSite=lax`.
|
||||||
Rate-Limiting auf den Login-Endpunkten.
|
Rate-Limiting auf den Login-Endpunkten.
|
||||||
6. Der Export enthaelt **keine** Konten, Passwoerter oder Passkeys - und
|
6. Der Export enthaelt **keine** Konten, Passwoerter oder Passkeys - und
|
||||||
keine Reservierungen oder Teilen-Token. Das Backup ist `./data`, nicht der
|
keine Reservierungen, Teilen-Token oder `app_setting` (SMTP-Zugang). Das Backup ist `./data`, nicht der
|
||||||
Export.
|
Export.
|
||||||
7. Der Service Worker beantwortet nur GET-Anfragen aus dem Cache und niemals
|
7. Der Service Worker beantwortet nur GET-Anfragen aus dem Cache und niemals
|
||||||
`/api/auth`, `/api/export` oder den Healthcheck. Beim Abmelden wird der
|
`/api/auth`, `/api/export` oder den Healthcheck. Beim Abmelden wird der
|
||||||
|
|||||||
@@ -144,16 +144,16 @@ tut.
|
|||||||
|
|
||||||
## 7. Passkey hinterlegen
|
## 7. Passkey hinterlegen
|
||||||
|
|
||||||
Unter **Konto → Passkeys → Passkey hinzufügen**. Ab dann meldest du dich per
|
Unter **Mehr → Konto & Passkeys → Passkey hinzufügen** (der Button „Mehr" oben rechts). Ab dann meldest du dich per
|
||||||
Fingerabdruck, Gesichtserkennung oder Geräte-PIN an. Für jedes Gerät ein
|
Fingerabdruck, Gesichtserkennung oder Geräte-PIN an. Für jedes Gerät ein
|
||||||
eigener Passkey – Handy, Tablet, Laptop.
|
eigener Passkey – Handy, Tablet, Laptop.
|
||||||
|
|
||||||
## 8. Loslegen
|
## 8. Loslegen
|
||||||
|
|
||||||
1. **Verwaltung → Hersteller**: Lego, Playmobil, Cobi … anlegen.
|
1. **Mehr → Hersteller**: Lego, Playmobil, Cobi … anlegen.
|
||||||
2. **Verwaltung → Themen**: „City", „Technic", „Novelmore" …
|
2. **Mehr → Themen**: „City", „Technic", „Novelmore" …
|
||||||
3. **Verwaltung → Standorte**: „Regal Kinderzimmer", „Kiste Keller 3" …
|
3. **Mehr → Standorte**: „Regal Kinderzimmer", „Kiste Keller 3" …
|
||||||
4. **Katalog → Set anlegen**: Setnummer, Name, Bild.
|
4. **+ in der Navigation → Von Hand**: Setnummer, Name, Bild.
|
||||||
5. Auf der Set-Seite **„Zur Sammlung hinzufügen"**, wenn das Set da ist.
|
5. Auf der Set-Seite **„Zur Sammlung hinzufügen"**, wenn das Set da ist.
|
||||||
6. **Katalog → Filter „Fehlt noch"**: Kachel lange drücken, mehrere auswählen,
|
6. **Katalog → Filter „Fehlt noch"**: Kachel lange drücken, mehrere auswählen,
|
||||||
**„Zur Wunschliste hinzufügen"**.
|
**„Zur Wunschliste hinzufügen"**.
|
||||||
|
|||||||
@@ -63,7 +63,21 @@ Die Startseite zeigt auf einen Blick, wo die Sammlung steht:
|
|||||||
| **Abkürzungen** | Direkt zu den fehlenden Sets, zu den Wunschlisten oder zu einem neuen Set |
|
| **Abkürzungen** | Direkt zu den fehlenden Sets, zu den Wunschlisten oder zu einem neuen Set |
|
||||||
|
|
||||||
Unten am Bildschirmrand liegt die Navigation – auf dem Handy mit dem Daumen
|
Unten am Bildschirmrand liegt die Navigation – auf dem Handy mit dem Daumen
|
||||||
erreichbar. Der aktive Bereich sitzt auf einer kleinen Platte.
|
erreichbar: **Start, Katalog, Sammlung, Wünsche** und in der Mitte der rote
|
||||||
|
**+-Stein**. Der aktive Bereich sitzt auf einer kleinen Platte.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Das **+** bündelt alle Wege, wie ein Set in den Katalog kommt: Strichcode
|
||||||
|
scannen, aus einem Link übernehmen, Lego-Nummer holen oder von Hand eintragen.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Alles, was man nicht täglich braucht, liegt hinter dem Button
|
||||||
|
**„Mehr"** oben rechts: Konto und Passkeys, Design,
|
||||||
|
Sammler, Einladungen, Erinnerungen, Hersteller, Themen, Standorte sowie
|
||||||
|
Export und Import. Was nur der Administrator darf, sehen Mitglieder dort gar
|
||||||
|
nicht erst.
|
||||||
|
|
||||||
Sets ohne Bild bekommen einen farbigen Platzhalter mit der Setnummer. Jeder
|
Sets ohne Bild bekommen einen farbigen Platzhalter mit der Setnummer. Jeder
|
||||||
Hersteller hat seine eigene Farbe – Lego gelb, Playmobil blau, Cobi grün.
|
Hersteller hat seine eigene Farbe – Lego gelb, Playmobil blau, Cobi grün.
|
||||||
@@ -74,8 +88,9 @@ Hersteller hat seine eigene Farbe – Lego gelb, Playmobil blau, Cobi grün.
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
Bevor du Sets anlegst, brauchst du mindestens einen **Hersteller**. Zu finden
|
Bevor du Sets von Hand anlegst, brauchst du mindestens einen **Hersteller**.
|
||||||
über das Konto-Menü oben rechts → **Verwaltung**.
|
Zu finden unter **Mehr → Hersteller**. Der Lego-Import und die Übernahme aus
|
||||||
|
einem Link legen fehlende Hersteller selbst an.
|
||||||
|
|
||||||
Drei Arten von Stammdaten:
|
Drei Arten von Stammdaten:
|
||||||
|
|
||||||
@@ -117,7 +132,7 @@ Die Kacheln zeigen auf einen Blick:
|
|||||||
- **„Auf Wunschliste"** – steht auf mindestens einer Liste
|
- **„Auf Wunschliste"** – steht auf mindestens einer Liste
|
||||||
- Setnummer, Hersteller, Thema, Jahr und UVP
|
- Setnummer, Hersteller, Thema, Jahr und UVP
|
||||||
|
|
||||||
Mit **„Set anlegen"** oben rechts kommt ein neues Set dazu. Pflicht sind nur
|
Über das **+** in der Navigation kommt ein neues Set dazu. Pflicht sind nur
|
||||||
Hersteller, Setnummer und Name; alles andere kannst du nachtragen. Ein Bild
|
Hersteller, Setnummer und Name; alles andere kannst du nachtragen. Ein Bild
|
||||||
lädst du entweder hoch (es wird verkleinert und lokal gespeichert) oder du
|
lädst du entweder hoch (es wird verkleinert und lokal gespeichert) oder du
|
||||||
hinterlegst eine Bild-URL.
|
hinterlegst eine Bild-URL.
|
||||||
@@ -286,7 +301,7 @@ zweites Mal.
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
Unter **Konto → Passkeys → Passkey hinzufügen** hinterlegst du Fingerabdruck,
|
Unter **Mehr → Konto & Passkeys → Passkey hinzufügen** hinterlegst du Fingerabdruck,
|
||||||
Gesichtserkennung oder die Geräte-PIN als Anmeldung. Danach reicht auf der
|
Gesichtserkennung oder die Geräte-PIN als Anmeldung. Danach reicht auf der
|
||||||
Anmeldeseite ein Tipp auf „Mit Passkey anmelden" – auf vielen Geräten schlägt
|
Anmeldeseite ein Tipp auf „Mit Passkey anmelden" – auf vielen Geräten schlägt
|
||||||
der Browser den Passkey sogar von selbst vor.
|
der Browser den Passkey sogar von selbst vor.
|
||||||
@@ -306,14 +321,41 @@ gib ihnen sprechende Namen.
|
|||||||
|
|
||||||
## 11. Import und Scanner
|
## 11. Import und Scanner
|
||||||
|
|
||||||
Sets von Hand einzutippen ist der langsame Weg. Für Lego gibt es zwei
|
Sets von Hand einzutippen ist der langsame Weg. Es gibt drei schnellere.
|
||||||
schnellere.
|
|
||||||
|
### Aus einem Link übernehmen – für alle Hersteller
|
||||||
|
|
||||||
|
Über **+ → Aus Link** landest du im 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
|
### Lego-Import über die Setnummer
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
Auf **Katalog → Set anlegen** steht oben das Feld für den Import. Setnummer
|
Über **+ → Lego-Nummer** landest du im Feld für den Import. Setnummer
|
||||||
von der Packung eintippen, „Holen" – und Name, Jahr, Teile, Minifiguren und
|
von der Packung eintippen, „Holen" – und Name, Jahr, Teile, Minifiguren und
|
||||||
Thema stehen drin. Das Bild wird **lokal gespeichert**, funktioniert also auch
|
Thema stehen drin. Das Bild wird **lokal gespeichert**, funktioniert also auch
|
||||||
weiter, wenn die Quelle es irgendwann austauscht. Fehlen Hersteller oder
|
weiter, wenn die Quelle es irgendwann austauscht. Fehlen Hersteller oder
|
||||||
@@ -366,7 +408,7 @@ der Sammlung stimmt damit, ohne dass du daran denken musst.
|
|||||||
|
|
||||||
Die Registrierung ist nach dem ersten Konto geschlossen – sonst könnte sich
|
Die Registrierung ist nach dem ersten Konto geschlossen – sonst könnte sich
|
||||||
jeder, der die Adresse kennt, ein Konto anlegen. Für den zweiten Elternteil
|
jeder, der die Adresse kennt, ein Konto anlegen. Für den zweiten Elternteil
|
||||||
gibt es deshalb **Verwaltung → Einladungen**:
|
gibt es deshalb **Mehr → Einladungen**:
|
||||||
|
|
||||||
1. „Einladung erstellen", optional an eine E-Mail-Adresse gebunden, mit Rolle
|
1. „Einladung erstellen", optional an eine E-Mail-Adresse gebunden, mit Rolle
|
||||||
und Gültigkeit.
|
und Gültigkeit.
|
||||||
@@ -384,7 +426,7 @@ zusätzlich einladen und Daten exportieren.
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
Unter **Verwaltung → Daten** gibt es drei Downloads:
|
Unter **Mehr → Export & Import** gibt es drei Downloads:
|
||||||
|
|
||||||
| | Wofür |
|
| | Wofür |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -444,14 +486,16 @@ Sammler. Ein Set, das nur die Schwester hat, fällt für den Bruder wieder unter
|
|||||||
> vorher. Erst der zweite Sammler schaltet all das frei – und dann willst du
|
> vorher. Erst der zweite Sammler schaltet all das frei – und dann willst du
|
||||||
> es auch.
|
> es auch.
|
||||||
|
|
||||||
Anlegen unter **Verwaltung → Sammler**. Jeder bekommt einen Namen und eine
|
Anlegen unter **Mehr → Sammler**. Jeder bekommt einen Namen und eine
|
||||||
Farbe zur Unterscheidung.
|
Farbe zur Unterscheidung.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
Danach steht in Katalog und Sammlung oben ein Umschalter. „Alle zusammen"
|
Danach steht oben unter dem Logo der Umschalter **„Für wen?"**. „Alle"
|
||||||
zeigt den Haushalt als Ganzes, ein Name zeigt genau dessen Sicht – inklusive
|
zeigt den Haushalt als Ganzes, ein Name zeigt genau dessen Sicht – auf der
|
||||||
der Frage, was **diesem** Kind noch fehlt.
|
Startseite, im Katalog und in der Sammlung, inklusive der Frage, was
|
||||||
|
**diesem** Kind noch fehlt. Die Wahl merkt sich jedes Gerät: Das Tablet im
|
||||||
|
Kinderzimmer öffnet direkt bei Max.
|
||||||
|
|
||||||
Beim Hinzufügen zur Sammlung fragt die App jetzt, wer das Set bekommt. Und
|
Beim Hinzufügen zur Sammlung fragt die App jetzt, wer das Set bekommt. Und
|
||||||
beim Anlegen einer Wunschliste, für wen sie ist: Das bestimmt, wessen Sammlung
|
beim Anlegen einer Wunschliste, für wen sie ist: Das bestimmt, wessen Sammlung
|
||||||
@@ -459,7 +503,7 @@ als „hat er schon" zählt und wann ein Wunsch automatisch als erfüllt gilt.
|
|||||||
|
|
||||||
> Die Umstellung einer bestehenden Instanz macht nichts kaputt: Beim Update
|
> Die Umstellung einer bestehenden Instanz macht nichts kaputt: Beim Update
|
||||||
> entsteht ein Sammler namens „Sammlung", dem alles Vorhandene gehört.
|
> entsteht ein Sammler namens „Sammlung", dem alles Vorhandene gehört.
|
||||||
> Umbenennen unter **Verwaltung → Sammler**, fertig.
|
> Umbenennen unter **Mehr → Sammler**, fertig.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -494,25 +538,27 @@ schickt Bricktrack dir eine E-Mail – standardmäßig 14 Tage vorher, höchsten
|
|||||||
einmal pro Woche je Liste. Ist alles reserviert, bleibt es still: eine
|
einmal pro Woche je Liste. Ist alles reserviert, bleibt es still: eine
|
||||||
Erinnerung ohne Handlungsbedarf ist nur Lärm.
|
Erinnerung ohne Handlungsbedarf ist nur Lärm.
|
||||||
|
|
||||||
Dafür braucht es einen SMTP-Zugang in der `.env`:
|
Dafür braucht es einen Mailserver (SMTP). Den trägst du direkt in der App ein:
|
||||||
|
**Mehr → Erinnerungen → Mailserver**. Die Angaben – Server, Port,
|
||||||
|
Verschlüsselung, Benutzername, Passwort und Absender – findest du bei deinem
|
||||||
|
Mail-Anbieter unter „SMTP" oder „Postausgangsserver". Ein Neustart ist nicht
|
||||||
|
nötig.
|
||||||
|
|
||||||
```bash
|
Das Passwort wird verschlüsselt gespeichert und nie wieder angezeigt; zum
|
||||||
SMTP_HOST=mail.example.de
|
Ändern einfach ein neues eintragen. Es landet auch nicht im Export.
|
||||||
SMTP_PORT=587
|
|
||||||
SMTP_USER=bricktrack@example.de
|
|
||||||
SMTP_PASSWORD=…
|
|
||||||
SMTP_FROM=Bricktrack <bricktrack@example.de>
|
|
||||||
|
|
||||||
# Optional, Standard ist 14:
|
> Wer lieber mit der `.env` arbeitet, kann den Mailserver weiterhin dort
|
||||||
REMINDER_DAYS_BEFORE=14
|
> eintragen (`SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`,
|
||||||
```
|
> `SMTP_FROM`). Dann hat die `.env` Vorrang, und die App zeigt die Werte nur
|
||||||
|
> an. Wie viele Tage vorher erinnert wird, steht in `REMINDER_DAYS_BEFORE`
|
||||||
|
> (Standard 14).
|
||||||
|
|
||||||
Unter **Verwaltung → Erinnerungen** kannst du die Verbindung prüfen, dir eine
|
Unter **Mehr → Erinnerungen** kannst du außerdem die Verbindung prüfen, dir eine
|
||||||
Testnachricht schicken und die Prüfung sofort auslösen, statt auf die nächste
|
Testnachricht schicken und die Prüfung sofort auslösen, statt auf die nächste
|
||||||
Stunde zu warten. Dort steht auch, welche Listen einen Termin haben und wie
|
Stunde zu warten. Dort steht auch, welche Listen einen Termin haben und wie
|
||||||
viel dort noch offen ist.
|
viel dort noch offen ist.
|
||||||
|
|
||||||
Ohne SMTP-Zugang ist die Funktion vollständig aus – alles andere funktioniert
|
Ohne Mailserver ist die Funktion vollständig aus – alles andere funktioniert
|
||||||
unverändert.
|
unverändert.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -556,7 +602,7 @@ Haken dran, habt ihr es schon.
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
Unter **Konto → Design** gibt es sechs Looks zur Auswahl:
|
Unter **Mehr → Design** gibt es sechs Looks zur Auswahl:
|
||||||
|
|
||||||
| Design | |
|
| Design | |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 162 KiB After Width: | Height: | Size: 187 KiB |
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 115 KiB |
|
Before Width: | Height: | Size: 435 KiB After Width: | Height: | Size: 445 KiB |
|
Before Width: | Height: | Size: 322 KiB After Width: | Height: | Size: 331 KiB |
|
Before Width: | Height: | Size: 361 KiB After Width: | Height: | Size: 371 KiB |
|
Before Width: | Height: | Size: 446 KiB After Width: | Height: | Size: 490 KiB |
|
Before Width: | Height: | Size: 329 KiB After Width: | Height: | Size: 350 KiB |
|
Before Width: | Height: | Size: 322 KiB After Width: | Height: | Size: 331 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 475 KiB After Width: | Height: | Size: 497 KiB |
|
Before Width: | Height: | Size: 597 KiB After Width: | Height: | Size: 426 KiB |
|
Before Width: | Height: | Size: 476 KiB After Width: | Height: | Size: 498 KiB |
|
Before Width: | Height: | Size: 283 KiB After Width: | Height: | Size: 302 KiB |
|
Before Width: | Height: | Size: 299 KiB After Width: | Height: | Size: 383 KiB |
|
Before Width: | Height: | Size: 205 KiB After Width: | Height: | Size: 245 KiB |
|
Before Width: | Height: | Size: 281 KiB After Width: | Height: | Size: 259 KiB |
|
Before Width: | Height: | Size: 297 KiB After Width: | Height: | Size: 310 KiB |
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 152 KiB |
|
Before Width: | Height: | Size: 368 KiB After Width: | Height: | Size: 380 KiB |
|
Before Width: | Height: | Size: 336 KiB After Width: | Height: | Size: 345 KiB |
|
Before Width: | Height: | Size: 222 KiB After Width: | Height: | Size: 345 KiB |
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 154 KiB |
|
After Width: | Height: | Size: 451 KiB |
|
After Width: | Height: | Size: 271 KiB |
@@ -0,0 +1,5 @@
|
|||||||
|
CREATE TABLE `app_setting` (
|
||||||
|
`key` text PRIMARY KEY NOT NULL,
|
||||||
|
`value` text NOT NULL,
|
||||||
|
`updated_at` integer NOT NULL
|
||||||
|
);
|
||||||
@@ -29,6 +29,13 @@
|
|||||||
"when": 1788765478965,
|
"when": 1788765478965,
|
||||||
"tag": "0003_powerful_sally_floyd",
|
"tag": "0003_powerful_sally_floyd",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 4,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1792963951232,
|
||||||
|
"tag": "0004_modern_agent_brand",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -52,6 +52,17 @@ test('Screenshots fuer die Anleitung', async ({ page, browser }) => {
|
|||||||
await visit(page, '/');
|
await visit(page, '/');
|
||||||
await shoot(page, '02-start');
|
await shoot(page, '02-start');
|
||||||
|
|
||||||
|
// The plus in the middle of the navigation, opened.
|
||||||
|
await page.getByRole('button', { name: 'Neues Set' }).click();
|
||||||
|
await expect(page.getByText('Was kommt dazu?')).toBeVisible();
|
||||||
|
await page.waitForTimeout(400); // let the sheet finish sliding in
|
||||||
|
await page.screenshot({ path: shotPath('26-plus-menue') });
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
|
||||||
|
await visit(page, '/mehr');
|
||||||
|
await expect(page.getByText('Katalog pflegen')).toBeVisible();
|
||||||
|
await shoot(page, '27-mehr');
|
||||||
|
|
||||||
// --- 3. master data -------------------------------------------------------
|
// --- 3. master data -------------------------------------------------------
|
||||||
await visit(page, '/verwaltung/hersteller');
|
await visit(page, '/verwaltung/hersteller');
|
||||||
await expect(page.getByText('Lego', { exact: true })).toBeVisible();
|
await expect(page.getByText('Lego', { exact: true })).toBeVisible();
|
||||||
@@ -186,10 +197,13 @@ test('Screenshots fuer die Anleitung', async ({ page, browser }) => {
|
|||||||
|
|
||||||
// The switcher only exists because the seed created two collectors.
|
// The switcher only exists because the seed created two collectors.
|
||||||
await visit(page, '/katalog?besitz=fehlt');
|
await visit(page, '/katalog?besitz=fehlt');
|
||||||
await expect(page.getByRole('button', { name: /Max/ })).toBeVisible();
|
await expect(page.getByRole('radio', { name: /Max/ })).toBeVisible();
|
||||||
await page.getByRole('button', { name: /Max/ }).click();
|
await page.getByRole('radio', { name: /Max/ }).click();
|
||||||
await expect(page.getByText('Sets, die Max noch nicht hat')).toBeVisible();
|
await expect(page.getByText('Sets, die Max noch nicht hat')).toBeVisible();
|
||||||
await shoot(page, '22-sammler-filter');
|
await shoot(page, '22-sammler-filter');
|
||||||
|
// Back to everybody, so the following pictures show the whole family.
|
||||||
|
await page.getByRole('radio', { name: /Alle/ }).click();
|
||||||
|
await expect(page.getByText('Sets, die noch nicht in der Sammlung sind')).toBeVisible();
|
||||||
|
|
||||||
// --- 17. prices -----------------------------------------------------------
|
// --- 17. prices -----------------------------------------------------------
|
||||||
await visit(page, '/katalog?q=60398');
|
await visit(page, '/katalog?q=60398');
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { revalidatePath } from 'next/cache';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { env } from '@/env';
|
||||||
|
import { actionError, actionOk, type ActionResult } from '@/lib/action-result';
|
||||||
|
import { COLLECTOR_COOKIE } from '@/lib/collector-choice';
|
||||||
|
import { resolveCollectorId } from '@/lib/queries/collectors';
|
||||||
|
import { requireUser } from '@/lib/session';
|
||||||
|
|
||||||
|
const schema = z.object({ id: z.string().max(64) });
|
||||||
|
|
||||||
|
/** Remembers the collector chosen in the header; an empty id means "alle". */
|
||||||
|
export async function chooseCollectorAction(input: unknown): Promise<ActionResult> {
|
||||||
|
await requireUser();
|
||||||
|
|
||||||
|
const parsed = schema.safeParse(input);
|
||||||
|
if (!parsed.success) return actionError('Ungültige Auswahl.');
|
||||||
|
|
||||||
|
const store = await cookies();
|
||||||
|
if (parsed.data.id === '') {
|
||||||
|
store.delete(COLLECTOR_COOKIE);
|
||||||
|
} else {
|
||||||
|
const id = resolveCollectorId(parsed.data.id);
|
||||||
|
if (!id) return actionError('Diesen Sammler gibt es nicht mehr.');
|
||||||
|
store.set(COLLECTOR_COOKIE, id, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: env.isProduction,
|
||||||
|
sameSite: 'lax',
|
||||||
|
path: '/',
|
||||||
|
maxAge: 60 * 60 * 24 * 365,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath('/', 'layout');
|
||||||
|
return actionOk();
|
||||||
|
}
|
||||||
@@ -23,7 +23,8 @@ import {
|
|||||||
listPricePoints,
|
listPricePoints,
|
||||||
} from '@/lib/queries/catalog';
|
} from '@/lib/queries/catalog';
|
||||||
import { listMissingParts } from '@/lib/queries/collection';
|
import { listMissingParts } from '@/lib/queries/collection';
|
||||||
import { listCollectors, resolveCollectorId } from '@/lib/queries/collectors';
|
import { activeCollectorId } from '@/lib/collector-choice';
|
||||||
|
import { listCollectors } from '@/lib/queries/collectors';
|
||||||
import { listLocations } from '@/lib/queries/master-data';
|
import { listLocations } from '@/lib/queries/master-data';
|
||||||
import { listWishlistsForSet, listWishlistTargets } from '@/lib/queries/wishlists';
|
import { listWishlistsForSet, listWishlistTargets } from '@/lib/queries/wishlists';
|
||||||
import { requireUser } from '@/lib/session';
|
import { requireUser } from '@/lib/session';
|
||||||
@@ -47,7 +48,7 @@ export default async function SetDetailPage({ params, searchParams }: Props) {
|
|||||||
// Keeps the "vorhanden / fehlt" badge in step with the collector the user
|
// Keeps the "vorhanden / fehlt" badge in step with the collector the user
|
||||||
// was looking at in the catalog.
|
// was looking at in the catalog.
|
||||||
const rawCollector = Array.isArray(query.sammler) ? query.sammler[0] : query.sammler;
|
const rawCollector = Array.isArray(query.sammler) ? query.sammler[0] : query.sammler;
|
||||||
const collectorId = resolveCollectorId(rawCollector);
|
const collectorId = await activeCollectorId(rawCollector);
|
||||||
const collectors = listCollectors();
|
const collectors = listCollectors();
|
||||||
|
|
||||||
const set = getSetById(id, collectorId);
|
const set = getSetById(id, collectorId);
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import { and, eq, ne } from 'drizzle-orm';
|
|||||||
import { db } from '@/db';
|
import { db } from '@/db';
|
||||||
import { instruction, photo, set } from '@/db/schema';
|
import { instruction, photo, set } from '@/db/schema';
|
||||||
import { actionData, actionError, actionOk, formToObject, parseForm, type ActionResult } from '@/lib/action-result';
|
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 { 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 { optionalText, optionalUrl, setSchema } from '@/lib/validation';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
@@ -20,6 +22,23 @@ function revalidateSet(id?: string) {
|
|||||||
if (id) revalidatePath(`/katalog/${id}`);
|
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 {
|
function fileFrom(formData: FormData, field: string): File | null {
|
||||||
const value = formData.get(field);
|
const value = formData.get(field);
|
||||||
return value instanceof File && value.size > 0 ? value : null;
|
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);
|
if (error instanceof UploadError) return actionError(error.message);
|
||||||
throw error;
|
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)
|
db.insert(set)
|
||||||
.values({
|
.values({
|
||||||
id,
|
id,
|
||||||
...parsed.data,
|
...parsed.data,
|
||||||
|
imageUrl: imagePath ? null : parsed.data.imageUrl,
|
||||||
imagePath,
|
imagePath,
|
||||||
createdAt: now(),
|
createdAt: now(),
|
||||||
updatedAt: 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 type { Metadata } from 'next';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { NewSetEntry } from '@/components/katalog/link-import';
|
||||||
import { RebrickableImport } from '@/components/katalog/rebrickable-import';
|
import { RebrickableImport } from '@/components/katalog/rebrickable-import';
|
||||||
import { SetForm } from '@/components/katalog/set-form';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { listManufacturers, listThemes } from '@/lib/queries/master-data';
|
import { listManufacturers, listThemes } from '@/lib/queries/master-data';
|
||||||
import { isRebrickableEnabled } from '@/lib/rebrickable';
|
import { isRebrickableEnabled } from '@/lib/rebrickable';
|
||||||
@@ -15,6 +15,8 @@ export default async function NeuesSetPage({ searchParams }: { searchParams: Sea
|
|||||||
await requireUser();
|
await requireUser();
|
||||||
const params = await searchParams;
|
const params = await searchParams;
|
||||||
const ean = typeof params.ean === 'string' ? params.ean : undefined;
|
const ean = typeof params.ean === 'string' ? params.ean : undefined;
|
||||||
|
// Which way the plus menu chose: the matching field gets the cursor.
|
||||||
|
const way = typeof params.weg === 'string' ? params.weg : undefined;
|
||||||
|
|
||||||
const manufacturers = listManufacturers().map((row) => ({ id: row.id, name: row.name }));
|
const manufacturers = listManufacturers().map((row) => ({ id: row.id, name: row.name }));
|
||||||
const themes = listThemes().map((row) => ({
|
const themes = listThemes().map((row) => ({
|
||||||
@@ -25,26 +27,8 @@ export default async function NeuesSetPage({ searchParams }: { searchParams: Sea
|
|||||||
|
|
||||||
const importEnabled = isRebrickableEnabled();
|
const importEnabled = isRebrickableEnabled();
|
||||||
|
|
||||||
if (manufacturers.length === 0) {
|
// One layout for both cases: the link import may create the first
|
||||||
return (
|
// manufacturer, and its draft must survive the refresh that follows.
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
@@ -54,15 +38,24 @@ export default async function NeuesSetPage({ searchParams }: { searchParams: Sea
|
|||||||
Sammlung hinzufügen“ fest.
|
Sammlung hinzufügen“ fest.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<RebrickableImport enabled={importEnabled} />
|
{/* The Lego import creates its manufacturer itself, so it works even
|
||||||
|
on a completely empty instance. */}
|
||||||
|
<RebrickableImport enabled={importEnabled} autoFocus={way === 'lego'} />
|
||||||
|
|
||||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
<NewSetEntry
|
||||||
<span className="h-px flex-1 bg-border" />
|
manufacturers={manufacturers}
|
||||||
oder von Hand
|
themes={themes}
|
||||||
<span className="h-px flex-1 bg-border" />
|
defaultEan={ean}
|
||||||
</div>
|
focus={way === 'link' || way === 'hand' ? way : undefined}
|
||||||
|
>
|
||||||
<SetForm 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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Plus } from 'lucide-react';
|
import { Plus } from 'lucide-react';
|
||||||
import { CollectorFilter } from '@/components/layout/collector-filter';
|
|
||||||
import { BarcodeScannerButton } from '@/components/katalog/barcode-scanner';
|
import { BarcodeScannerButton } from '@/components/katalog/barcode-scanner';
|
||||||
import { CatalogFilters } from '@/components/katalog/catalog-filters';
|
import { CatalogFilters } from '@/components/katalog/catalog-filters';
|
||||||
import { CatalogGrid } from '@/components/katalog/catalog-grid';
|
import { CatalogGrid } from '@/components/katalog/catalog-grid';
|
||||||
@@ -10,7 +9,8 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { pluralize } from '@/lib/format';
|
import { pluralize } from '@/lib/format';
|
||||||
import { OWNERSHIP_FILTER_LABELS, parseOwnershipFilter } from '@/lib/ownership';
|
import { OWNERSHIP_FILTER_LABELS, parseOwnershipFilter } from '@/lib/ownership';
|
||||||
import { CATALOG_PAGE_SIZE, listSets, listSetYears } from '@/lib/queries/catalog';
|
import { CATALOG_PAGE_SIZE, listSets, listSetYears } from '@/lib/queries/catalog';
|
||||||
import { listCollectors, resolveCollectorId } from '@/lib/queries/collectors';
|
import { activeCollectorId } from '@/lib/collector-choice';
|
||||||
|
import { listCollectors } from '@/lib/queries/collectors';
|
||||||
import { listManufacturers, listThemes } from '@/lib/queries/master-data';
|
import { listManufacturers, listThemes } from '@/lib/queries/master-data';
|
||||||
import { listWishlistTargets } from '@/lib/queries/wishlists';
|
import { listWishlistTargets } from '@/lib/queries/wishlists';
|
||||||
import { requireUser } from '@/lib/session';
|
import { requireUser } from '@/lib/session';
|
||||||
@@ -30,7 +30,7 @@ export default async function KatalogPage({ searchParams }: { searchParams: Sear
|
|||||||
|
|
||||||
const ownership = parseOwnershipFilter(single(params.besitz));
|
const ownership = parseOwnershipFilter(single(params.besitz));
|
||||||
const collectors = listCollectors();
|
const collectors = listCollectors();
|
||||||
const collectorId = resolveCollectorId(single(params.sammler));
|
const collectorId = await activeCollectorId(single(params.sammler));
|
||||||
const activeCollector = collectors.find((row) => row.id === collectorId) ?? null;
|
const activeCollector = collectors.find((row) => row.id === collectorId) ?? null;
|
||||||
const yearParam = single(params.jahr);
|
const yearParam = single(params.jahr);
|
||||||
const page = Number(single(params.seite) ?? '1') || 1;
|
const page = Number(single(params.seite) ?? '1') || 1;
|
||||||
@@ -80,11 +80,6 @@ export default async function KatalogPage({ searchParams }: { searchParams: Sear
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CollectorFilter
|
|
||||||
collectors={collectors.map((row) => ({ id: row.id, name: row.name, color: row.color }))}
|
|
||||||
allLabel="Alle zusammen"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<CatalogFilters options={{ manufacturers, themes, years: listSetYears() }} />
|
<CatalogFilters options={{ manufacturers, themes, years: listSetYears() }} />
|
||||||
|
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { AppHeader } from '@/components/layout/app-header';
|
import { AppHeader } from '@/components/layout/app-header';
|
||||||
import { BottomNav } from '@/components/layout/bottom-nav';
|
import { BottomNav } from '@/components/layout/bottom-nav';
|
||||||
|
import { storedCollectorId } from '@/lib/collector-choice';
|
||||||
|
import { listCollectors } from '@/lib/queries/collectors';
|
||||||
|
import { isRebrickableEnabled } from '@/lib/rebrickable';
|
||||||
import { ensureReminderScheduler } from '@/lib/reminder-job';
|
import { ensureReminderScheduler } from '@/lib/reminder-job';
|
||||||
import { requireUser } from '@/lib/session';
|
import { requireUser } from '@/lib/session';
|
||||||
|
|
||||||
@@ -9,13 +12,24 @@ export default async function AppLayout({ children }: { children: React.ReactNod
|
|||||||
ensureReminderScheduler();
|
ensureReminderScheduler();
|
||||||
|
|
||||||
const user = await requireUser();
|
const user = await requireUser();
|
||||||
|
const collectors = listCollectors().map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
color: row.color,
|
||||||
|
}));
|
||||||
|
const legoImport = isRebrickableEnabled();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-dvh flex-col">
|
<div className="flex min-h-dvh flex-col">
|
||||||
<AppHeader user={user} />
|
<AppHeader
|
||||||
|
user={user}
|
||||||
|
collectors={collectors}
|
||||||
|
storedCollectorId={(await storedCollectorId()) ?? null}
|
||||||
|
legoImport={legoImport}
|
||||||
|
/>
|
||||||
{/* Bottom padding keeps the mobile nav from covering the last row. */}
|
{/* Bottom padding keeps the mobile nav from covering the last row. */}
|
||||||
<main className="mx-auto w-full max-w-5xl flex-1 px-4 pt-4 pb-24 md:pb-10">{children}</main>
|
<main className="mx-auto w-full max-w-5xl flex-1 px-4 pt-4 pb-28 md:pb-10">{children}</main>
|
||||||
<BottomNav />
|
<BottomNav legoImport={legoImport} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { cn } from 'cn';
|
||||||
|
import { Studs } from '@/components/brick/studs';
|
||||||
|
import { MORE_GROUPS, moreItemsFor } from '@/components/layout/more-items';
|
||||||
|
import { SignOutButton } from '@/components/layout/sign-out-button';
|
||||||
|
import { requireUser } from '@/lib/session';
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: 'Mehr' };
|
||||||
|
|
||||||
|
const GROUP_TONES: Record<string, string> = {
|
||||||
|
ich: 'bg-tone-1',
|
||||||
|
familie: 'bg-tone-2',
|
||||||
|
katalog: 'bg-tone-3',
|
||||||
|
daten: 'bg-tone-4',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function MehrPage() {
|
||||||
|
const user = await requireUser();
|
||||||
|
const items = moreItemsFor(user.role);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-7">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">{user.name}</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{user.email} · {user.role === 'admin' ? 'Administrator' : 'Mitglied'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{MORE_GROUPS.map((group) => {
|
||||||
|
const entries = items.filter((item) => item.group === group.id);
|
||||||
|
if (entries.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<section key={group.id} className="space-y-4">
|
||||||
|
<h2 className="text-xs font-bold tracking-widest text-muted-foreground uppercase">
|
||||||
|
{group.label}
|
||||||
|
</h2>
|
||||||
|
<ul className="grid grid-cols-2 gap-x-3 gap-y-5 sm:grid-cols-3">
|
||||||
|
{entries.map((item) => (
|
||||||
|
<li key={item.href} className="relative">
|
||||||
|
<Studs studClassName={GROUP_TONES[group.id]} />
|
||||||
|
<Link
|
||||||
|
href={item.href}
|
||||||
|
className={cn(
|
||||||
|
'brick flex h-full flex-col gap-1 rounded-xl p-3.5 text-tone-ink outline-none focus-visible:ring-3 focus-visible:ring-ring/50',
|
||||||
|
GROUP_TONES[group.id],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<item.icon className="size-6" aria-hidden />
|
||||||
|
<span className="font-bold">{item.label}</span>
|
||||||
|
<span className="text-xs leading-snug opacity-80">{item.description}</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<SignOutButton />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { cn } from 'cn';
|
|||||||
import { Studs } from '@/components/brick/studs';
|
import { Studs } from '@/components/brick/studs';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { brickRow } from '@/lib/brick-row';
|
import { brickRow } from '@/lib/brick-row';
|
||||||
|
import { activeCollectorId } from '@/lib/collector-choice';
|
||||||
import { collectorDotClass } from '@/lib/collector-colors';
|
import { collectorDotClass } from '@/lib/collector-colors';
|
||||||
import { formatDate, formatNumber, pluralize } from '@/lib/format';
|
import { formatDate, formatNumber, pluralize } from '@/lib/format';
|
||||||
import { formatEuro } from '@/lib/money';
|
import { formatEuro } from '@/lib/money';
|
||||||
@@ -19,22 +20,31 @@ function daysLabel(days: number): string {
|
|||||||
return `in ${days} Tagen`;
|
return `in ${days} Tagen`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function DashboardPage() {
|
type SearchParams = Promise<Record<string, string | string[] | undefined>>;
|
||||||
const user = await requireUser();
|
|
||||||
|
|
||||||
const stats = getCollectionStats();
|
export default async function DashboardPage({ searchParams }: { searchParams: SearchParams }) {
|
||||||
const missing = countMissingSets();
|
const user = await requireUser();
|
||||||
|
const params = await searchParams;
|
||||||
|
const rawCollector = Array.isArray(params.sammler) ? params.sammler[0] : params.sammler;
|
||||||
|
|
||||||
|
// With a child chosen in the header, the start page is about that child.
|
||||||
|
const collectorId = await activeCollectorId(rawCollector);
|
||||||
|
const stats = getCollectionStats(collectorId);
|
||||||
|
const missing = countMissingSets(collectorId);
|
||||||
const totalSets = stats.setCount + missing;
|
const totalSets = stats.setCount + missing;
|
||||||
|
|
||||||
const collectors = listCollectors();
|
const collectors = listCollectors();
|
||||||
|
const activeCollector = collectors.find((entry) => entry.id === collectorId) ?? null;
|
||||||
const perCollector = countItemsPerCollector();
|
const perCollector = countItemsPerCollector();
|
||||||
const showCollectors = collectors.length > 1;
|
const showCollectors = collectors.length > 1 && !activeCollector;
|
||||||
const bricks = brickRow(
|
const bricks = brickRow(
|
||||||
stats.setCount,
|
stats.setCount,
|
||||||
totalSets,
|
totalSets,
|
||||||
showCollectors
|
activeCollector
|
||||||
? collectors.map((entry) => ({ key: entry.id, count: perCollector.get(entry.id) ?? 0 }))
|
? [{ key: activeCollector.id, count: 1 }]
|
||||||
: [],
|
: showCollectors
|
||||||
|
? collectors.map((entry) => ({ key: entry.id, count: perCollector.get(entry.id) ?? 0 }))
|
||||||
|
: [],
|
||||||
);
|
);
|
||||||
const colorById = new Map(collectors.map((entry) => [entry.id, entry.color]));
|
const colorById = new Map(collectors.map((entry) => [entry.id, entry.color]));
|
||||||
|
|
||||||
@@ -53,7 +63,9 @@ export default async function DashboardPage() {
|
|||||||
<Studs count={3} />
|
<Studs count={3} />
|
||||||
<div className="brick rounded-xl bg-card p-4 text-card-foreground">
|
<div className="brick rounded-xl bg-card p-4 text-card-foreground">
|
||||||
<div className="flex items-baseline justify-between gap-3">
|
<div className="flex items-baseline justify-between gap-3">
|
||||||
<h2 className="text-lg font-semibold">Unsere Sammlung</h2>
|
<h2 className="text-lg font-semibold">
|
||||||
|
{activeCollector ? `Sammlung von ${activeCollector.name}` : 'Unsere Sammlung'}
|
||||||
|
</h2>
|
||||||
<p className="text-sm font-semibold text-muted-foreground">
|
<p className="text-sm font-semibold text-muted-foreground">
|
||||||
{formatNumber(stats.setCount)} von {pluralize(totalSets, 'Set', 'Sets')}
|
{formatNumber(stats.setCount)} von {pluralize(totalSets, 'Set', 'Sets')}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import Link from 'next/link';
|
|||||||
import { Boxes, Euro, Layers, PackageSearch } from 'lucide-react';
|
import { Boxes, Euro, Layers, PackageSearch } from 'lucide-react';
|
||||||
import { SetPlaceholder } from '@/components/brick/set-placeholder';
|
import { SetPlaceholder } from '@/components/brick/set-placeholder';
|
||||||
import { Studs } from '@/components/brick/studs';
|
import { Studs } from '@/components/brick/studs';
|
||||||
import { CollectorFilter } from '@/components/layout/collector-filter';
|
|
||||||
import { CollectionFilters } from '@/components/sammlung/collection-filters';
|
import { CollectionFilters } from '@/components/sammlung/collection-filters';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
@@ -19,7 +18,8 @@ import {
|
|||||||
listCollectionItems,
|
listCollectionItems,
|
||||||
type CollectionFilters as Filters,
|
type CollectionFilters as Filters,
|
||||||
} from '@/lib/queries/collection';
|
} from '@/lib/queries/collection';
|
||||||
import { listCollectors, resolveCollectorId } from '@/lib/queries/collectors';
|
import { activeCollectorId } from '@/lib/collector-choice';
|
||||||
|
import { listCollectors } from '@/lib/queries/collectors';
|
||||||
import { listLocations, listManufacturers } from '@/lib/queries/master-data';
|
import { listLocations, listManufacturers } from '@/lib/queries/master-data';
|
||||||
import { requireUser } from '@/lib/session';
|
import { requireUser } from '@/lib/session';
|
||||||
import type { CollectionStatus, Completeness } from '@/db/types';
|
import type { CollectionStatus, Completeness } from '@/db/types';
|
||||||
@@ -38,7 +38,7 @@ export default async function SammlungPage({ searchParams }: { searchParams: Sea
|
|||||||
const params = await searchParams;
|
const params = await searchParams;
|
||||||
|
|
||||||
const collectors = listCollectors();
|
const collectors = listCollectors();
|
||||||
const collectorId = resolveCollectorId(single(params.sammler));
|
const collectorId = await activeCollectorId(single(params.sammler));
|
||||||
const activeCollector = collectors.find((row) => row.id === collectorId) ?? null;
|
const activeCollector = collectors.find((row) => row.id === collectorId) ?? null;
|
||||||
|
|
||||||
const filters: Filters = {
|
const filters: Filters = {
|
||||||
@@ -78,11 +78,6 @@ export default async function SammlungPage({ searchParams }: { searchParams: Sea
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CollectorFilter
|
|
||||||
collectors={collectors.map((row) => ({ id: row.id, name: row.name, color: row.color }))}
|
|
||||||
allLabel="Alle zusammen"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
{tiles.map((tile) => (
|
{tiles.map((tile) => (
|
||||||
<Card key={tile.label}>
|
<Card key={tile.label}>
|
||||||
|
|||||||
@@ -1,9 +1,23 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { revalidatePath } from 'next/cache';
|
import { revalidatePath } from 'next/cache';
|
||||||
import { actionData, actionError, type ActionResult } from '@/lib/action-result';
|
import {
|
||||||
|
actionData,
|
||||||
|
actionError,
|
||||||
|
actionOk,
|
||||||
|
formToObject,
|
||||||
|
parseForm,
|
||||||
|
type ActionResult,
|
||||||
|
} from '@/lib/action-result';
|
||||||
import { env } from '@/env';
|
import { env } from '@/env';
|
||||||
|
import {
|
||||||
|
deleteMailSettings,
|
||||||
|
getMailConfig,
|
||||||
|
isMailConfigured,
|
||||||
|
saveMailSettings,
|
||||||
|
} from '@/lib/mail-settings';
|
||||||
import { sendMail, verifyMailConnection } from '@/lib/mailer';
|
import { sendMail, verifyMailConnection } from '@/lib/mailer';
|
||||||
|
import { mailSettingsSchema } from '@/lib/validation';
|
||||||
import { runReminderJob, type ReminderRun } from '@/lib/reminder-job';
|
import { runReminderJob, type ReminderRun } from '@/lib/reminder-job';
|
||||||
import { requireAdmin } from '@/lib/session';
|
import { requireAdmin } from '@/lib/session';
|
||||||
|
|
||||||
@@ -17,7 +31,7 @@ export async function testMailConnection(): Promise<ActionResult> {
|
|||||||
export async function sendTestMail(): Promise<ActionResult> {
|
export async function sendTestMail(): Promise<ActionResult> {
|
||||||
const admin = await requireAdmin();
|
const admin = await requireAdmin();
|
||||||
|
|
||||||
if (!env.isMailConfigured) {
|
if (!isMailConfigured()) {
|
||||||
return actionError('Es ist kein SMTP-Server konfiguriert.');
|
return actionError('Es ist kein SMTP-Server konfiguriert.');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,7 +55,7 @@ export async function sendTestMail(): Promise<ActionResult> {
|
|||||||
export async function runRemindersNow(): Promise<ActionResult<ReminderRun>> {
|
export async function runRemindersNow(): Promise<ActionResult<ReminderRun>> {
|
||||||
await requireAdmin();
|
await requireAdmin();
|
||||||
|
|
||||||
if (!env.isMailConfigured) {
|
if (!isMailConfigured()) {
|
||||||
return actionError('Es ist kein SMTP-Server konfiguriert.');
|
return actionError('Es ist kein SMTP-Server konfiguriert.');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,3 +63,26 @@ export async function runRemindersNow(): Promise<ActionResult<ReminderRun>> {
|
|||||||
revalidatePath('/verwaltung/erinnerungen');
|
revalidatePath('/verwaltung/erinnerungen');
|
||||||
return actionData(run);
|
return actionData(run);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Stores the SMTP server set up on the reminders page. */
|
||||||
|
export async function saveMailSettingsAction(formData: FormData): Promise<ActionResult> {
|
||||||
|
await requireAdmin();
|
||||||
|
|
||||||
|
if (getMailConfig()?.source === 'env') {
|
||||||
|
return actionError('Der Mailserver kommt aus der .env. Änderungen bitte dort vornehmen.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parseForm(mailSettingsSchema, formToObject(formData));
|
||||||
|
if (!parsed.ok) return parsed;
|
||||||
|
|
||||||
|
saveMailSettings(parsed.data);
|
||||||
|
revalidatePath('/verwaltung/erinnerungen');
|
||||||
|
return actionOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteMailSettingsAction(): Promise<ActionResult> {
|
||||||
|
await requireAdmin();
|
||||||
|
deleteMailSettings();
|
||||||
|
revalidatePath('/verwaltung/erinnerungen');
|
||||||
|
return actionOk();
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ import { eq, sql } from 'drizzle-orm';
|
|||||||
import { db } from '@/db';
|
import { db } from '@/db';
|
||||||
import { wishlist } from '@/db/schema';
|
import { wishlist } from '@/db/schema';
|
||||||
import { env } from '@/env';
|
import { env } from '@/env';
|
||||||
|
import { MailSettingsForm } from '@/components/verwaltung/mail-settings-form';
|
||||||
import {
|
import {
|
||||||
ReminderSettings,
|
ReminderSettings,
|
||||||
type ReminderListRow,
|
type ReminderListRow,
|
||||||
} from '@/components/verwaltung/reminder-settings';
|
} from '@/components/verwaltung/reminder-settings';
|
||||||
|
import { getMailSettingsView } from '@/lib/mail-settings';
|
||||||
import { daysUntil } from '@/lib/reminders';
|
import { daysUntil } from '@/lib/reminders';
|
||||||
import { requireAdmin } from '@/lib/session';
|
import { requireAdmin } from '@/lib/session';
|
||||||
|
|
||||||
@@ -41,11 +43,16 @@ export default async function ErinnerungenPage() {
|
|||||||
}))
|
}))
|
||||||
.sort((a, b) => (a.daysLeft ?? 0) - (b.daysLeft ?? 0));
|
.sort((a, b) => (a.daysLeft ?? 0) - (b.daysLeft ?? 0));
|
||||||
|
|
||||||
|
const mail = getMailSettingsView();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ReminderSettings
|
<div className="space-y-6">
|
||||||
configured={env.isMailConfigured}
|
<ReminderSettings
|
||||||
daysBefore={env.REMINDER_DAYS_BEFORE}
|
configured={mail.source !== null}
|
||||||
lists={lists}
|
daysBefore={env.REMINDER_DAYS_BEFORE}
|
||||||
/>
|
lists={lists}
|
||||||
|
/>
|
||||||
|
<MailSettingsForm view={mail} />
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,15 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import { VerwaltungNav } from '@/components/verwaltung/verwaltung-nav';
|
import { VerwaltungHeader } from '@/components/verwaltung/verwaltung-header';
|
||||||
import { requireUser } from '@/lib/session';
|
import { requireUser } from '@/lib/session';
|
||||||
|
|
||||||
export const metadata: Metadata = { title: 'Verwaltung' };
|
export const metadata: Metadata = { title: 'Verwaltung' };
|
||||||
|
|
||||||
export default async function VerwaltungLayout({ children }: { children: React.ReactNode }) {
|
export default async function VerwaltungLayout({ children }: { children: React.ReactNode }) {
|
||||||
const user = await requireUser();
|
await requireUser();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<VerwaltungHeader />
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Verwaltung</h1>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Stammdaten, die im Katalog und in der Sammlung ausgewählt werden.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<VerwaltungNav isAdmin={user.role === 'admin'} />
|
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -25,13 +25,18 @@ type Controls = { stop: () => void };
|
|||||||
* loaded only when the scanner is opened - nothing leaves the device, and the
|
* loaded only when the scanner is opened - nothing leaves the device, and the
|
||||||
* catalog pages stay light for everyone who never scans.
|
* catalog pages stay light for everyone who never scans.
|
||||||
*/
|
*/
|
||||||
export function BarcodeScannerButton() {
|
export function BarcodeScannerDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange: setOpen,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
const controlsRef = useRef<Controls | null>(null);
|
const controlsRef = useRef<Controls | null>(null);
|
||||||
const handledRef = useRef(false);
|
const handledRef = useRef(false);
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [status, setStatus] = useState<'starting' | 'scanning' | 'checking' | 'error'>('starting');
|
const [status, setStatus] = useState<'starting' | 'scanning' | 'checking' | 'error'>('starting');
|
||||||
const [message, setMessage] = useState<string | null>(null);
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -68,7 +73,7 @@ export function BarcodeScannerButton() {
|
|||||||
router.refresh();
|
router.refresh();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[router, stop],
|
[router, setOpen, stop],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -134,6 +139,61 @@ export function BarcodeScannerButton() {
|
|||||||
};
|
};
|
||||||
}, [open, onDetected, stop]);
|
}, [open, onDetected, stop]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(next) => {
|
||||||
|
if (!next) stop();
|
||||||
|
setOpen(next);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Barcode scannen</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Halte den Strichcode auf der Packung ins Bild. Kennt der Katalog das Set schon,
|
||||||
|
landest du direkt darauf – sonst im Formular mit ausgefüllter EAN.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{status === 'error' ? (
|
||||||
|
<p className="rounded-lg border border-dashed border-border p-4 text-sm text-muted-foreground">
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="relative overflow-hidden rounded-lg bg-black">
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
className="aspect-[3/4] w-full object-cover"
|
||||||
|
muted
|
||||||
|
playsInline
|
||||||
|
aria-label="Kamerabild"
|
||||||
|
/>
|
||||||
|
{/* A guide line makes people hold the box the right way round. */}
|
||||||
|
<div className="pointer-events-none absolute inset-x-6 top-1/2 h-0.5 -translate-y-1/2 bg-red-500/80" />
|
||||||
|
|
||||||
|
{status !== 'scanning' ? (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center gap-2 bg-black/60 text-sm text-white">
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
{status === 'checking' ? 'Im Katalog nachsehen …' : 'Kamera startet …'}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BarcodeScannerButton() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
@@ -145,55 +205,7 @@ export function BarcodeScannerButton() {
|
|||||||
>
|
>
|
||||||
<ScanLine />
|
<ScanLine />
|
||||||
</Button>
|
</Button>
|
||||||
|
<BarcodeScannerDialog open={open} onOpenChange={setOpen} />
|
||||||
<Dialog
|
|
||||||
open={open}
|
|
||||||
onOpenChange={(next) => {
|
|
||||||
if (!next) stop();
|
|
||||||
setOpen(next);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Barcode scannen</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Halte den Strichcode auf der Packung ins Bild. Kennt der Katalog das Set schon,
|
|
||||||
landest du direkt darauf – sonst im Formular mit ausgefüllter EAN.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
{status === 'error' ? (
|
|
||||||
<p className="rounded-lg border border-dashed border-border p-4 text-sm text-muted-foreground">
|
|
||||||
{message}
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<div className="relative overflow-hidden rounded-lg bg-black">
|
|
||||||
<video
|
|
||||||
ref={videoRef}
|
|
||||||
className="aspect-[3/4] w-full object-cover"
|
|
||||||
muted
|
|
||||||
playsInline
|
|
||||||
aria-label="Kamerabild"
|
|
||||||
/>
|
|
||||||
{/* A guide line makes people hold the box the right way round. */}
|
|
||||||
<div className="pointer-events-none absolute inset-x-6 top-1/2 h-0.5 -translate-y-1/2 bg-red-500/80" />
|
|
||||||
|
|
||||||
{status !== 'scanning' ? (
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center gap-2 bg-black/60 text-sm text-white">
|
|
||||||
<Loader2 className="size-4 animate-spin" />
|
|
||||||
{status === 'checking' ? 'Im Katalog nachsehen …' : 'Kamera startet …'}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
|
||||||
Abbrechen
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useEffect, 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,
|
||||||
|
focus,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
manufacturers: Array<{ id: string; name: string }>;
|
||||||
|
themes: Array<{ id: string; name: string; manufacturerId: string }>;
|
||||||
|
defaultEan?: string;
|
||||||
|
/** Set by the plus menu: start in the link field or in the form. */
|
||||||
|
focus?: 'link' | 'hand';
|
||||||
|
/** 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();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (focus !== 'hand') return;
|
||||||
|
const field = document.getElementById('setNumber');
|
||||||
|
field?.scrollIntoView({ block: 'center' });
|
||||||
|
field?.focus({ preventScroll: true });
|
||||||
|
}, [focus]);
|
||||||
|
|
||||||
|
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}
|
||||||
|
autoFocus={focus === 'link'}
|
||||||
|
/>
|
||||||
|
</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}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ import { Input } from '@/components/ui/input';
|
|||||||
* One-field Lego import: type the number from the box, get a finished catalog
|
* One-field Lego import: type the number from the box, get a finished catalog
|
||||||
* entry. Hidden entirely when no API key is configured.
|
* entry. Hidden entirely when no API key is configured.
|
||||||
*/
|
*/
|
||||||
export function RebrickableImport({ enabled }: { enabled: boolean }) {
|
export function RebrickableImport({ enabled, autoFocus }: { enabled: boolean; autoFocus?: boolean }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [setNumber, setSetNumber] = useState('');
|
const [setNumber, setSetNumber] = useState('');
|
||||||
const [pending, startTransition] = useTransition();
|
const [pending, startTransition] = useTransition();
|
||||||
@@ -80,6 +80,7 @@ export function RebrickableImport({ enabled }: { enabled: boolean }) {
|
|||||||
onChange={(event) => setSetNumber(event.target.value)}
|
onChange={(event) => setSetNumber(event.target.value)}
|
||||||
placeholder="60398"
|
placeholder="60398"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
|
autoFocus={autoFocus}
|
||||||
maxLength={40}
|
maxLength={40}
|
||||||
className="font-mono"
|
className="font-mono"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -47,19 +47,26 @@ export type SetFormValues = {
|
|||||||
notes: string | null;
|
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 = {
|
type Props = {
|
||||||
manufacturers: Array<{ id: string; name: string }>;
|
manufacturers: Array<{ id: string; name: string }>;
|
||||||
themes: Array<{ id: string; name: string; manufacturerId: string }>;
|
themes: Array<{ id: string; name: string; manufacturerId: string }>;
|
||||||
set?: SetFormValues;
|
set?: SetFormValues;
|
||||||
/** Prefilled EAN coming from the barcode scanner (phase 2). */
|
/** Prefilled EAN coming from the barcode scanner (phase 2). */
|
||||||
defaultEan?: string;
|
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 router = useRouter();
|
||||||
const [pending, startTransition] = useTransition();
|
const [pending, startTransition] = useTransition();
|
||||||
const [manufacturerId, setManufacturerId] = useState(
|
const [manufacturerId, setManufacturerId] = useState(
|
||||||
set?.manufacturerId ?? manufacturers[0]?.id ?? '',
|
set?.manufacturerId ?? prefill?.manufacturerId ?? manufacturers[0]?.id ?? '',
|
||||||
);
|
);
|
||||||
const [duplicates, setDuplicates] = useState<DuplicateCandidate[] | null>(null);
|
const [duplicates, setDuplicates] = useState<DuplicateCandidate[] | null>(null);
|
||||||
const pendingForm = useRef<FormData | null>(null);
|
const pendingForm = useRef<FormData | null>(null);
|
||||||
@@ -167,7 +174,7 @@ export function SetForm({ manufacturers, themes, set, defaultEan }: Props) {
|
|||||||
<Input
|
<Input
|
||||||
id="setNumber"
|
id="setNumber"
|
||||||
name="setNumber"
|
name="setNumber"
|
||||||
defaultValue={set?.setNumber ?? ''}
|
defaultValue={set?.setNumber ?? prefill?.setNumber ?? ''}
|
||||||
required
|
required
|
||||||
maxLength={40}
|
maxLength={40}
|
||||||
placeholder="60398"
|
placeholder="60398"
|
||||||
@@ -179,7 +186,7 @@ export function SetForm({ manufacturers, themes, set, defaultEan }: Props) {
|
|||||||
<Input
|
<Input
|
||||||
id="name"
|
id="name"
|
||||||
name="name"
|
name="name"
|
||||||
defaultValue={set?.name ?? ''}
|
defaultValue={set?.name ?? prefill?.name ?? ''}
|
||||||
required
|
required
|
||||||
maxLength={160}
|
maxLength={160}
|
||||||
placeholder="Familienhaus und Elektroauto"
|
placeholder="Familienhaus und Elektroauto"
|
||||||
@@ -192,7 +199,7 @@ export function SetForm({ manufacturers, themes, set, defaultEan }: Props) {
|
|||||||
name="year"
|
name="year"
|
||||||
type="number"
|
type="number"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
defaultValue={set?.year ?? ''}
|
defaultValue={set?.year ?? prefill?.year ?? ''}
|
||||||
min={1930}
|
min={1930}
|
||||||
max={new Date().getFullYear() + 5}
|
max={new Date().getFullYear() + 5}
|
||||||
/>
|
/>
|
||||||
@@ -204,7 +211,7 @@ export function SetForm({ manufacturers, themes, set, defaultEan }: Props) {
|
|||||||
name="pieceCount"
|
name="pieceCount"
|
||||||
type="number"
|
type="number"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
defaultValue={set?.pieceCount ?? ''}
|
defaultValue={set?.pieceCount ?? prefill?.pieceCount ?? ''}
|
||||||
min={0}
|
min={0}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
@@ -215,7 +222,7 @@ export function SetForm({ manufacturers, themes, set, defaultEan }: Props) {
|
|||||||
name="minifigCount"
|
name="minifigCount"
|
||||||
type="number"
|
type="number"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
defaultValue={set?.minifigCount ?? ''}
|
defaultValue={set?.minifigCount ?? prefill?.minifigCount ?? ''}
|
||||||
min={0}
|
min={0}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
@@ -235,7 +242,7 @@ export function SetForm({ manufacturers, themes, set, defaultEan }: Props) {
|
|||||||
id="ean"
|
id="ean"
|
||||||
name="ean"
|
name="ean"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
defaultValue={set?.ean ?? defaultEan ?? ''}
|
defaultValue={set?.ean ?? prefill?.ean ?? defaultEan ?? ''}
|
||||||
maxLength={20}
|
maxLength={20}
|
||||||
className="font-mono"
|
className="font-mono"
|
||||||
/>
|
/>
|
||||||
@@ -256,10 +263,18 @@ export function SetForm({ manufacturers, themes, set, defaultEan }: Props) {
|
|||||||
name="imageUrl"
|
name="imageUrl"
|
||||||
type="url"
|
type="url"
|
||||||
inputMode="url"
|
inputMode="url"
|
||||||
defaultValue={set?.imageUrl ?? ''}
|
defaultValue={set?.imageUrl ?? prefill?.imageUrl ?? ''}
|
||||||
placeholder="https://…"
|
placeholder="https://…"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</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">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="image">Bild hochladen</Label>
|
<Label htmlFor="image">Bild hochladen</Label>
|
||||||
@@ -286,7 +301,12 @@ export function SetForm({ manufacturers, themes, set, defaultEan }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Field label="Notizen" htmlFor="notes">
|
<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>
|
</Field>
|
||||||
|
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Hash, Link2, PencilLine, Plus, ScanLine, type LucideIcon } from 'lucide-react';
|
||||||
|
import { cn } from 'cn';
|
||||||
|
import { Studs } from '@/components/brick/studs';
|
||||||
|
import { BarcodeScannerDialog } from '@/components/katalog/barcode-scanner';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetDescription,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
SheetTrigger,
|
||||||
|
} from '@/components/ui/sheet';
|
||||||
|
|
||||||
|
type Action = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
hint: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
tone: string;
|
||||||
|
href?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The plus in the middle of the navigation: every way a set gets into the
|
||||||
|
* catalog, in one place. Opens as a sheet from the bottom.
|
||||||
|
*/
|
||||||
|
export function AddMenu({
|
||||||
|
legoImport,
|
||||||
|
variant = 'nav',
|
||||||
|
}: {
|
||||||
|
/** Rebrickable is configured. */
|
||||||
|
legoImport: boolean;
|
||||||
|
variant?: 'nav' | 'button';
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [scanning, setScanning] = useState(false);
|
||||||
|
|
||||||
|
const actions: Action[] = [
|
||||||
|
{ id: 'scan', label: 'Scannen', hint: 'Strichcode der Packung', icon: ScanLine, tone: 'bg-tone-1' },
|
||||||
|
{
|
||||||
|
id: 'link',
|
||||||
|
label: 'Aus Link',
|
||||||
|
hint: 'Amazon, idealo, Shop',
|
||||||
|
icon: Link2,
|
||||||
|
tone: 'bg-tone-2',
|
||||||
|
href: '/katalog/neu?weg=link',
|
||||||
|
},
|
||||||
|
...(legoImport
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: 'lego',
|
||||||
|
label: 'Lego-Nummer',
|
||||||
|
hint: 'über Rebrickable',
|
||||||
|
icon: Hash,
|
||||||
|
tone: 'bg-tone-3',
|
||||||
|
href: '/katalog/neu?weg=lego',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
{
|
||||||
|
id: 'hand',
|
||||||
|
label: 'Von Hand',
|
||||||
|
hint: 'alles selbst eintragen',
|
||||||
|
icon: PencilLine,
|
||||||
|
tone: 'bg-card',
|
||||||
|
href: '/katalog/neu?weg=hand',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function run(action: Action) {
|
||||||
|
setOpen(false);
|
||||||
|
if (action.id === 'scan') {
|
||||||
|
setScanning(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action.href) router.push(action.href);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Sheet open={open} onOpenChange={setOpen}>
|
||||||
|
<SheetTrigger asChild>
|
||||||
|
{variant === 'nav' ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Neues Set"
|
||||||
|
className="relative -mt-7 flex h-14 w-16 items-center justify-center rounded-[calc(var(--radius)*0.9)] border-[length:var(--edge-w)] border-edge bg-primary text-primary-foreground shadow-(--lift) outline-none focus-visible:ring-3 focus-visible:ring-ring/50 active:translate-y-0.5"
|
||||||
|
>
|
||||||
|
<Studs count={2} className="left-2.5 gap-3 [&>.stud]:w-3.5 [&>.stud]:bg-primary" />
|
||||||
|
<Plus className={cn('size-7 transition-transform', open && 'rotate-45')} strokeWidth={3} />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<Button size="lg">
|
||||||
|
<Plus />
|
||||||
|
Neu
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</SheetTrigger>
|
||||||
|
<SheetContent
|
||||||
|
side="bottom"
|
||||||
|
className="rounded-t-[calc(var(--radius)*1.6)] border-t-[length:max(1px,var(--edge-w))] border-edge bg-background pb-[calc(env(safe-area-inset-bottom)+1.5rem)]"
|
||||||
|
>
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle className="text-xl">Was kommt dazu?</SheetTitle>
|
||||||
|
<SheetDescription>Ein Set in den Katalog bringen – auf dem schnellsten Weg.</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
<div className="mx-auto grid w-full max-w-lg grid-cols-2 gap-x-3 gap-y-5 px-4 pt-2">
|
||||||
|
{actions.map((action) => (
|
||||||
|
<button
|
||||||
|
key={action.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => run(action)}
|
||||||
|
className="relative text-left outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||||
|
>
|
||||||
|
<Studs studClassName={action.tone} />
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'brick flex h-full flex-col gap-1 rounded-xl p-3.5 text-foreground',
|
||||||
|
action.tone,
|
||||||
|
action.tone !== 'bg-card' && 'text-tone-ink',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<action.icon className="size-6" aria-hidden />
|
||||||
|
<span className="text-base font-bold">{action.label}</span>
|
||||||
|
<span className="text-xs font-semibold opacity-80">{action.hint}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
|
||||||
|
<BarcodeScannerDialog open={scanning} onOpenChange={setScanning} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,22 @@
|
|||||||
|
import { Suspense } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { Menu } from 'lucide-react';
|
||||||
import { BrickLogo } from '@/components/brick/logo';
|
import { BrickLogo } from '@/components/brick/logo';
|
||||||
import { DesktopNav } from './bottom-nav';
|
import { DesktopNav } from './bottom-nav';
|
||||||
import { UserMenu } from './user-menu';
|
import { CollectorSwitch, type CollectorOption } from './collector-switch';
|
||||||
import type { SessionUser } from '@/lib/session';
|
import type { SessionUser } from '@/lib/session';
|
||||||
|
|
||||||
export function AppHeader({ user }: { user: SessionUser }) {
|
export function AppHeader({
|
||||||
|
user,
|
||||||
|
collectors,
|
||||||
|
storedCollectorId,
|
||||||
|
legoImport,
|
||||||
|
}: {
|
||||||
|
user: SessionUser;
|
||||||
|
collectors: CollectorOption[];
|
||||||
|
storedCollectorId: string | null;
|
||||||
|
legoImport: boolean;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-40 border-b border-border bg-background/95 backdrop-blur">
|
<header className="sticky top-0 z-40 border-b border-border bg-background/95 backdrop-blur">
|
||||||
<div className="mx-auto flex h-14 max-w-5xl items-center gap-3 px-4">
|
<div className="mx-auto flex h-14 max-w-5xl items-center gap-3 px-4">
|
||||||
@@ -16,9 +28,27 @@ export function AppHeader({ user }: { user: SessionUser }) {
|
|||||||
<span>Bricktrack</span>
|
<span>Bricktrack</span>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<DesktopNav />
|
<DesktopNav legoImport={legoImport} />
|
||||||
<UserMenu user={user} />
|
{/* Account, design and master data: everything that is not daily use. */}
|
||||||
|
{/* Labelled on purpose: an initials-only box was not recognisable. */}
|
||||||
|
<Link
|
||||||
|
href="/mehr"
|
||||||
|
title={`Mehr: Konto (${user.name}), Design und Verwaltung`}
|
||||||
|
className="flex h-10 items-center gap-2 rounded-[calc(var(--radius)*0.7)] border-[length:max(1px,var(--edge-w))] border-edge bg-card px-3 text-sm font-bold shadow-(--lift-sm) outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||||
|
>
|
||||||
|
<Menu className="size-5" aria-hidden />
|
||||||
|
Mehr
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{collectors.length > 1 ? (
|
||||||
|
<div className="mx-auto max-w-5xl px-4 pb-2">
|
||||||
|
{/* useSearchParams needs a boundary; the switch reads ?sammler=. */}
|
||||||
|
<Suspense fallback={<div className="h-11" />}>
|
||||||
|
<CollectorSwitch collectors={collectors} storedId={storedCollectorId} />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,17 +2,52 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { Boxes, Gift, LayoutGrid, User } from 'lucide-react';
|
import { Boxes, Gift, House, LayoutGrid } from 'lucide-react';
|
||||||
import { cn } from 'cn';
|
import { cn } from 'cn';
|
||||||
|
import { AddMenu } from './add-menu';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Four places and the plus in the middle. Account, design and master data
|
||||||
|
* sit behind "Mehr" in the header - they are not part of the daily routine.
|
||||||
|
*/
|
||||||
const items = [
|
const items = [
|
||||||
|
{ href: '/', label: 'Start', icon: House },
|
||||||
{ href: '/katalog', label: 'Katalog', icon: LayoutGrid },
|
{ href: '/katalog', label: 'Katalog', icon: LayoutGrid },
|
||||||
{ href: '/sammlung', label: 'Sammlung', icon: Boxes },
|
{ href: '/sammlung', label: 'Sammlung', icon: Boxes },
|
||||||
{ href: '/wunschlisten', label: 'Wünsche', icon: Gift },
|
{ href: '/wunschlisten', label: 'Wünsche', icon: Gift },
|
||||||
{ href: '/konto', label: 'Konto', icon: User },
|
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export function BottomNav() {
|
function isActive(pathname: string, href: string) {
|
||||||
|
if (href === '/') return pathname === '/';
|
||||||
|
return pathname === href || pathname.startsWith(`${href}/`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavLink({ item, pathname }: { item: (typeof items)[number]; pathname: string }) {
|
||||||
|
const active = isActive(pathname, item.href);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={item.href}
|
||||||
|
aria-current={active ? 'page' : undefined}
|
||||||
|
className={cn(
|
||||||
|
'flex h-16 flex-col items-center justify-center gap-1 text-xs transition-colors',
|
||||||
|
active ? 'font-bold text-foreground' : 'font-medium text-muted-foreground hover:text-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* The active tab sits on a small plate, like a tile on a baseplate. */}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'flex h-7 w-12 items-center justify-center rounded-[calc(var(--radius)*0.6)] border-[length:var(--edge-w)]',
|
||||||
|
active ? 'border-edge bg-secondary text-secondary-foreground' : 'border-transparent',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<item.icon className="size-5" aria-hidden />
|
||||||
|
</span>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BottomNav({ legoImport }: { legoImport: boolean }) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -20,49 +55,32 @@ export function BottomNav() {
|
|||||||
aria-label="Hauptnavigation"
|
aria-label="Hauptnavigation"
|
||||||
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"
|
className="fixed inset-x-0 bottom-0 z-40 border-t-[length:max(1px,var(--edge-w))] border-border bg-card/95 pb-[env(safe-area-inset-bottom)] backdrop-blur md:hidden"
|
||||||
>
|
>
|
||||||
<ul className="mx-auto flex max-w-lg">
|
<ul className="mx-auto grid max-w-lg grid-cols-5 items-center">
|
||||||
{items.map((item) => {
|
{items.slice(0, 2).map((item) => (
|
||||||
const active = pathname === item.href || pathname.startsWith(`${item.href}/`);
|
<li key={item.href}>
|
||||||
return (
|
<NavLink item={item} pathname={pathname} />
|
||||||
<li key={item.href} className="flex-1">
|
</li>
|
||||||
<Link
|
))}
|
||||||
href={item.href}
|
<li className="flex justify-center">
|
||||||
aria-current={active ? 'page' : undefined}
|
<AddMenu legoImport={legoImport} />
|
||||||
className={cn(
|
</li>
|
||||||
'flex h-16 flex-col items-center justify-center gap-1 text-xs transition-colors',
|
{items.slice(2).map((item) => (
|
||||||
active
|
<li key={item.href}>
|
||||||
? 'font-bold text-foreground'
|
<NavLink item={item} pathname={pathname} />
|
||||||
: 'font-medium text-muted-foreground hover:text-foreground',
|
</li>
|
||||||
)}
|
))}
|
||||||
>
|
|
||||||
{/* 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>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DesktopNav() {
|
export function DesktopNav({ legoImport }: { legoImport: boolean }) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav aria-label="Hauptnavigation" className="hidden items-center gap-1 md:flex">
|
<nav aria-label="Hauptnavigation" className="hidden items-center gap-1 md:flex">
|
||||||
{items.map((item) => {
|
{items.map((item) => {
|
||||||
const active = pathname === item.href || pathname.startsWith(`${item.href}/`);
|
const active = isActive(pathname, item.href);
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={item.href}
|
key={item.href}
|
||||||
@@ -80,6 +98,9 @@ export function DesktopNav() {
|
|||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
<span className="ml-2">
|
||||||
|
<AddMenu legoImport={legoImport} variant="button" />
|
||||||
|
</span>
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
|
||||||
import { useTransition } from 'react';
|
|
||||||
import { cn } from 'cn';
|
|
||||||
import { collectorDotClass } from '@/lib/collector-colors';
|
|
||||||
|
|
||||||
export type CollectorOption = { id: string; name: string; color: string };
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Switches the view between collectors.
|
|
||||||
*
|
|
||||||
* Rendered only when there is more than one - with a single collector the
|
|
||||||
* whole question does not exist, and the app should not ask it. The choice
|
|
||||||
* lives in the URL (`?sammler=`), so a filtered view stays shareable and the
|
|
||||||
* back button behaves.
|
|
||||||
*/
|
|
||||||
export function CollectorFilter({
|
|
||||||
collectors,
|
|
||||||
allLabel = 'Alle',
|
|
||||||
}: {
|
|
||||||
collectors: CollectorOption[];
|
|
||||||
allLabel?: string;
|
|
||||||
}) {
|
|
||||||
const router = useRouter();
|
|
||||||
const pathname = usePathname();
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const [, startTransition] = useTransition();
|
|
||||||
|
|
||||||
if (collectors.length < 2) return null;
|
|
||||||
|
|
||||||
const current = searchParams.get('sammler') ?? '';
|
|
||||||
|
|
||||||
function select(id: string) {
|
|
||||||
const params = new URLSearchParams(searchParams.toString());
|
|
||||||
if (id) params.set('sammler', id);
|
|
||||||
else params.delete('sammler');
|
|
||||||
params.delete('seite');
|
|
||||||
|
|
||||||
const query = params.toString();
|
|
||||||
startTransition(() => router.push(query ? `${pathname}?${query}` : pathname));
|
|
||||||
}
|
|
||||||
|
|
||||||
const options = [{ id: '', name: allLabel, color: 'slate' }, ...collectors];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<nav
|
|
||||||
aria-label="Sammler"
|
|
||||||
className="flex gap-1 overflow-x-auto rounded-lg bg-muted p-1"
|
|
||||||
>
|
|
||||||
{options.map((option) => {
|
|
||||||
const active = current === option.id;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={option.id || 'alle'}
|
|
||||||
type="button"
|
|
||||||
aria-pressed={active}
|
|
||||||
onClick={() => select(option.id)}
|
|
||||||
className={cn(
|
|
||||||
'flex flex-1 items-center justify-center gap-2 rounded-md px-3 py-2 text-sm font-medium whitespace-nowrap transition-colors',
|
|
||||||
active
|
|
||||||
? 'bg-background text-foreground shadow-sm'
|
|
||||||
: 'text-muted-foreground hover:text-foreground',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{option.id ? (
|
|
||||||
<span
|
|
||||||
className={cn('size-2 rounded-full', collectorDotClass(option.color))}
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{option.name}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import { useTransition } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { cn } from 'cn';
|
||||||
|
import { chooseCollectorAction } from '@/app/(app)/collector-actions';
|
||||||
|
import { collectorDotClass } from '@/lib/collector-colors';
|
||||||
|
|
||||||
|
export type CollectorOption = { id: string; name: string; color: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Für wen?" in the header: picks the child the whole app is looking at.
|
||||||
|
*
|
||||||
|
* Only rendered with more than one collector - with a single one the question
|
||||||
|
* does not exist. The choice is remembered per device; a `?sammler=` in the
|
||||||
|
* URL (a shared link) takes precedence and is shown as active.
|
||||||
|
*/
|
||||||
|
export function CollectorSwitch({
|
||||||
|
collectors,
|
||||||
|
storedId,
|
||||||
|
}: {
|
||||||
|
collectors: CollectorOption[];
|
||||||
|
storedId: string | null;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const fromUrl = searchParams.get('sammler');
|
||||||
|
const current = fromUrl === 'alle' ? '' : (fromUrl ?? storedId ?? '');
|
||||||
|
|
||||||
|
function select(id: string) {
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await chooseCollectorAction({ id });
|
||||||
|
if (!result.ok) {
|
||||||
|
toast.error(result.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The header choice replaces a collector that came in via the URL.
|
||||||
|
if (fromUrl !== null) {
|
||||||
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
|
params.delete('sammler');
|
||||||
|
params.delete('seite');
|
||||||
|
const query = params.toString();
|
||||||
|
router.replace(query ? `${pathname}?${query}` : pathname);
|
||||||
|
}
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = [{ id: '', name: 'Alle', color: '' }, ...collectors];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div role="radiogroup" aria-label="Für wen?" className="flex gap-2 overflow-x-auto pb-1">
|
||||||
|
{options.map((option) => {
|
||||||
|
const active = current === option.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.id || 'alle'}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={active}
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() => select(option.id)}
|
||||||
|
className={cn(
|
||||||
|
'flex h-10 shrink-0 items-center gap-2 rounded-[calc(var(--radius)*0.7)] border-[length:max(1px,var(--edge-w))] px-3 text-sm font-bold transition-colors',
|
||||||
|
active
|
||||||
|
? 'border-edge bg-card text-card-foreground shadow-(--lift-sm)'
|
||||||
|
: 'border-transparent text-muted-foreground hover:bg-muted',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className={cn(
|
||||||
|
'flex size-6 items-center justify-center rounded-md border-[length:var(--edge-w)] border-edge text-xs font-bold text-white',
|
||||||
|
option.id ? collectorDotClass(option.color) : 'bg-secondary text-secondary-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{option.id ? option.name.charAt(0).toUpperCase() : '★'}
|
||||||
|
</span>
|
||||||
|
{option.name}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import {
|
||||||
|
Bell,
|
||||||
|
Building2,
|
||||||
|
Database,
|
||||||
|
Mail,
|
||||||
|
MapPin,
|
||||||
|
Palette,
|
||||||
|
Tag,
|
||||||
|
UserRound,
|
||||||
|
Users,
|
||||||
|
type LucideIcon,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything that is not part of the daily routine lives behind "Mehr":
|
||||||
|
* the account, the design, family settings and master data. One list feeds
|
||||||
|
* the "Mehr" page and the title bar of each of those pages.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type MoreGroup = 'ich' | 'familie' | 'katalog' | 'daten';
|
||||||
|
|
||||||
|
export type MoreItem = {
|
||||||
|
href: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
group: MoreGroup;
|
||||||
|
icon: LucideIcon;
|
||||||
|
adminOnly: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MORE_GROUPS: Array<{ id: MoreGroup; label: string }> = [
|
||||||
|
{ id: 'ich', label: 'Ich' },
|
||||||
|
{ id: 'familie', label: 'Familie' },
|
||||||
|
{ id: 'katalog', label: 'Katalog pflegen' },
|
||||||
|
{ id: 'daten', label: 'Daten' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const MORE_ITEMS: MoreItem[] = [
|
||||||
|
{
|
||||||
|
href: '/konto',
|
||||||
|
label: 'Konto & Passkeys',
|
||||||
|
description: 'Profil, Anmeldung per Passkey.',
|
||||||
|
group: 'ich',
|
||||||
|
icon: UserRound,
|
||||||
|
adminOnly: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/konto#design',
|
||||||
|
label: 'Design',
|
||||||
|
description: 'Wie Bricktrack auf diesem Gerät aussieht.',
|
||||||
|
group: 'ich',
|
||||||
|
icon: Palette,
|
||||||
|
adminOnly: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/verwaltung/sammler',
|
||||||
|
label: 'Sammler',
|
||||||
|
description: 'Wer sammelt – jedes Kind mit eigener Sammlung und eigenen Wunschlisten.',
|
||||||
|
group: 'familie',
|
||||||
|
icon: Users,
|
||||||
|
adminOnly: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/verwaltung/einladungen',
|
||||||
|
label: 'Einladungen',
|
||||||
|
description: 'Weitere Konten für die Familie.',
|
||||||
|
group: 'familie',
|
||||||
|
icon: Mail,
|
||||||
|
adminOnly: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/verwaltung/erinnerungen',
|
||||||
|
label: 'Erinnerungen',
|
||||||
|
description: 'E-Mail, wenn ein Wunschlisten-Termin näher rückt.',
|
||||||
|
group: 'familie',
|
||||||
|
icon: Bell,
|
||||||
|
adminOnly: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/verwaltung/hersteller',
|
||||||
|
label: 'Hersteller',
|
||||||
|
description: 'Die Marken, deren Sets im Katalog stehen.',
|
||||||
|
group: 'katalog',
|
||||||
|
icon: Building2,
|
||||||
|
adminOnly: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/verwaltung/themen',
|
||||||
|
label: 'Themen',
|
||||||
|
description: 'Serien je Hersteller, auch verschachtelt.',
|
||||||
|
group: 'katalog',
|
||||||
|
icon: Tag,
|
||||||
|
adminOnly: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/verwaltung/standorte',
|
||||||
|
label: 'Standorte',
|
||||||
|
description: 'Wo die Sets aufbewahrt werden.',
|
||||||
|
group: 'katalog',
|
||||||
|
icon: MapPin,
|
||||||
|
adminOnly: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/verwaltung/daten',
|
||||||
|
label: 'Export & Import',
|
||||||
|
description: 'Bestand sichern, umziehen oder als Tabelle auswerten.',
|
||||||
|
group: 'daten',
|
||||||
|
icon: Database,
|
||||||
|
adminOnly: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function moreItemsFor(role: string): MoreItem[] {
|
||||||
|
return MORE_ITEMS.filter((item) => !item.adminOnly || role === 'admin');
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Loader2, LogOut } from 'lucide-react';
|
||||||
|
import { clearOfflineCaches } from '@/components/layout/service-worker';
|
||||||
|
import { authClient } from '@/lib/auth-client';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
export function SignOutButton() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
|
||||||
|
async function signOut() {
|
||||||
|
setPending(true);
|
||||||
|
await authClient.signOut();
|
||||||
|
// Cached pages must not stay readable on a shared device.
|
||||||
|
await clearOfflineCaches();
|
||||||
|
router.push('/anmelden');
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button variant="outline" size="lg" onClick={() => void signOut()} disabled={pending}>
|
||||||
|
{pending ? <Loader2 className="animate-spin" /> : <LogOut />}
|
||||||
|
Abmelden
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import { LogOut, Settings, UserRound } from 'lucide-react';
|
|
||||||
import { clearOfflineCaches } from '@/components/layout/service-worker';
|
|
||||||
import { authClient } from '@/lib/auth-client';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@/components/ui/dropdown-menu';
|
|
||||||
import type { SessionUser } from '@/lib/session';
|
|
||||||
|
|
||||||
export function UserMenu({ user }: { user: SessionUser }) {
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
async function signOut() {
|
|
||||||
await authClient.signOut();
|
|
||||||
// Cached pages must not stay readable on a shared device.
|
|
||||||
await clearOfflineCaches();
|
|
||||||
router.push('/anmelden');
|
|
||||||
router.refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="ghost" size="icon" aria-label="Konto-Menü">
|
|
||||||
<UserRound />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end" className="w-56">
|
|
||||||
<DropdownMenuLabel className="font-normal">
|
|
||||||
<span className="block text-sm font-medium">{user.name}</span>
|
|
||||||
<span className="block truncate text-xs text-muted-foreground">{user.email}</span>
|
|
||||||
</DropdownMenuLabel>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem asChild>
|
|
||||||
<Link href="/konto">
|
|
||||||
<UserRound />
|
|
||||||
Konto und Passkeys
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
{user.role === 'admin' ? (
|
|
||||||
<DropdownMenuItem asChild>
|
|
||||||
<Link href="/verwaltung/hersteller">
|
|
||||||
<Settings />
|
|
||||||
Verwaltung
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
) : null}
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem onSelect={() => void signOut()}>
|
|
||||||
<LogOut />
|
|
||||||
Abmelden
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useState, useTransition } from 'react';
|
||||||
|
import { Loader2, Save, Server, Trash2 } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import {
|
||||||
|
deleteMailSettingsAction,
|
||||||
|
saveMailSettingsAction,
|
||||||
|
} from '@/app/(app)/verwaltung/erinnerungen/actions';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { Field } from '@/components/ui/field';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { NativeSelect } from '@/components/ui/native-select';
|
||||||
|
|
||||||
|
export type MailSettingsFormView = {
|
||||||
|
source: 'env' | 'app' | null;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
security: 'starttls' | 'tls' | 'none';
|
||||||
|
user: string;
|
||||||
|
from: string;
|
||||||
|
hasPassword: boolean;
|
||||||
|
passwordUnreadable: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SECURITY_LABELS = {
|
||||||
|
starttls: 'STARTTLS (meist Port 587)',
|
||||||
|
tls: 'SSL/TLS (meist Port 465)',
|
||||||
|
none: 'Unverschlüsselt (nur im eigenen Netz)',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** The SMTP server for reminders, set up right here instead of in the .env. */
|
||||||
|
export function MailSettingsForm({ view }: { view: MailSettingsFormView }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
const [security, setSecurity] = useState(view.security);
|
||||||
|
const [port, setPort] = useState(String(view.port));
|
||||||
|
|
||||||
|
if (view.source === 'env') {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Server className="size-5 text-primary" aria-hidden />
|
||||||
|
Mailserver
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Kommt aus der .env und hat dort Vorrang. Ändern lässt er sich nur dort, danach den
|
||||||
|
Container neu starten.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-1 text-sm">
|
||||||
|
<p>
|
||||||
|
<span className="text-muted-foreground">Server:</span> {view.host}:{view.port} ·{' '}
|
||||||
|
{SECURITY_LABELS[view.security].split(' (')[0]}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span className="text-muted-foreground">Absender:</span> {view.from}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeSecurity(next: 'starttls' | 'tls' | 'none') {
|
||||||
|
setSecurity(next);
|
||||||
|
// Follow the usual port while the user has not picked a special one.
|
||||||
|
if (next === 'tls' && port === '587') setPort('465');
|
||||||
|
if (next !== 'tls' && port === '465') setPort('587');
|
||||||
|
}
|
||||||
|
|
||||||
|
function submit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
const form = event.currentTarget;
|
||||||
|
const formData = new FormData(form);
|
||||||
|
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await saveMailSettingsAction(formData);
|
||||||
|
if (!result.ok) {
|
||||||
|
toast.error(result.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The password never stays visible, not even as dots.
|
||||||
|
const password = form.elements.namedItem('password');
|
||||||
|
if (password instanceof HTMLInputElement) password.value = '';
|
||||||
|
toast.success('Mailserver gespeichert. Prüf ihn gleich mit „Verbindung prüfen“.');
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove() {
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await deleteMailSettingsAction();
|
||||||
|
if (!result.ok) {
|
||||||
|
toast.error(result.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast.success('Mailserver entfernt. Erinnerungen sind aus.');
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Server className="size-5 text-primary" aria-hidden />
|
||||||
|
Mailserver
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Über diesen Server gehen die Erinnerungen raus. Die Angaben findest du bei deinem
|
||||||
|
Mail-Anbieter unter „SMTP“ oder „Postausgangsserver“. Das Passwort wird verschlüsselt
|
||||||
|
gespeichert und nie wieder angezeigt.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={submit} className="space-y-4">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-[1fr_8rem]">
|
||||||
|
<Field label="SMTP-Server" htmlFor="mail-host" required>
|
||||||
|
<Input
|
||||||
|
id="mail-host"
|
||||||
|
name="host"
|
||||||
|
defaultValue={view.host}
|
||||||
|
placeholder="mail.example.de"
|
||||||
|
autoComplete="off"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Port" htmlFor="mail-port" required>
|
||||||
|
<Input
|
||||||
|
id="mail-port"
|
||||||
|
name="port"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={port}
|
||||||
|
onChange={(event) => setPort(event.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Field label="Verschlüsselung" htmlFor="mail-security">
|
||||||
|
<NativeSelect
|
||||||
|
id="mail-security"
|
||||||
|
name="security"
|
||||||
|
value={security}
|
||||||
|
onChange={(event) => changeSecurity(event.target.value as typeof security)}
|
||||||
|
>
|
||||||
|
{Object.entries(SECURITY_LABELS).map(([value, label]) => (
|
||||||
|
<option key={value} value={value}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</NativeSelect>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Field label="Benutzername" htmlFor="mail-user">
|
||||||
|
<Input
|
||||||
|
id="mail-user"
|
||||||
|
name="user"
|
||||||
|
defaultValue={view.user}
|
||||||
|
placeholder="bricktrack@example.de"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
label="Passwort"
|
||||||
|
htmlFor="mail-password"
|
||||||
|
|
||||||
|
hint={
|
||||||
|
view.passwordUnreadable
|
||||||
|
? 'Das gespeicherte Passwort ist nicht mehr lesbar (BETTER_AUTH_SECRET wurde geändert). Bitte neu eingeben.'
|
||||||
|
: view.hasPassword
|
||||||
|
? 'Gespeichert. Leer lassen, um es zu behalten.'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="mail-password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
placeholder={view.hasPassword ? '••••••••' : ''}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{view.hasPassword ? (
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<Checkbox name="clearPassword" value="on" />
|
||||||
|
Gespeichertes Passwort entfernen
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label="Absender"
|
||||||
|
htmlFor="mail-from"
|
||||||
|
required
|
||||||
|
|
||||||
|
hint="So steht es im Postfach, z. B. Bricktrack <bricktrack@example.de>."
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="mail-from"
|
||||||
|
name="from"
|
||||||
|
defaultValue={view.from}
|
||||||
|
placeholder="Bricktrack <bricktrack@example.de>"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button type="submit" size="lg" disabled={pending}>
|
||||||
|
{pending ? <Loader2 className="animate-spin" /> : <Save />}
|
||||||
|
Speichern
|
||||||
|
</Button>
|
||||||
|
{view.source === 'app' ? (
|
||||||
|
<Button type="button" variant="ghost" size="lg" onClick={remove} disabled={pending}>
|
||||||
|
<Trash2 />
|
||||||
|
Entfernen
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -98,17 +98,10 @@ export function ReminderSettings({
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-2 rounded-lg border border-dashed border-border p-4 text-sm text-muted-foreground">
|
<div className="space-y-2 rounded-lg border border-dashed border-border p-4 text-sm text-muted-foreground">
|
||||||
<p>
|
<p>
|
||||||
Es ist kein Mailversand eingerichtet – Erinnerungen sind damit aus, alles andere
|
Es ist noch kein Mailserver eingerichtet – Erinnerungen sind damit aus, alles andere
|
||||||
funktioniert unverändert.
|
funktioniert unverändert.
|
||||||
</p>
|
</p>
|
||||||
<p>Zum Aktivieren in die .env eintragen und den Container neu starten:</p>
|
<p>Trag ihn unten unter „Mailserver“ ein. Ein Neustart ist nicht nötig.</p>
|
||||||
<pre className="overflow-x-auto rounded-md bg-muted p-3 font-mono text-xs">
|
|
||||||
{`SMTP_HOST=mail.example.de
|
|
||||||
SMTP_PORT=587
|
|
||||||
SMTP_USER=bricktrack@example.de
|
|
||||||
SMTP_PASSWORD=…
|
|
||||||
SMTP_FROM=Bricktrack <bricktrack@example.de>`}
|
|
||||||
</pre>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
import { ChevronLeft } from 'lucide-react';
|
||||||
|
import { MORE_ITEMS } from '@/components/layout/more-items';
|
||||||
|
|
||||||
|
/** Title of a master-data page, with the way back to "Mehr". */
|
||||||
|
export function VerwaltungHeader() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const item = MORE_ITEMS.find((entry) => entry.href === pathname);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Link
|
||||||
|
href="/mehr"
|
||||||
|
className="inline-flex items-center gap-1 text-sm font-semibold text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="size-4" aria-hidden />
|
||||||
|
Mehr
|
||||||
|
</Link>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">{item?.label ?? 'Verwaltung'}</h1>
|
||||||
|
{item ? <p className="text-sm text-muted-foreground">{item.description}</p> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { usePathname } from 'next/navigation';
|
|
||||||
import { cn } from 'cn';
|
|
||||||
|
|
||||||
const tabs = [
|
|
||||||
{ href: '/verwaltung/sammler', label: 'Sammler', adminOnly: false },
|
|
||||||
{ href: '/verwaltung/hersteller', label: 'Hersteller', adminOnly: false },
|
|
||||||
{ href: '/verwaltung/themen', label: 'Themen', adminOnly: false },
|
|
||||||
{ href: '/verwaltung/standorte', label: 'Standorte', adminOnly: false },
|
|
||||||
{ href: '/verwaltung/einladungen', label: 'Einladungen', adminOnly: true },
|
|
||||||
{ href: '/verwaltung/erinnerungen', label: 'Erinnerungen', adminOnly: true },
|
|
||||||
{ href: '/verwaltung/daten', label: 'Daten', adminOnly: true },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export function VerwaltungNav({ isAdmin }: { isAdmin: boolean }) {
|
|
||||||
const pathname = usePathname();
|
|
||||||
const visible = tabs.filter((tab) => !tab.adminOnly || isAdmin);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<nav className="flex gap-1 overflow-x-auto rounded-lg bg-muted p-1" aria-label="Stammdaten">
|
|
||||||
{visible.map((tab) => {
|
|
||||||
const active = pathname === tab.href;
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={tab.href}
|
|
||||||
href={tab.href}
|
|
||||||
aria-current={active ? 'page' : undefined}
|
|
||||||
className={cn(
|
|
||||||
'flex-1 whitespace-nowrap rounded-md px-3 py-2 text-center text-sm font-medium transition-colors',
|
|
||||||
active
|
|
||||||
? 'bg-background text-foreground shadow-sm'
|
|
||||||
: 'text-muted-foreground hover:text-foreground',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{tab.label}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -4,4 +4,5 @@ export * from './collector';
|
|||||||
export * from './prices';
|
export * from './prices';
|
||||||
export * from './collection';
|
export * from './collection';
|
||||||
export * from './wishlist';
|
export * from './wishlist';
|
||||||
|
export * from './settings';
|
||||||
export * from './relations';
|
export * from './relations';
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settings made in the app rather than in the .env - for now the SMTP server
|
||||||
|
* for reminders. One row per topic, the value as JSON.
|
||||||
|
*
|
||||||
|
* Never part of the export: it may hold credentials (the SMTP password is
|
||||||
|
* stored encrypted, see src/lib/secret-box.ts).
|
||||||
|
*/
|
||||||
|
export const appSetting = sqliteTable('app_setting', {
|
||||||
|
key: text('key').primaryKey(),
|
||||||
|
value: text('value', { mode: 'json' }).notNull(),
|
||||||
|
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import 'server-only';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { resolveCollectorId } from '@/lib/queries/collectors';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which collector the app is looking at.
|
||||||
|
*
|
||||||
|
* The switch in the header remembers the choice per device (cookie), so Max's
|
||||||
|
* tablet opens on Max. A `?sammler=` in the URL still wins - shared links and
|
||||||
|
* the back button keep working - and `?sammler=alle` shows everybody.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const COLLECTOR_COOKIE = 'bricktrack-sammler';
|
||||||
|
export const ALL_COLLECTORS = 'alle';
|
||||||
|
|
||||||
|
export async function storedCollectorId(): Promise<string | undefined> {
|
||||||
|
return resolveCollectorId((await cookies()).get(COLLECTOR_COOKIE)?.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function activeCollectorId(param: string | undefined): Promise<string | undefined> {
|
||||||
|
if (param === ALL_COLLECTORS) return undefined;
|
||||||
|
if (param) return resolveCollectorId(param);
|
||||||
|
return storedCollectorId();
|
||||||
|
}
|
||||||
@@ -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,129 @@
|
|||||||
|
import 'server-only';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { db } from '@/db';
|
||||||
|
import { appSetting } from '@/db/schema';
|
||||||
|
import { env } from '@/env';
|
||||||
|
import { openSecret, sealSecret } from '@/lib/secret-box';
|
||||||
|
import type { MailSettingsInput } from '@/lib/validation';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the SMTP server comes from.
|
||||||
|
*
|
||||||
|
* The .env wins when it names a server: an admin who configured it there
|
||||||
|
* expects exactly that. Otherwise the settings made on the reminders page
|
||||||
|
* apply. Without either, reminders stay off.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type MailSecurity = 'starttls' | 'tls' | 'none';
|
||||||
|
|
||||||
|
export type MailConfig = {
|
||||||
|
source: 'env' | 'app';
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
security: MailSecurity;
|
||||||
|
user: string | null;
|
||||||
|
password: string | null;
|
||||||
|
from: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type StoredMail = {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
security: MailSecurity;
|
||||||
|
user: string | null;
|
||||||
|
/** Encrypted, see secret-box.ts. */
|
||||||
|
password: string | null;
|
||||||
|
from: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const KEY = 'smtp';
|
||||||
|
|
||||||
|
function readStored(): StoredMail | null {
|
||||||
|
const row = db.select().from(appSetting).where(eq(appSetting.key, KEY)).get();
|
||||||
|
return (row?.value as StoredMail | undefined) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMailConfig(): MailConfig | null {
|
||||||
|
if (env.isMailConfigured) {
|
||||||
|
return {
|
||||||
|
source: 'env',
|
||||||
|
host: env.SMTP_HOST!,
|
||||||
|
port: env.SMTP_PORT,
|
||||||
|
security: env.SMTP_SECURE || env.SMTP_PORT === 465 ? 'tls' : 'starttls',
|
||||||
|
user: env.SMTP_USER ?? null,
|
||||||
|
password: env.SMTP_PASSWORD ?? null,
|
||||||
|
from: env.SMTP_FROM!,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const stored = readStored();
|
||||||
|
if (!stored) return null;
|
||||||
|
return {
|
||||||
|
source: 'app',
|
||||||
|
host: stored.host,
|
||||||
|
port: stored.port,
|
||||||
|
security: stored.security,
|
||||||
|
user: stored.user,
|
||||||
|
password: stored.password ? openSecret(stored.password, env.BETTER_AUTH_SECRET) : null,
|
||||||
|
from: stored.from,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMailConfigured(): boolean {
|
||||||
|
return getMailConfig() !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the settings form may show - never the password itself. */
|
||||||
|
export type MailSettingsView = {
|
||||||
|
source: 'env' | 'app' | null;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
security: MailSecurity;
|
||||||
|
user: string;
|
||||||
|
from: string;
|
||||||
|
hasPassword: boolean;
|
||||||
|
/** A password is stored but cannot be decrypted (BETTER_AUTH_SECRET changed). */
|
||||||
|
passwordUnreadable: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getMailSettingsView(): MailSettingsView {
|
||||||
|
const config = getMailConfig();
|
||||||
|
const stored = config?.source === 'env' ? null : readStored();
|
||||||
|
|
||||||
|
return {
|
||||||
|
source: config?.source ?? null,
|
||||||
|
host: config?.host ?? '',
|
||||||
|
port: config?.port ?? 587,
|
||||||
|
security: config?.security ?? 'starttls',
|
||||||
|
user: config?.user ?? '',
|
||||||
|
from: config?.from ?? '',
|
||||||
|
hasPassword: Boolean(config?.password),
|
||||||
|
passwordUnreadable: Boolean(stored?.password && !config?.password),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveMailSettings(input: MailSettingsInput): void {
|
||||||
|
const previous = readStored();
|
||||||
|
|
||||||
|
let password = previous?.password ?? null;
|
||||||
|
if (input.clearPassword) password = null;
|
||||||
|
if (input.password) password = sealSecret(input.password, env.BETTER_AUTH_SECRET);
|
||||||
|
|
||||||
|
const value: StoredMail = {
|
||||||
|
host: input.host,
|
||||||
|
port: input.port,
|
||||||
|
security: input.security,
|
||||||
|
user: input.user,
|
||||||
|
password,
|
||||||
|
from: input.from,
|
||||||
|
};
|
||||||
|
|
||||||
|
db.insert(appSetting)
|
||||||
|
.values({ key: KEY, value, updatedAt: new Date() })
|
||||||
|
.onConflictDoUpdate({ target: appSetting.key, set: { value, updatedAt: new Date() } })
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteMailSettings(): void {
|
||||||
|
db.delete(appSetting).where(eq(appSetting.key, KEY)).run();
|
||||||
|
}
|
||||||
@@ -1,32 +1,39 @@
|
|||||||
import 'server-only';
|
import 'server-only';
|
||||||
import nodemailer, { type Transporter } from 'nodemailer';
|
import nodemailer, { type Transporter } from 'nodemailer';
|
||||||
import { env } from '@/env';
|
import { getMailConfig, type MailConfig } from '@/lib/mail-settings';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Outgoing mail.
|
* Outgoing mail.
|
||||||
*
|
*
|
||||||
* Entirely optional: without SMTP_HOST and SMTP_FROM nothing is scheduled and
|
* Entirely optional: without a server - in the .env or on the reminders page -
|
||||||
* nothing is sent. The app never depends on mail working.
|
* nothing is sent. The app never depends on mail working.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
let transporter: Transporter | null = null;
|
let cached: { key: string; transporter: Transporter; from: string } | null = null;
|
||||||
|
|
||||||
function getTransporter(): Transporter | null {
|
function connectionKey(config: MailConfig): string {
|
||||||
if (!env.isMailConfigured) return null;
|
return JSON.stringify([config.host, config.port, config.security, config.user, config.password]);
|
||||||
if (transporter) return transporter;
|
}
|
||||||
|
|
||||||
transporter = nodemailer.createTransport({
|
function getTransport(): { transporter: Transporter; from: string } | null {
|
||||||
host: env.SMTP_HOST,
|
const config = getMailConfig();
|
||||||
port: env.SMTP_PORT,
|
if (!config) return null;
|
||||||
|
|
||||||
|
// Settings can change at runtime now; a new connection follows them.
|
||||||
|
const key = connectionKey(config);
|
||||||
|
if (cached?.key === key) return { transporter: cached.transporter, from: config.from };
|
||||||
|
|
||||||
|
const transporter = nodemailer.createTransport({
|
||||||
|
host: config.host,
|
||||||
|
port: config.port,
|
||||||
// Implicit TLS on 465, STARTTLS on 587 - the usual pair.
|
// Implicit TLS on 465, STARTTLS on 587 - the usual pair.
|
||||||
secure: env.SMTP_SECURE || env.SMTP_PORT === 465,
|
secure: config.security === 'tls',
|
||||||
auth:
|
ignoreTLS: config.security === 'none',
|
||||||
env.SMTP_USER && env.SMTP_PASSWORD
|
auth: config.user && config.password ? { user: config.user, pass: config.password } : undefined,
|
||||||
? { user: env.SMTP_USER, pass: env.SMTP_PASSWORD }
|
|
||||||
: undefined,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return transporter;
|
cached = { key, transporter, from: config.from };
|
||||||
|
return { transporter, from: config.from };
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MailResult = { ok: true } | { ok: false; error: string };
|
export type MailResult = { ok: true } | { ok: false; error: string };
|
||||||
@@ -36,14 +43,14 @@ export async function sendMail(input: {
|
|||||||
subject: string;
|
subject: string;
|
||||||
text: string;
|
text: string;
|
||||||
}): Promise<MailResult> {
|
}): Promise<MailResult> {
|
||||||
const transport = getTransporter();
|
const transport = getTransport();
|
||||||
if (!transport) {
|
if (!transport) {
|
||||||
return { ok: false, error: 'Es ist kein SMTP-Server konfiguriert.' };
|
return { ok: false, error: 'Es ist kein SMTP-Server konfiguriert.' };
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await transport.sendMail({
|
await transport.transporter.sendMail({
|
||||||
from: env.SMTP_FROM,
|
from: transport.from,
|
||||||
to: input.to,
|
to: input.to,
|
||||||
subject: input.subject,
|
subject: input.subject,
|
||||||
text: input.text,
|
text: input.text,
|
||||||
@@ -60,13 +67,13 @@ export async function sendMail(input: {
|
|||||||
|
|
||||||
/** Checks the connection without sending anything. */
|
/** Checks the connection without sending anything. */
|
||||||
export async function verifyMailConnection(): Promise<MailResult> {
|
export async function verifyMailConnection(): Promise<MailResult> {
|
||||||
const transport = getTransporter();
|
const transport = getTransport();
|
||||||
if (!transport) {
|
if (!transport) {
|
||||||
return { ok: false, error: 'Es ist kein SMTP-Server konfiguriert.' };
|
return { ok: false, error: 'Es ist kein SMTP-Server konfiguriert.' };
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await transport.verify();
|
await transport.transporter.verify();
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { eq, sql } from 'drizzle-orm';
|
|||||||
import { db } from '@/db';
|
import { db } from '@/db';
|
||||||
import { user, wishlist } from '@/db/schema';
|
import { user, wishlist } from '@/db/schema';
|
||||||
import { env } from '@/env';
|
import { env } from '@/env';
|
||||||
|
import { isMailConfigured } from './mail-settings';
|
||||||
import { sendMail } from './mailer';
|
import { sendMail } from './mailer';
|
||||||
import {
|
import {
|
||||||
reminderSubject,
|
reminderSubject,
|
||||||
@@ -59,7 +60,7 @@ function loadCandidates(): Candidate[] {
|
|||||||
|
|
||||||
export async function runReminderJob(now = new Date()): Promise<ReminderRun> {
|
export async function runReminderJob(now = new Date()): Promise<ReminderRun> {
|
||||||
const result: ReminderRun = { checked: 0, sent: 0, skipped: 0, failures: [] };
|
const result: ReminderRun = { checked: 0, sent: 0, skipped: 0, failures: [] };
|
||||||
if (!env.isMailConfigured) return result;
|
if (!isMailConfigured()) return result;
|
||||||
|
|
||||||
const candidates = loadCandidates();
|
const candidates = loadCandidates();
|
||||||
result.checked = candidates.length;
|
result.checked = candidates.length;
|
||||||
@@ -129,13 +130,12 @@ export function ensureReminderScheduler(): void {
|
|||||||
* cooldown keeps that from turning into repeated mail.
|
* cooldown keeps that from turning into repeated mail.
|
||||||
*/
|
*/
|
||||||
export function startReminderScheduler(): void {
|
export function startReminderScheduler(): void {
|
||||||
if (!env.isMailConfigured) {
|
// Always scheduled: the mail server can be set up in the app at any time,
|
||||||
console.log('[reminders] Kein SMTP konfiguriert - Erinnerungen sind aus.');
|
// and each run checks whether one is configured.
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`[reminders] Aktiv: Erinnerung ${env.REMINDER_DAYS_BEFORE} Tage vor dem Termin, stuendliche Pruefung.`,
|
isMailConfigured()
|
||||||
|
? `[reminders] Aktiv: Erinnerung ${env.REMINDER_DAYS_BEFORE} Tage vor dem Termin, stuendliche Pruefung.`
|
||||||
|
: '[reminders] Noch kein SMTP-Server eingerichtet - Erinnerungen starten, sobald es einen gibt.',
|
||||||
);
|
);
|
||||||
|
|
||||||
const tick = () => {
|
const tick = () => {
|
||||||
|
|||||||
@@ -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,41 @@
|
|||||||
|
import { createCipheriv, createDecipheriv, hkdfSync, randomBytes } from 'node:crypto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypts secrets that settings must keep, such as the SMTP password.
|
||||||
|
*
|
||||||
|
* AES-256-GCM with a key derived from BETTER_AUTH_SECRET: a copy of the
|
||||||
|
* database alone does not reveal the password. Changing BETTER_AUTH_SECRET
|
||||||
|
* makes stored secrets unreadable - `openSecret` then returns null and the
|
||||||
|
* password has to be entered again. Pure apart from node:crypto, so tested.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const VERSION = 'v1';
|
||||||
|
|
||||||
|
function deriveKey(masterSecret: string): Buffer {
|
||||||
|
return Buffer.from(hkdfSync('sha256', masterSecret, 'bricktrack', 'app-setting-secret', 32));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sealSecret(plain: string, masterSecret: string): string {
|
||||||
|
const iv = randomBytes(12);
|
||||||
|
const cipher = createCipheriv('aes-256-gcm', deriveKey(masterSecret), iv);
|
||||||
|
const encrypted = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]);
|
||||||
|
const tag = cipher.getAuthTag();
|
||||||
|
return [VERSION, iv.toString('base64'), tag.toString('base64'), encrypted.toString('base64')].join(':');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openSecret(sealed: string, masterSecret: string): string | null {
|
||||||
|
const [version, iv, tag, encrypted] = sealed.split(':');
|
||||||
|
if (version !== VERSION || !iv || !tag || encrypted === undefined) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decipher = createDecipheriv('aes-256-gcm', deriveKey(masterSecret), Buffer.from(iv, 'base64'));
|
||||||
|
decipher.setAuthTag(Buffer.from(tag, 'base64'));
|
||||||
|
return Buffer.concat([
|
||||||
|
decipher.update(Buffer.from(encrypted, 'base64')),
|
||||||
|
decipher.final(),
|
||||||
|
]).toString('utf8');
|
||||||
|
} catch {
|
||||||
|
// Wrong key or tampered value: treat as "no password stored".
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -219,6 +219,39 @@ export const reservationSchema = z.object({
|
|||||||
|
|
||||||
export type ManufacturerInput = z.infer<typeof manufacturerSchema>;
|
export type ManufacturerInput = z.infer<typeof manufacturerSchema>;
|
||||||
export type ThemeInput = z.infer<typeof themeSchema>;
|
export type ThemeInput = z.infer<typeof themeSchema>;
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mail (reminders)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const mailSecurityEnum = z.enum(['starttls', 'tls', 'none']);
|
||||||
|
|
||||||
|
export const mailSettingsSchema = z.object({
|
||||||
|
host: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1, 'Bitte den SMTP-Server angeben.')
|
||||||
|
.max(255)
|
||||||
|
.regex(/^[a-z0-9.-]+$/i, 'Nur der Servername, z. B. mail.example.de – ohne https:// und Port.'),
|
||||||
|
port: z.coerce.number().int().min(1, 'Ungültiger Port.').max(65535, 'Ungültiger Port.').default(587),
|
||||||
|
security: mailSecurityEnum.default('starttls'),
|
||||||
|
user: optionalText(255),
|
||||||
|
/** Empty keeps the stored password; see clearPassword to remove it. Not trimmed. */
|
||||||
|
password: z
|
||||||
|
.string()
|
||||||
|
.max(500)
|
||||||
|
.nullish()
|
||||||
|
.transform((value) => (value ? value : null)),
|
||||||
|
clearPassword: checkbox,
|
||||||
|
from: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(3, 'Bitte einen Absender angeben.')
|
||||||
|
.max(255)
|
||||||
|
.refine((value) => /@[^@\s>]+\.[^@\s>]+/.test(value), 'Der Absender braucht eine E-Mail-Adresse.'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type MailSettingsInput = z.infer<typeof mailSettingsSchema>;
|
||||||
|
|
||||||
export type LocationInput = z.infer<typeof locationSchema>;
|
export type LocationInput = z.infer<typeof locationSchema>;
|
||||||
export type SetInput = z.infer<typeof setSchema>;
|
export type SetInput = z.infer<typeof setSchema>;
|
||||||
export type CollectionItemInput = z.infer<typeof collectionItemSchema>;
|
export type CollectionItemInput = z.infer<typeof collectionItemSchema>;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { openSecret, sealSecret } from '@/lib/secret-box';
|
||||||
|
import { mailSettingsSchema } from '@/lib/validation';
|
||||||
|
|
||||||
|
const SECRET = 'test-secret-0123456789-abcdefghijklmnop';
|
||||||
|
|
||||||
|
describe('Passwort verschluesselt speichern', () => {
|
||||||
|
it('kommt mit demselben Schluessel unveraendert zurueck', () => {
|
||||||
|
const sealed = sealSecret('geheim mit Leerzeichen ', SECRET);
|
||||||
|
expect(sealed).not.toContain('geheim');
|
||||||
|
expect(openSecret(sealed, SECRET)).toBe('geheim mit Leerzeichen ');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verschluesselt dasselbe Passwort jedes Mal anders', () => {
|
||||||
|
expect(sealSecret('x', SECRET)).not.toBe(sealSecret('x', SECRET));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('liefert nichts bei anderem Schluessel oder veraendertem Wert', () => {
|
||||||
|
const sealed = sealSecret('geheim', SECRET);
|
||||||
|
expect(openSecret(sealed, 'ein-anderes-secret-0123456789-abcdefgh')).toBeNull();
|
||||||
|
|
||||||
|
const [version, iv, tag, data] = sealed.split(':');
|
||||||
|
const flipped = Buffer.from(data!, 'base64');
|
||||||
|
flipped[0] = flipped[0]! ^ 0xff;
|
||||||
|
expect(openSecret([version, iv, tag, flipped.toString('base64')].join(':'), SECRET)).toBeNull();
|
||||||
|
expect(openSecret('kaputt', SECRET)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SMTP-Eingaben pruefen', () => {
|
||||||
|
const valid = {
|
||||||
|
host: 'mail.example.de',
|
||||||
|
port: '587',
|
||||||
|
security: 'starttls',
|
||||||
|
user: 'bricktrack@example.de',
|
||||||
|
password: ' mit Rand ',
|
||||||
|
from: 'Bricktrack <bricktrack@example.de>',
|
||||||
|
};
|
||||||
|
|
||||||
|
it('nimmt gueltige Angaben und laesst das Passwort, wie es ist', () => {
|
||||||
|
const parsed = mailSettingsSchema.parse(valid);
|
||||||
|
expect(parsed.port).toBe(587);
|
||||||
|
expect(parsed.password).toBe(' mit Rand ');
|
||||||
|
expect(parsed.clearPassword).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('macht aus einem leeren Passwort "unveraendert"', () => {
|
||||||
|
expect(mailSettingsSchema.parse({ ...valid, password: '' }).password).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lehnt URLs, Ports und Absender ohne Adresse ab', () => {
|
||||||
|
expect(mailSettingsSchema.safeParse({ ...valid, host: 'https://mail.example.de' }).success).toBe(false);
|
||||||
|
expect(mailSettingsSchema.safeParse({ ...valid, host: 'mail.example.de:587' }).success).toBe(false);
|
||||||
|
expect(mailSettingsSchema.safeParse({ ...valid, port: '70000' }).success).toBe(false);
|
||||||
|
expect(mailSettingsSchema.safeParse({ ...valid, from: 'Bricktrack' }).success).toBe(false);
|
||||||
|
expect(mailSettingsSchema.safeParse({ ...valid, security: 'ssl3' }).success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||