diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ad176d..8805d93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,24 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/). ## [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 - **Set aus einem Link übernehmen**, für alle Hersteller: Amazon, idealo, diff --git a/CLAUDE.md b/CLAUDE.md index 19ca94f..5639be8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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/export.ts` | Export und Import des gesamten Bestands | | `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` | diff --git a/QUICKSTART.md b/QUICKSTART.md index ac6e172..a9646e5 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -144,16 +144,16 @@ tut. ## 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 eigener Passkey – Handy, Tablet, Laptop. ## 8. Loslegen -1. **Verwaltung → Hersteller**: Lego, Playmobil, Cobi … anlegen. -2. **Verwaltung → Themen**: „City", „Technic", „Novelmore" … -3. **Verwaltung → Standorte**: „Regal Kinderzimmer", „Kiste Keller 3" … -4. **Katalog → Set anlegen**: Setnummer, Name, Bild. +1. **Mehr → Hersteller**: Lego, Playmobil, Cobi … anlegen. +2. **Mehr → Themen**: „City", „Technic", „Novelmore" … +3. **Mehr → Standorte**: „Regal Kinderzimmer", „Kiste Keller 3" … +4. **+ in der Navigation → Von Hand**: Setnummer, Name, Bild. 5. Auf der Set-Seite **„Zur Sammlung hinzufügen"**, wenn das Set da ist. 6. **Katalog → Filter „Fehlt noch"**: Kachel lange drücken, mehrere auswählen, **„Zur Wunschliste hinzufügen"**. diff --git a/docs/ANLEITUNG.md b/docs/ANLEITUNG.md index 8d259c8..87a7531 100644 --- a/docs/ANLEITUNG.md +++ b/docs/ANLEITUNG.md @@ -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 | 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 Plus-Menü](bilder/26-plus-menue.png) + +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. + +![Die Mehr-Seite](bilder/27-mehr.png) + +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 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. ![Hersteller verwalten](bilder/03-verwaltung-hersteller.png) -Bevor du Sets anlegst, brauchst du mindestens einen **Hersteller**. Zu finden -über das Konto-Menü oben rechts → **Verwaltung**. +Bevor du Sets von Hand anlegst, brauchst du mindestens einen **Hersteller**. +Zu finden unter **Mehr → Hersteller**. Der Lego-Import und die Übernahme aus +einem Link legen fehlende Hersteller selbst an. Drei Arten von Stammdaten: @@ -117,7 +132,7 @@ Die Kacheln zeigen auf einen Blick: - **„Auf Wunschliste"** – steht auf mindestens einer Liste - Setnummer, Hersteller, Thema, Jahr und UVP -Mit **„Set anlegen"** oben rechts kommt ein neues Set dazu. Pflicht sind nur +Über das **+** in der Navigation kommt ein neues Set dazu. Pflicht sind nur Hersteller, Setnummer und Name; alles andere kannst du nachtragen. Ein Bild lädst du entweder hoch (es wird verkleinert und lokal gespeichert) oder du hinterlegst eine Bild-URL. @@ -286,7 +301,7 @@ zweites Mal. ![Konto und Passkeys](bilder/16-konto-passkeys.png) -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 Anmeldeseite ein Tipp auf „Mit Passkey anmelden" – auf vielen Geräten schlägt 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 -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 des Herstellers oder eine Set-Datenbank wie [Brickfact](https://brickfact.com) (Lego) und @@ -340,7 +355,7 @@ Katalog, kommt statt des Formulars ein Link zum vorhandenen Eintrag. ![Lego-Import](bilder/17-lego-import.png) -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 Thema stehen drin. Das Bild wird **lokal gespeichert**, funktioniert also auch 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 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 und Gültigkeit. @@ -411,7 +426,7 @@ zusätzlich einladen und Daten exportieren. ![Daten](bilder/20-daten.png) -Unter **Verwaltung → Daten** gibt es drei Downloads: +Unter **Mehr → Export & Import** gibt es drei Downloads: | | 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 > 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. ![Nach Sammler filtern](bilder/22-sammler-filter.png) -Danach steht in Katalog und Sammlung oben ein Umschalter. „Alle zusammen" -zeigt den Haushalt als Ganzes, ein Name zeigt genau dessen Sicht – inklusive -der Frage, was **diesem** Kind noch fehlt. +Danach steht oben unter dem Logo der Umschalter **„Für wen?"**. „Alle" +zeigt den Haushalt als Ganzes, ein Name zeigt genau dessen Sicht – auf der +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 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 > 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 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 Stunde zu warten. Dort steht auch, welche Listen einen Termin haben und wie viel dort noch offen ist. @@ -583,7 +600,7 @@ Haken dran, habt ihr es schon. ![Startseite im Design Nachtblau](bilder/25-design-nachtblau.png) -Unter **Konto → Design** gibt es sechs Looks zur Auswahl: +Unter **Mehr → Design** gibt es sechs Looks zur Auswahl: | Design | | |---|---| diff --git a/docs/bilder/02-start.png b/docs/bilder/02-start.png index 05d013f..a31b215 100644 Binary files a/docs/bilder/02-start.png and b/docs/bilder/02-start.png differ diff --git a/docs/bilder/03-verwaltung-hersteller.png b/docs/bilder/03-verwaltung-hersteller.png index a416427..0c47d64 100644 Binary files a/docs/bilder/03-verwaltung-hersteller.png and b/docs/bilder/03-verwaltung-hersteller.png differ diff --git a/docs/bilder/04-katalog.png b/docs/bilder/04-katalog.png index 881d0c9..e5754d6 100644 Binary files a/docs/bilder/04-katalog.png and b/docs/bilder/04-katalog.png differ diff --git a/docs/bilder/05-fehlende-sets.png b/docs/bilder/05-fehlende-sets.png index 3539d31..7a30e61 100644 Binary files a/docs/bilder/05-fehlende-sets.png and b/docs/bilder/05-fehlende-sets.png differ diff --git a/docs/bilder/06-mehrfachauswahl.png b/docs/bilder/06-mehrfachauswahl.png index c05e66e..a1a5d20 100644 Binary files a/docs/bilder/06-mehrfachauswahl.png and b/docs/bilder/06-mehrfachauswahl.png differ diff --git a/docs/bilder/07-wunschliste-dialog.png b/docs/bilder/07-wunschliste-dialog.png index d423f4f..e9c0b7c 100644 Binary files a/docs/bilder/07-wunschliste-dialog.png and b/docs/bilder/07-wunschliste-dialog.png differ diff --git a/docs/bilder/08-set-detail.png b/docs/bilder/08-set-detail.png index 883e022..c6f37a8 100644 Binary files a/docs/bilder/08-set-detail.png and b/docs/bilder/08-set-detail.png differ diff --git a/docs/bilder/09-sammlung.png b/docs/bilder/09-sammlung.png index bb83224..2a51200 100644 Binary files a/docs/bilder/09-sammlung.png and b/docs/bilder/09-sammlung.png differ diff --git a/docs/bilder/10-wunschlisten.png b/docs/bilder/10-wunschlisten.png index fdcef8b..365c6b1 100644 Binary files a/docs/bilder/10-wunschlisten.png and b/docs/bilder/10-wunschlisten.png differ diff --git a/docs/bilder/11-wunschliste-bearbeiten.png b/docs/bilder/11-wunschliste-bearbeiten.png index 6584a66..7df65fb 100644 Binary files a/docs/bilder/11-wunschliste-bearbeiten.png and b/docs/bilder/11-wunschliste-bearbeiten.png differ diff --git a/docs/bilder/13-reservieren.png b/docs/bilder/13-reservieren.png index 26043cc..14719b0 100644 Binary files a/docs/bilder/13-reservieren.png and b/docs/bilder/13-reservieren.png differ diff --git a/docs/bilder/15-wunschliste-reserviert.png b/docs/bilder/15-wunschliste-reserviert.png index e39df29..2f10dea 100644 Binary files a/docs/bilder/15-wunschliste-reserviert.png and b/docs/bilder/15-wunschliste-reserviert.png differ diff --git a/docs/bilder/16-konto-passkeys.png b/docs/bilder/16-konto-passkeys.png index c9c58bd..327f2b7 100644 Binary files a/docs/bilder/16-konto-passkeys.png and b/docs/bilder/16-konto-passkeys.png differ diff --git a/docs/bilder/17-lego-import.png b/docs/bilder/17-lego-import.png index 5007027..71bb513 100644 Binary files a/docs/bilder/17-lego-import.png and b/docs/bilder/17-lego-import.png differ diff --git a/docs/bilder/18-fehlteile.png b/docs/bilder/18-fehlteile.png index 2f03d9d..1090dd5 100644 Binary files a/docs/bilder/18-fehlteile.png and b/docs/bilder/18-fehlteile.png differ diff --git a/docs/bilder/19-einladungen.png b/docs/bilder/19-einladungen.png index f234a94..2f7bb0f 100644 Binary files a/docs/bilder/19-einladungen.png and b/docs/bilder/19-einladungen.png differ diff --git a/docs/bilder/20-daten.png b/docs/bilder/20-daten.png index 4735899..ca0ea90 100644 Binary files a/docs/bilder/20-daten.png and b/docs/bilder/20-daten.png differ diff --git a/docs/bilder/21-sammler.png b/docs/bilder/21-sammler.png index 454c589..fa759c9 100644 Binary files a/docs/bilder/21-sammler.png and b/docs/bilder/21-sammler.png differ diff --git a/docs/bilder/22-sammler-filter.png b/docs/bilder/22-sammler-filter.png index e75cf38..0f03f1d 100644 Binary files a/docs/bilder/22-sammler-filter.png and b/docs/bilder/22-sammler-filter.png differ diff --git a/docs/bilder/23-preise.png b/docs/bilder/23-preise.png index 32dea35..61a434d 100644 Binary files a/docs/bilder/23-preise.png and b/docs/bilder/23-preise.png differ diff --git a/docs/bilder/24-erinnerungen.png b/docs/bilder/24-erinnerungen.png index 06a4476..d7bb26b 100644 Binary files a/docs/bilder/24-erinnerungen.png and b/docs/bilder/24-erinnerungen.png differ diff --git a/docs/bilder/25-design-nachtblau.png b/docs/bilder/25-design-nachtblau.png index 334ddd2..9f92040 100644 Binary files a/docs/bilder/25-design-nachtblau.png and b/docs/bilder/25-design-nachtblau.png differ diff --git a/docs/bilder/26-plus-menue.png b/docs/bilder/26-plus-menue.png new file mode 100644 index 0000000..dd9be03 Binary files /dev/null and b/docs/bilder/26-plus-menue.png differ diff --git a/docs/bilder/27-mehr.png b/docs/bilder/27-mehr.png new file mode 100644 index 0000000..0e65a1d Binary files /dev/null and b/docs/bilder/27-mehr.png differ diff --git a/e2e/screenshots.spec.ts b/e2e/screenshots.spec.ts index 93f99be..62ed79c 100644 --- a/e2e/screenshots.spec.ts +++ b/e2e/screenshots.spec.ts @@ -52,6 +52,17 @@ test('Screenshots fuer die Anleitung', async ({ page, browser }) => { await visit(page, '/'); 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 ------------------------------------------------------- await visit(page, '/verwaltung/hersteller'); 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. await visit(page, '/katalog?besitz=fehlt'); - await expect(page.getByRole('button', { name: /Max/ })).toBeVisible(); - await page.getByRole('button', { name: /Max/ }).click(); + await expect(page.getByRole('radio', { name: /Max/ })).toBeVisible(); + await page.getByRole('radio', { name: /Max/ }).click(); await expect(page.getByText('Sets, die Max noch nicht hat')).toBeVisible(); 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 ----------------------------------------------------------- await visit(page, '/katalog?q=60398'); diff --git a/src/app/(app)/collector-actions.ts b/src/app/(app)/collector-actions.ts new file mode 100644 index 0000000..24e73fc --- /dev/null +++ b/src/app/(app)/collector-actions.ts @@ -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 { + 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(); +} diff --git a/src/app/(app)/katalog/[id]/page.tsx b/src/app/(app)/katalog/[id]/page.tsx index 527ec1d..0d83de0 100644 --- a/src/app/(app)/katalog/[id]/page.tsx +++ b/src/app/(app)/katalog/[id]/page.tsx @@ -23,7 +23,8 @@ import { listPricePoints, } from '@/lib/queries/catalog'; 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 { listWishlistsForSet, listWishlistTargets } from '@/lib/queries/wishlists'; 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 // was looking at in the catalog. const rawCollector = Array.isArray(query.sammler) ? query.sammler[0] : query.sammler; - const collectorId = resolveCollectorId(rawCollector); + const collectorId = await activeCollectorId(rawCollector); const collectors = listCollectors(); const set = getSetById(id, collectorId); diff --git a/src/app/(app)/katalog/neu/page.tsx b/src/app/(app)/katalog/neu/page.tsx index e19c47d..2d76de3 100644 --- a/src/app/(app)/katalog/neu/page.tsx +++ b/src/app/(app)/katalog/neu/page.tsx @@ -15,6 +15,8 @@ export default async function NeuesSetPage({ searchParams }: { searchParams: Sea await requireUser(); const params = await searchParams; 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 themes = listThemes().map((row) => ({ @@ -38,9 +40,14 @@ export default async function NeuesSetPage({ searchParams }: { searchParams: Sea {/* The Lego import creates its manufacturer itself, so it works even on a completely empty instance. */} - + - +

Für ein Set von Hand brauchst du zuerst einen Hersteller – Lego, Playmobil, Cobi oder was auch immer im Kinderzimmer steht. diff --git a/src/app/(app)/katalog/page.tsx b/src/app/(app)/katalog/page.tsx index e76a3d0..f23775a 100644 --- a/src/app/(app)/katalog/page.tsx +++ b/src/app/(app)/katalog/page.tsx @@ -1,7 +1,6 @@ import type { Metadata } from 'next'; import Link from 'next/link'; import { Plus } from 'lucide-react'; -import { CollectorFilter } from '@/components/layout/collector-filter'; import { BarcodeScannerButton } from '@/components/katalog/barcode-scanner'; import { CatalogFilters } from '@/components/katalog/catalog-filters'; import { CatalogGrid } from '@/components/katalog/catalog-grid'; @@ -10,7 +9,8 @@ import { Button } from '@/components/ui/button'; import { pluralize } from '@/lib/format'; import { OWNERSHIP_FILTER_LABELS, parseOwnershipFilter } from '@/lib/ownership'; import { CATALOG_PAGE_SIZE, listSets, listSetYears } from '@/lib/queries/catalog'; -import { 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 { listWishlistTargets } from '@/lib/queries/wishlists'; import { requireUser } from '@/lib/session'; @@ -30,7 +30,7 @@ export default async function KatalogPage({ searchParams }: { searchParams: Sear const ownership = parseOwnershipFilter(single(params.besitz)); 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 yearParam = single(params.jahr); const page = Number(single(params.seite) ?? '1') || 1; @@ -80,11 +80,6 @@ export default async function KatalogPage({ searchParams }: { searchParams: Sear - ({ id: row.id, name: row.name, color: row.color }))} - allLabel="Alle zusammen" - /> -

diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index 69963e7..678423d 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -1,5 +1,8 @@ import { AppHeader } from '@/components/layout/app-header'; 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 { requireUser } from '@/lib/session'; @@ -9,13 +12,24 @@ export default async function AppLayout({ children }: { children: React.ReactNod ensureReminderScheduler(); const user = await requireUser(); + const collectors = listCollectors().map((row) => ({ + id: row.id, + name: row.name, + color: row.color, + })); + const legoImport = isRebrickableEnabled(); return (

- + {/* Bottom padding keeps the mobile nav from covering the last row. */} -
{children}
- +
{children}
+
); } diff --git a/src/app/(app)/mehr/page.tsx b/src/app/(app)/mehr/page.tsx new file mode 100644 index 0000000..0916a10 --- /dev/null +++ b/src/app/(app)/mehr/page.tsx @@ -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 = { + 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 ( +
+
+
+

{user.name}

+

+ {user.email} · {user.role === 'admin' ? 'Administrator' : 'Mitglied'} +

+
+
+ + {MORE_GROUPS.map((group) => { + const entries = items.filter((item) => item.group === group.id); + if (entries.length === 0) return null; + return ( +
+

+ {group.label} +

+
    + {entries.map((item) => ( +
  • + + + + {item.label} + {item.description} + +
  • + ))} +
+
+ ); + })} + + +
+ ); +} diff --git a/src/app/(app)/page.tsx b/src/app/(app)/page.tsx index 60b2f5f..36253f9 100644 --- a/src/app/(app)/page.tsx +++ b/src/app/(app)/page.tsx @@ -4,6 +4,7 @@ import { cn } from 'cn'; import { Studs } from '@/components/brick/studs'; import { Button } from '@/components/ui/button'; import { brickRow } from '@/lib/brick-row'; +import { activeCollectorId } from '@/lib/collector-choice'; import { collectorDotClass } from '@/lib/collector-colors'; import { formatDate, formatNumber, pluralize } from '@/lib/format'; import { formatEuro } from '@/lib/money'; @@ -19,22 +20,31 @@ function daysLabel(days: number): string { return `in ${days} Tagen`; } -export default async function DashboardPage() { - const user = await requireUser(); +type SearchParams = Promise>; - const stats = getCollectionStats(); - const missing = countMissingSets(); +export default async function DashboardPage({ searchParams }: { searchParams: SearchParams }) { + 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 collectors = listCollectors(); + const activeCollector = collectors.find((entry) => entry.id === collectorId) ?? null; const perCollector = countItemsPerCollector(); - const showCollectors = collectors.length > 1; + const showCollectors = collectors.length > 1 && !activeCollector; const bricks = brickRow( stats.setCount, totalSets, - showCollectors - ? collectors.map((entry) => ({ key: entry.id, count: perCollector.get(entry.id) ?? 0 })) - : [], + activeCollector + ? [{ 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])); @@ -53,7 +63,9 @@ export default async function DashboardPage() {
-

Unsere Sammlung

+

+ {activeCollector ? `Sammlung von ${activeCollector.name}` : 'Unsere Sammlung'} +

{formatNumber(stats.setCount)} von {pluralize(totalSets, 'Set', 'Sets')}

diff --git a/src/app/(app)/sammlung/page.tsx b/src/app/(app)/sammlung/page.tsx index b101959..3dff3d2 100644 --- a/src/app/(app)/sammlung/page.tsx +++ b/src/app/(app)/sammlung/page.tsx @@ -3,7 +3,6 @@ import Link from 'next/link'; import { Boxes, Euro, Layers, PackageSearch } from 'lucide-react'; import { SetPlaceholder } from '@/components/brick/set-placeholder'; import { Studs } from '@/components/brick/studs'; -import { CollectorFilter } from '@/components/layout/collector-filter'; import { CollectionFilters } from '@/components/sammlung/collection-filters'; import { Badge } from '@/components/ui/badge'; import { Card, CardContent } from '@/components/ui/card'; @@ -19,7 +18,8 @@ import { listCollectionItems, type CollectionFilters as Filters, } 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 { requireUser } from '@/lib/session'; import type { CollectionStatus, Completeness } from '@/db/types'; @@ -38,7 +38,7 @@ export default async function SammlungPage({ searchParams }: { searchParams: Sea const params = await searchParams; 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 filters: Filters = { @@ -78,11 +78,6 @@ export default async function SammlungPage({ searchParams }: { searchParams: Sea

- ({ id: row.id, name: row.name, color: row.color }))} - allLabel="Alle zusammen" - /> -
{tiles.map((tile) => ( diff --git a/src/app/(app)/verwaltung/layout.tsx b/src/app/(app)/verwaltung/layout.tsx index ae01e31..616a6ba 100644 --- a/src/app/(app)/verwaltung/layout.tsx +++ b/src/app/(app)/verwaltung/layout.tsx @@ -1,21 +1,15 @@ import type { Metadata } from 'next'; -import { VerwaltungNav } from '@/components/verwaltung/verwaltung-nav'; +import { VerwaltungHeader } from '@/components/verwaltung/verwaltung-header'; import { requireUser } from '@/lib/session'; export const metadata: Metadata = { title: 'Verwaltung' }; export default async function VerwaltungLayout({ children }: { children: React.ReactNode }) { - const user = await requireUser(); + await requireUser(); return (
-
-

Verwaltung

-

- Stammdaten, die im Katalog und in der Sammlung ausgewählt werden. -

-
- + {children}
); diff --git a/src/components/katalog/barcode-scanner.tsx b/src/components/katalog/barcode-scanner.tsx index f5fa78f..9bbf54a 100644 --- a/src/components/katalog/barcode-scanner.tsx +++ b/src/components/katalog/barcode-scanner.tsx @@ -25,13 +25,18 @@ type Controls = { stop: () => void }; * loaded only when the scanner is opened - nothing leaves the device, and the * 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 videoRef = useRef(null); const controlsRef = useRef(null); const handledRef = useRef(false); - const [open, setOpen] = useState(false); const [status, setStatus] = useState<'starting' | 'scanning' | 'checking' | 'error'>('starting'); const [message, setMessage] = useState(null); @@ -68,7 +73,7 @@ export function BarcodeScannerButton() { router.refresh(); }); }, - [router, stop], + [router, setOpen, stop], ); useEffect(() => { @@ -134,6 +139,61 @@ export function BarcodeScannerButton() { }; }, [open, onDetected, stop]); + return ( + { + if (!next) stop(); + setOpen(next); + }} + > + + + Barcode scannen + + 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. + + + + {status === 'error' ? ( +

+ {message} +

+ ) : ( +
+
+ ); +} + +export function BarcodeScannerButton() { + const [open, setOpen] = useState(false); + return ( <> - - { - if (!next) stop(); - setOpen(next); - }} - > - - - Barcode scannen - - 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. - - - - {status === 'error' ? ( -

- {message} -

- ) : ( -
-
+ ); } diff --git a/src/components/katalog/link-import.tsx b/src/components/katalog/link-import.tsx index 2db3bed..88339d0 100644 --- a/src/components/katalog/link-import.tsx +++ b/src/components/katalog/link-import.tsx @@ -2,7 +2,7 @@ import Link from 'next/link'; import { useRouter } from 'next/navigation'; -import { useState, useTransition } from 'react'; +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'; @@ -35,11 +35,14 @@ 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; }) { @@ -50,6 +53,13 @@ export function NewSetEntry({ 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) { event.preventDefault(); setExisting(null); @@ -114,6 +124,7 @@ export function NewSetEntry({ onChange={(event) => setUrl(event.target.value)} placeholder="https://…" maxLength={2000} + autoFocus={focus === 'link'} /> + ) : ( + + )} + + + + Was kommt dazu? + Ein Set in den Katalog bringen – auf dem schnellsten Weg. + +
+ {actions.map((action) => ( + + ))} +
+
+ + + + + ); +} diff --git a/src/components/layout/app-header.tsx b/src/components/layout/app-header.tsx index 1d8b2fc..561ac87 100644 --- a/src/components/layout/app-header.tsx +++ b/src/components/layout/app-header.tsx @@ -1,10 +1,27 @@ +import { Suspense } from 'react'; import Link from 'next/link'; import { BrickLogo } from '@/components/brick/logo'; import { DesktopNav } from './bottom-nav'; -import { UserMenu } from './user-menu'; +import { CollectorSwitch, type CollectorOption } from './collector-switch'; 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 (
@@ -16,9 +33,26 @@ export function AppHeader({ user }: { user: SessionUser }) { Bricktrack
- - + + {/* Account, design and master data: everything that is not daily use. */} + + {initials(user.name)} +
+ + {collectors.length > 1 ? ( +
+ {/* useSearchParams needs a boundary; the switch reads ?sammler=. */} + }> + + +
+ ) : null}
); } diff --git a/src/components/layout/bottom-nav.tsx b/src/components/layout/bottom-nav.tsx index ff1a9b5..1ea6f4a 100644 --- a/src/components/layout/bottom-nav.tsx +++ b/src/components/layout/bottom-nav.tsx @@ -2,17 +2,52 @@ import Link from 'next/link'; 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 { 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 = [ + { href: '/', label: 'Start', icon: House }, { href: '/katalog', label: 'Katalog', icon: LayoutGrid }, { href: '/sammlung', label: 'Sammlung', icon: Boxes }, { href: '/wunschlisten', label: 'Wünsche', icon: Gift }, - { href: '/konto', label: 'Konto', icon: User }, ] as const; -export function BottomNav() { +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 ( + + {/* The active tab sits on a small plate, like a tile on a baseplate. */} + + + + {item.label} + + ); +} + +export function BottomNav({ legoImport }: { legoImport: boolean }) { const pathname = usePathname(); return ( @@ -20,49 +55,32 @@ export function BottomNav() { 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" > -
    - {items.map((item) => { - const active = pathname === item.href || pathname.startsWith(`${item.href}/`); - return ( -
  • - - {/* The active tab sits on a small plate, like a tile on a baseplate. */} - - - - {item.label} - -
  • - ); - })} +
      + {items.slice(0, 2).map((item) => ( +
    • + +
    • + ))} +
    • + +
    • + {items.slice(2).map((item) => ( +
    • + +
    • + ))}
    ); } -export function DesktopNav() { +export function DesktopNav({ legoImport }: { legoImport: boolean }) { const pathname = usePathname(); return ( ); } diff --git a/src/components/layout/collector-filter.tsx b/src/components/layout/collector-filter.tsx deleted file mode 100644 index bcd284c..0000000 --- a/src/components/layout/collector-filter.tsx +++ /dev/null @@ -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 ( - - ); -} diff --git a/src/components/layout/collector-switch.tsx b/src/components/layout/collector-switch.tsx new file mode 100644 index 0000000..beb5974 --- /dev/null +++ b/src/components/layout/collector-switch.tsx @@ -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 ( +
    + {options.map((option) => { + const active = current === option.id; + return ( + + ); + })} +
    + ); +} diff --git a/src/components/layout/more-items.ts b/src/components/layout/more-items.ts new file mode 100644 index 0000000..03297dc --- /dev/null +++ b/src/components/layout/more-items.ts @@ -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'); +} diff --git a/src/components/layout/sign-out-button.tsx b/src/components/layout/sign-out-button.tsx new file mode 100644 index 0000000..c7cea01 --- /dev/null +++ b/src/components/layout/sign-out-button.tsx @@ -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 ( + + ); +} diff --git a/src/components/layout/user-menu.tsx b/src/components/layout/user-menu.tsx deleted file mode 100644 index 14a6760..0000000 --- a/src/components/layout/user-menu.tsx +++ /dev/null @@ -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 ( - - - - - - - {user.name} - {user.email} - - - - - - Konto und Passkeys - - - {user.role === 'admin' ? ( - - - - Verwaltung - - - ) : null} - - void signOut()}> - - Abmelden - - - - ); -} diff --git a/src/components/verwaltung/verwaltung-header.tsx b/src/components/verwaltung/verwaltung-header.tsx new file mode 100644 index 0000000..b54d786 --- /dev/null +++ b/src/components/verwaltung/verwaltung-header.tsx @@ -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 ( +
    + + + Mehr + +
    +

    {item?.label ?? 'Verwaltung'}

    + {item ?

    {item.description}

    : null} +
    +
    + ); +} diff --git a/src/components/verwaltung/verwaltung-nav.tsx b/src/components/verwaltung/verwaltung-nav.tsx deleted file mode 100644 index 12c229a..0000000 --- a/src/components/verwaltung/verwaltung-nav.tsx +++ /dev/null @@ -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 ( - - ); -} diff --git a/src/lib/collector-choice.ts b/src/lib/collector-choice.ts new file mode 100644 index 0000000..dc998e2 --- /dev/null +++ b/src/lib/collector-choice.ts @@ -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 { + return resolveCollectorId((await cookies()).get(COLLECTOR_COOKIE)?.value); +} + +export async function activeCollectorId(param: string | undefined): Promise { + if (param === ALL_COLLECTORS) return undefined; + if (param) return resolveCollectorId(param); + return storedCollectorId(); +}