From d5ffcbae6049a5d1e5da8c1aa6432974b30895f6 Mon Sep 17 00:00:00 2001 From: unknown Date: Sun, 25 Oct 2026 18:50:13 +0100 Subject: [PATCH] feat(navigation): plus in der mitte, mehr-seite und sammler-umschalter Unten Start, Katalog, Sammlung, Wuensche und ein Plus mit allen Wegen zu einem neuen Set. Konto, Design und Stammdaten auf der Seite "Mehr" statt im Konto-Menue und in sieben Reitern. Bei mehreren Sammlern waehlt "Fuer wen?" in der Kopfzeile das Kind fuer die ganze App, gemerkt pro Geraet. Behebt nebenbei, dass Mitglieder die Stammdaten nicht erreichten. Co-Authored-By: Claude Opus 5.5 --- e2e/screenshots.spec.ts | 18 ++- src/app/(app)/collector-actions.ts | 38 +++++ src/app/(app)/katalog/[id]/page.tsx | 5 +- src/app/(app)/katalog/neu/page.tsx | 11 +- src/app/(app)/katalog/page.tsx | 11 +- src/app/(app)/layout.tsx | 20 ++- src/app/(app)/mehr/page.tsx | 66 ++++++++ src/app/(app)/page.tsx | 30 ++-- src/app/(app)/sammlung/page.tsx | 11 +- src/app/(app)/verwaltung/layout.tsx | 12 +- src/components/katalog/barcode-scanner.tsx | 116 +++++++------- src/components/katalog/link-import.tsx | 13 +- src/components/katalog/rebrickable-import.tsx | 3 +- src/components/layout/add-menu.tsx | 142 ++++++++++++++++++ src/components/layout/app-header.tsx | 42 +++++- src/components/layout/bottom-nav.tsx | 93 +++++++----- src/components/layout/collector-filter.tsx | 78 ---------- src/components/layout/collector-switch.tsx | 90 +++++++++++ src/components/layout/more-items.ts | 115 ++++++++++++++ src/components/layout/sign-out-button.tsx | 29 ++++ src/components/layout/user-menu.tsx | 65 -------- .../verwaltung/verwaltung-header.tsx | 28 ++++ src/components/verwaltung/verwaltung-nav.tsx | 43 ------ src/lib/collector-choice.ts | 24 +++ 24 files changed, 780 insertions(+), 323 deletions(-) create mode 100644 src/app/(app)/collector-actions.ts create mode 100644 src/app/(app)/mehr/page.tsx create mode 100644 src/components/layout/add-menu.tsx delete mode 100644 src/components/layout/collector-filter.tsx create mode 100644 src/components/layout/collector-switch.tsx create mode 100644 src/components/layout/more-items.ts create mode 100644 src/components/layout/sign-out-button.tsx delete mode 100644 src/components/layout/user-menu.tsx create mode 100644 src/components/verwaltung/verwaltung-header.tsx delete mode 100644 src/components/verwaltung/verwaltung-nav.tsx create mode 100644 src/lib/collector-choice.ts 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(); +}