Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
295da414bb | ||
|
|
d5ffcbae60 |
@@ -7,6 +7,24 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
|
||||||
|
- **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, oben rechts hinter den Initialen.
|
||||||
|
- **„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.
|
||||||
|
|
||||||
### Hinzugefügt
|
### Hinzugefügt
|
||||||
|
|
||||||
- **Set aus einem Link übernehmen**, für alle Hersteller: Amazon, idealo,
|
- **Set aus einem Link übernehmen**, für alle Hersteller: Amazon, idealo,
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ Bild-URLs, die der Nutzer selbst hinterlegt hat.
|
|||||||
| `src/lib/reminder-job.ts`, `src/lib/mailer.ts` | Versand und Zeitplan |
|
| `src/lib/reminder-job.ts`, `src/lib/mailer.ts` | Versand und Zeitplan |
|
||||||
| `src/lib/export.ts` | Export und Import des gesamten Bestands |
|
| `src/lib/export.ts` | Export und Import des gesamten Bestands |
|
||||||
| `src/lib/money.ts`, `src/lib/format.ts` | Cent-Umrechnung, dd.MM.yyyy |
|
| `src/lib/money.ts`, `src/lib/format.ts` | Cent-Umrechnung, dd.MM.yyyy |
|
||||||
|
| `src/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/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/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` |
|
||||||
|
|||||||
@@ -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** (das Kästchen mit deinen Initialen 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 Kästchen mit deinen
|
||||||
|
Initialen oben rechts – der Seite **Mehr**: 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.
|
||||||
@@ -310,7 +325,7 @@ Sets von Hand einzutippen ist der langsame Weg. Es gibt drei schnellere.
|
|||||||
|
|
||||||
### Aus einem Link übernehmen – für alle Hersteller
|
### Aus einem Link übernehmen – für alle Hersteller
|
||||||
|
|
||||||
Auf **Katalog → Set anlegen** steht das Feld „Aus einem Link übernehmen".
|
Über **+ → Aus Link** landest du im Feld „Aus einem Link übernehmen".
|
||||||
Dort passt jeder Link zu einem Set hinein: Amazon, idealo, geizhals, der Shop
|
Dort passt jeder Link zu einem Set hinein: Amazon, idealo, geizhals, der Shop
|
||||||
des Herstellers oder eine Set-Datenbank wie
|
des Herstellers oder eine Set-Datenbank wie
|
||||||
[Brickfact](https://brickfact.com) (Lego) und
|
[Brickfact](https://brickfact.com) (Lego) und
|
||||||
@@ -340,7 +355,7 @@ Katalog, kommt statt des Formulars ein Link zum vorhandenen Eintrag.
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
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
|
||||||
@@ -393,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.
|
||||||
@@ -411,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 |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -471,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
|
||||||
@@ -486,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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -534,7 +551,7 @@ SMTP_FROM=Bricktrack <bricktrack@example.de>
|
|||||||
REMINDER_DAYS_BEFORE=14
|
REMINDER_DAYS_BEFORE=14
|
||||||
```
|
```
|
||||||
|
|
||||||
Unter **Verwaltung → Erinnerungen** kannst du die Verbindung prüfen, dir eine
|
Unter **Mehr → Erinnerungen** kannst du 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.
|
||||||
@@ -583,7 +600,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: 185 KiB |
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 435 KiB After Width: | Height: | Size: 443 KiB |
|
Before Width: | Height: | Size: 322 KiB After Width: | Height: | Size: 329 KiB |
|
Before Width: | Height: | Size: 361 KiB After Width: | Height: | Size: 369 KiB |
|
Before Width: | Height: | Size: 326 KiB After Width: | Height: | Size: 407 KiB |
|
Before Width: | Height: | Size: 329 KiB After Width: | Height: | Size: 348 KiB |
|
Before Width: | Height: | Size: 320 KiB After Width: | Height: | Size: 329 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 476 KiB After Width: | Height: | Size: 495 KiB |
|
Before Width: | Height: | Size: 426 KiB After Width: | Height: | Size: 427 KiB |
|
Before Width: | Height: | Size: 477 KiB After Width: | Height: | Size: 497 KiB |
|
Before Width: | Height: | Size: 283 KiB After Width: | Height: | Size: 300 KiB |
|
Before Width: | Height: | Size: 361 KiB After Width: | Height: | Size: 381 KiB |
|
Before Width: | Height: | Size: 206 KiB After Width: | Height: | Size: 232 KiB |
|
Before Width: | Height: | Size: 208 KiB After Width: | Height: | Size: 327 KiB |
|
Before Width: | Height: | Size: 297 KiB After Width: | Height: | Size: 308 KiB |
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 149 KiB |
|
Before Width: | Height: | Size: 368 KiB After Width: | Height: | Size: 378 KiB |
|
Before Width: | Height: | Size: 336 KiB After Width: | Height: | Size: 345 KiB |
|
Before Width: | Height: | Size: 222 KiB After Width: | Height: | Size: 232 KiB |
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 445 KiB |
|
After Width: | Height: | Size: 269 KiB |
@@ -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);
|
||||||
|
|||||||
@@ -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) => ({
|
||||||
@@ -38,9 +40,14 @@ export default async function NeuesSetPage({ searchParams }: { searchParams: Sea
|
|||||||
</div>
|
</div>
|
||||||
{/* The Lego import creates its manufacturer itself, so it works even
|
{/* The Lego import creates its manufacturer itself, so it works even
|
||||||
on a completely empty instance. */}
|
on a completely empty instance. */}
|
||||||
<RebrickableImport enabled={importEnabled} />
|
<RebrickableImport enabled={importEnabled} autoFocus={way === 'lego'} />
|
||||||
|
|
||||||
<NewSetEntry manufacturers={manufacturers} themes={themes} defaultEan={ean}>
|
<NewSetEntry
|
||||||
|
manufacturers={manufacturers}
|
||||||
|
themes={themes}
|
||||||
|
defaultEan={ean}
|
||||||
|
focus={way === 'link' || way === 'hand' ? way : undefined}
|
||||||
|
>
|
||||||
<p className="rounded-lg border border-dashed border-border p-6 text-sm text-muted-foreground">
|
<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
|
Für ein Set von Hand brauchst du zuerst einen Hersteller – Lego, Playmobil, Cobi oder
|
||||||
was auch immer im Kinderzimmer steht.
|
was auch immer im Kinderzimmer steht.
|
||||||
|
|||||||
@@ -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,20 +20,29 @@ 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
|
||||||
|
? [{ key: activeCollector.id, count: 1 }]
|
||||||
|
: showCollectors
|
||||||
? collectors.map((entry) => ({ key: entry.id, count: perCollector.get(entry.id) ?? 0 }))
|
? collectors.map((entry) => ({ key: entry.id, count: perCollector.get(entry.id) ?? 0 }))
|
||||||
: [],
|
: [],
|
||||||
);
|
);
|
||||||
@@ -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,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(() => {
|
||||||
@@ -135,17 +140,6 @@ export function BarcodeScannerButton() {
|
|||||||
}, [open, onDetected, stop]);
|
}, [open, onDetected, stop]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon-lg"
|
|
||||||
aria-label="Barcode scannen"
|
|
||||||
title="Barcode scannen"
|
|
||||||
onClick={() => setOpen(true)}
|
|
||||||
>
|
|
||||||
<ScanLine />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={(next) => {
|
onOpenChange={(next) => {
|
||||||
@@ -194,6 +188,24 @@ export function BarcodeScannerButton() {
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BarcodeScannerButton() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon-lg"
|
||||||
|
aria-label="Barcode scannen"
|
||||||
|
title="Barcode scannen"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
>
|
||||||
|
<ScanLine />
|
||||||
|
</Button>
|
||||||
|
<BarcodeScannerDialog open={open} onOpenChange={setOpen} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useState, useTransition } from 'react';
|
import { useEffect, useState, useTransition } from 'react';
|
||||||
import { Link2, Loader2 } from 'lucide-react';
|
import { Link2, Loader2 } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { previewSetFromLink, type LinkPreview } from '@/app/(app)/katalog/link-actions';
|
import { previewSetFromLink, type LinkPreview } from '@/app/(app)/katalog/link-actions';
|
||||||
@@ -35,11 +35,14 @@ export function NewSetEntry({
|
|||||||
manufacturers,
|
manufacturers,
|
||||||
themes,
|
themes,
|
||||||
defaultEan,
|
defaultEan,
|
||||||
|
focus,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
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 }>;
|
||||||
defaultEan?: 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. */
|
/** Shown instead of the form while there is no manufacturer yet. */
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
@@ -50,6 +53,13 @@ export function NewSetEntry({
|
|||||||
const [existing, setExisting] = useState<{ id: string; label: string } | null>(null);
|
const [existing, setExisting] = useState<{ id: string; label: string } | null>(null);
|
||||||
const [pending, startTransition] = useTransition();
|
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>) {
|
function submit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setExisting(null);
|
setExisting(null);
|
||||||
@@ -114,6 +124,7 @@ export function NewSetEntry({
|
|||||||
onChange={(event) => setUrl(event.target.value)}
|
onChange={(event) => setUrl(event.target.value)}
|
||||||
placeholder="https://…"
|
placeholder="https://…"
|
||||||
maxLength={2000}
|
maxLength={2000}
|
||||||
|
autoFocus={focus === 'link'}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Button type="submit" size="lg" disabled={pending || url.trim().length === 0}>
|
<Button type="submit" size="lg" disabled={pending || url.trim().length === 0}>
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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,27 @@
|
|||||||
|
import { Suspense } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
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 }) {
|
function initials(name: string): string {
|
||||||
|
const parts = name.trim().split(/\s+/);
|
||||||
|
const letters = (parts[0]?.[0] ?? '') + (parts.length > 1 ? (parts.at(-1)?.[0] ?? '') : '');
|
||||||
|
return letters.toUpperCase() || '?';
|
||||||
|
}
|
||||||
|
|
||||||
|
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 +33,26 @@ 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. */}
|
||||||
|
<Link
|
||||||
|
href="/mehr"
|
||||||
|
aria-label="Mehr: Konto, Design und Verwaltung"
|
||||||
|
title="Mehr"
|
||||||
|
className="flex size-10 items-center justify-center rounded-[calc(var(--radius)*0.7)] border-[length:max(1px,var(--edge-w))] border-edge bg-card text-sm font-bold shadow-(--lift-sm) outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||||
|
>
|
||||||
|
{initials(user.name)}
|
||||||
|
</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">
|
|
||||||
<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>
|
|
||||||
</li>
|
</li>
|
||||||
);
|
))}
|
||||||
})}
|
<li className="flex justify-center">
|
||||||
|
<AddMenu legoImport={legoImport} />
|
||||||
|
</li>
|
||||||
|
{items.slice(2).map((item) => (
|
||||||
|
<li key={item.href}>
|
||||||
|
<NavLink item={item} pathname={pathname} />
|
||||||
|
</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,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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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();
|
||||||
|
}
|
||||||