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(); +}