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 <noreply@anthropic.com>
This commit is contained in:
unknown
2026-10-25 18:50:13 +01:00
co-authored by Claude Opus 5.5
parent d578eac743
commit d5ffcbae60
24 changed files with 780 additions and 323 deletions
+16 -2
View File
@@ -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');
+38
View File
@@ -0,0 +1,38 @@
'use server';
import { revalidatePath } from 'next/cache';
import { cookies } from 'next/headers';
import { z } from 'zod';
import { env } from '@/env';
import { actionError, actionOk, type ActionResult } from '@/lib/action-result';
import { COLLECTOR_COOKIE } from '@/lib/collector-choice';
import { resolveCollectorId } from '@/lib/queries/collectors';
import { requireUser } from '@/lib/session';
const schema = z.object({ id: z.string().max(64) });
/** Remembers the collector chosen in the header; an empty id means "alle". */
export async function chooseCollectorAction(input: unknown): Promise<ActionResult> {
await requireUser();
const parsed = schema.safeParse(input);
if (!parsed.success) return actionError('Ungültige Auswahl.');
const store = await cookies();
if (parsed.data.id === '') {
store.delete(COLLECTOR_COOKIE);
} else {
const id = resolveCollectorId(parsed.data.id);
if (!id) return actionError('Diesen Sammler gibt es nicht mehr.');
store.set(COLLECTOR_COOKIE, id, {
httpOnly: true,
secure: env.isProduction,
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 24 * 365,
});
}
revalidatePath('/', 'layout');
return actionOk();
}
+3 -2
View File
@@ -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);
+9 -2
View File
@@ -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
</div>
{/* The Lego import creates its manufacturer itself, so it works even
on a completely empty instance. */}
<RebrickableImport enabled={importEnabled} />
<RebrickableImport enabled={importEnabled} autoFocus={way === 'lego'} />
<NewSetEntry manufacturers={manufacturers} themes={themes} defaultEan={ean}>
<NewSetEntry
manufacturers={manufacturers}
themes={themes}
defaultEan={ean}
focus={way === 'link' || way === 'hand' ? way : undefined}
>
<p className="rounded-lg border border-dashed border-border p-6 text-sm text-muted-foreground">
Für ein Set von Hand brauchst du zuerst einen Hersteller – Lego, Playmobil, Cobi oder
was auch immer im Kinderzimmer steht.
+3 -8
View File
@@ -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
</div>
</div>
<CollectorFilter
collectors={collectors.map((row) => ({ id: row.id, name: row.name, color: row.color }))}
allLabel="Alle zusammen"
/>
<CatalogFilters options={{ manufacturers, themes, years: listSetYears() }} />
<p className="text-sm text-muted-foreground">
+17 -3
View File
@@ -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 (
<div className="flex min-h-dvh flex-col">
<AppHeader user={user} />
<AppHeader
user={user}
collectors={collectors}
storedCollectorId={(await storedCollectorId()) ?? null}
legoImport={legoImport}
/>
{/* Bottom padding keeps the mobile nav from covering the last row. */}
<main className="mx-auto w-full max-w-5xl flex-1 px-4 pt-4 pb-24 md:pb-10">{children}</main>
<BottomNav />
<main className="mx-auto w-full max-w-5xl flex-1 px-4 pt-4 pb-28 md:pb-10">{children}</main>
<BottomNav legoImport={legoImport} />
</div>
);
}
+66
View File
@@ -0,0 +1,66 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { cn } from 'cn';
import { Studs } from '@/components/brick/studs';
import { MORE_GROUPS, moreItemsFor } from '@/components/layout/more-items';
import { SignOutButton } from '@/components/layout/sign-out-button';
import { requireUser } from '@/lib/session';
export const metadata: Metadata = { title: 'Mehr' };
const GROUP_TONES: Record<string, string> = {
ich: 'bg-tone-1',
familie: 'bg-tone-2',
katalog: 'bg-tone-3',
daten: 'bg-tone-4',
};
export default async function MehrPage() {
const user = await requireUser();
const items = moreItemsFor(user.role);
return (
<div className="space-y-7">
<div className="flex items-center gap-3">
<div>
<h1 className="text-2xl font-semibold tracking-tight">{user.name}</h1>
<p className="text-sm text-muted-foreground">
{user.email} · {user.role === 'admin' ? 'Administrator' : 'Mitglied'}
</p>
</div>
</div>
{MORE_GROUPS.map((group) => {
const entries = items.filter((item) => item.group === group.id);
if (entries.length === 0) return null;
return (
<section key={group.id} className="space-y-4">
<h2 className="text-xs font-bold tracking-widest text-muted-foreground uppercase">
{group.label}
</h2>
<ul className="grid grid-cols-2 gap-x-3 gap-y-5 sm:grid-cols-3">
{entries.map((item) => (
<li key={item.href} className="relative">
<Studs studClassName={GROUP_TONES[group.id]} />
<Link
href={item.href}
className={cn(
'brick flex h-full flex-col gap-1 rounded-xl p-3.5 text-tone-ink outline-none focus-visible:ring-3 focus-visible:ring-ring/50',
GROUP_TONES[group.id],
)}
>
<item.icon className="size-6" aria-hidden />
<span className="font-bold">{item.label}</span>
<span className="text-xs leading-snug opacity-80">{item.description}</span>
</Link>
</li>
))}
</ul>
</section>
);
})}
<SignOutButton />
</div>
);
}
+21 -9
View File
@@ -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<Record<string, string | string[] | undefined>>;
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() {
<Studs count={3} />
<div className="brick rounded-xl bg-card p-4 text-card-foreground">
<div className="flex items-baseline justify-between gap-3">
<h2 className="text-lg font-semibold">Unsere Sammlung</h2>
<h2 className="text-lg font-semibold">
{activeCollector ? `Sammlung von ${activeCollector.name}` : 'Unsere Sammlung'}
</h2>
<p className="text-sm font-semibold text-muted-foreground">
{formatNumber(stats.setCount)} von {pluralize(totalSets, 'Set', 'Sets')}
</p>
+3 -8
View File
@@ -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
</p>
</div>
<CollectorFilter
collectors={collectors.map((row) => ({ id: row.id, name: row.name, color: row.color }))}
allLabel="Alle zusammen"
/>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{tiles.map((tile) => (
<Card key={tile.label}>
+3 -9
View File
@@ -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 (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Verwaltung</h1>
<p className="text-sm text-muted-foreground">
Stammdaten, die im Katalog und in der Sammlung ausgewählt werden.
</p>
</div>
<VerwaltungNav isAdmin={user.role === 'admin'} />
<VerwaltungHeader />
{children}
</div>
);
+64 -52
View File
@@ -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<HTMLVideoElement>(null);
const controlsRef = useRef<Controls | null>(null);
const handledRef = useRef(false);
const [open, setOpen] = useState(false);
const [status, setStatus] = useState<'starting' | 'scanning' | 'checking' | 'error'>('starting');
const [message, setMessage] = useState<string | null>(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 (
<Dialog
open={open}
onOpenChange={(next) => {
if (!next) stop();
setOpen(next);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Barcode scannen</DialogTitle>
<DialogDescription>
Halte den Strichcode auf der Packung ins Bild. Kennt der Katalog das Set schon,
landest du direkt darauf – sonst im Formular mit ausgefüllter EAN.
</DialogDescription>
</DialogHeader>
{status === 'error' ? (
<p className="rounded-lg border border-dashed border-border p-4 text-sm text-muted-foreground">
{message}
</p>
) : (
<div className="relative overflow-hidden rounded-lg bg-black">
<video
ref={videoRef}
className="aspect-[3/4] w-full object-cover"
muted
playsInline
aria-label="Kamerabild"
/>
{/* A guide line makes people hold the box the right way round. */}
<div className="pointer-events-none absolute inset-x-6 top-1/2 h-0.5 -translate-y-1/2 bg-red-500/80" />
{status !== 'scanning' ? (
<div className="absolute inset-0 flex items-center justify-center gap-2 bg-black/60 text-sm text-white">
<Loader2 className="size-4 animate-spin" />
{status === 'checking' ? 'Im Katalog nachsehen …' : 'Kamera startet …'}
</div>
) : null}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
Abbrechen
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export function BarcodeScannerButton() {
const [open, setOpen] = useState(false);
return (
<>
<Button
@@ -145,55 +205,7 @@ export function BarcodeScannerButton() {
>
<ScanLine />
</Button>
<Dialog
open={open}
onOpenChange={(next) => {
if (!next) stop();
setOpen(next);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Barcode scannen</DialogTitle>
<DialogDescription>
Halte den Strichcode auf der Packung ins Bild. Kennt der Katalog das Set schon,
landest du direkt darauf – sonst im Formular mit ausgefüllter EAN.
</DialogDescription>
</DialogHeader>
{status === 'error' ? (
<p className="rounded-lg border border-dashed border-border p-4 text-sm text-muted-foreground">
{message}
</p>
) : (
<div className="relative overflow-hidden rounded-lg bg-black">
<video
ref={videoRef}
className="aspect-[3/4] w-full object-cover"
muted
playsInline
aria-label="Kamerabild"
/>
{/* A guide line makes people hold the box the right way round. */}
<div className="pointer-events-none absolute inset-x-6 top-1/2 h-0.5 -translate-y-1/2 bg-red-500/80" />
{status !== 'scanning' ? (
<div className="absolute inset-0 flex items-center justify-center gap-2 bg-black/60 text-sm text-white">
<Loader2 className="size-4 animate-spin" />
{status === 'checking' ? 'Im Katalog nachsehen …' : 'Kamera startet …'}
</div>
) : null}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
Abbrechen
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<BarcodeScannerDialog open={open} onOpenChange={setOpen} />
</>
);
}
+12 -1
View File
@@ -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<HTMLFormElement>) {
event.preventDefault();
setExisting(null);
@@ -114,6 +124,7 @@ export function NewSetEntry({
onChange={(event) => setUrl(event.target.value)}
placeholder="https://…"
maxLength={2000}
autoFocus={focus === 'link'}
/>
</Field>
<Button type="submit" size="lg" disabled={pending || url.trim().length === 0}>
@@ -14,7 +14,7 @@ import { Input } from '@/components/ui/input';
* One-field Lego import: type the number from the box, get a finished catalog
* entry. Hidden entirely when no API key is configured.
*/
export function RebrickableImport({ enabled }: { enabled: boolean }) {
export function RebrickableImport({ enabled, autoFocus }: { enabled: boolean; autoFocus?: boolean }) {
const router = useRouter();
const [setNumber, setSetNumber] = useState('');
const [pending, startTransition] = useTransition();
@@ -80,6 +80,7 @@ export function RebrickableImport({ enabled }: { enabled: boolean }) {
onChange={(event) => setSetNumber(event.target.value)}
placeholder="60398"
inputMode="numeric"
autoFocus={autoFocus}
maxLength={40}
className="font-mono"
/>
+142
View File
@@ -0,0 +1,142 @@
'use client';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { Hash, Link2, PencilLine, Plus, ScanLine, type LucideIcon } from 'lucide-react';
import { cn } from 'cn';
import { Studs } from '@/components/brick/studs';
import { BarcodeScannerDialog } from '@/components/katalog/barcode-scanner';
import { Button } from '@/components/ui/button';
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@/components/ui/sheet';
type Action = {
id: string;
label: string;
hint: string;
icon: LucideIcon;
tone: string;
href?: string;
};
/**
* The plus in the middle of the navigation: every way a set gets into the
* catalog, in one place. Opens as a sheet from the bottom.
*/
export function AddMenu({
legoImport,
variant = 'nav',
}: {
/** Rebrickable is configured. */
legoImport: boolean;
variant?: 'nav' | 'button';
}) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [scanning, setScanning] = useState(false);
const actions: Action[] = [
{ id: 'scan', label: 'Scannen', hint: 'Strichcode der Packung', icon: ScanLine, tone: 'bg-tone-1' },
{
id: 'link',
label: 'Aus Link',
hint: 'Amazon, idealo, Shop',
icon: Link2,
tone: 'bg-tone-2',
href: '/katalog/neu?weg=link',
},
...(legoImport
? [
{
id: 'lego',
label: 'Lego-Nummer',
hint: 'über Rebrickable',
icon: Hash,
tone: 'bg-tone-3',
href: '/katalog/neu?weg=lego',
},
]
: []),
{
id: 'hand',
label: 'Von Hand',
hint: 'alles selbst eintragen',
icon: PencilLine,
tone: 'bg-card',
href: '/katalog/neu?weg=hand',
},
];
function run(action: Action) {
setOpen(false);
if (action.id === 'scan') {
setScanning(true);
return;
}
if (action.href) router.push(action.href);
}
return (
<>
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
{variant === 'nav' ? (
<button
type="button"
aria-label="Neues Set"
className="relative -mt-7 flex h-14 w-16 items-center justify-center rounded-[calc(var(--radius)*0.9)] border-[length:var(--edge-w)] border-edge bg-primary text-primary-foreground shadow-(--lift) outline-none focus-visible:ring-3 focus-visible:ring-ring/50 active:translate-y-0.5"
>
<Studs count={2} className="left-2.5 gap-3 [&>.stud]:w-3.5 [&>.stud]:bg-primary" />
<Plus className={cn('size-7 transition-transform', open && 'rotate-45')} strokeWidth={3} />
</button>
) : (
<Button size="lg">
<Plus />
Neu
</Button>
)}
</SheetTrigger>
<SheetContent
side="bottom"
className="rounded-t-[calc(var(--radius)*1.6)] border-t-[length:max(1px,var(--edge-w))] border-edge bg-background pb-[calc(env(safe-area-inset-bottom)+1.5rem)]"
>
<SheetHeader>
<SheetTitle className="text-xl">Was kommt dazu?</SheetTitle>
<SheetDescription>Ein Set in den Katalog bringen – auf dem schnellsten Weg.</SheetDescription>
</SheetHeader>
<div className="mx-auto grid w-full max-w-lg grid-cols-2 gap-x-3 gap-y-5 px-4 pt-2">
{actions.map((action) => (
<button
key={action.id}
type="button"
onClick={() => run(action)}
className="relative text-left outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
>
<Studs studClassName={action.tone} />
<span
className={cn(
'brick flex h-full flex-col gap-1 rounded-xl p-3.5 text-foreground',
action.tone,
action.tone !== 'bg-card' && 'text-tone-ink',
)}
>
<action.icon className="size-6" aria-hidden />
<span className="text-base font-bold">{action.label}</span>
<span className="text-xs font-semibold opacity-80">{action.hint}</span>
</span>
</button>
))}
</div>
</SheetContent>
</Sheet>
<BarcodeScannerDialog open={scanning} onOpenChange={setScanning} />
</>
);
}
+38 -4
View File
@@ -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 (
<header className="sticky top-0 z-40 border-b border-border bg-background/95 backdrop-blur">
<div className="mx-auto flex h-14 max-w-5xl items-center gap-3 px-4">
@@ -16,9 +33,26 @@ export function AppHeader({ user }: { user: SessionUser }) {
<span>Bricktrack</span>
</Link>
<div className="flex-1" />
<DesktopNav />
<UserMenu user={user} />
<DesktopNav legoImport={legoImport} />
{/* Account, design and master data: everything that is not daily use. */}
<Link
href="/mehr"
aria-label="Mehr: Konto, Design und Verwaltung"
title="Mehr"
className="flex size-10 items-center justify-center rounded-[calc(var(--radius)*0.7)] border-[length:max(1px,var(--edge-w))] border-edge bg-card text-sm font-bold shadow-(--lift-sm) outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
>
{initials(user.name)}
</Link>
</div>
{collectors.length > 1 ? (
<div className="mx-auto max-w-5xl px-4 pb-2">
{/* useSearchParams needs a boundary; the switch reads ?sammler=. */}
<Suspense fallback={<div className="h-11" />}>
<CollectorSwitch collectors={collectors} storedId={storedCollectorId} />
</Suspense>
</div>
) : null}
</header>
);
}
+57 -36
View File
@@ -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 (
<Link
href={item.href}
aria-current={active ? 'page' : undefined}
className={cn(
'flex h-16 flex-col items-center justify-center gap-1 text-xs transition-colors',
active ? 'font-bold text-foreground' : 'font-medium text-muted-foreground hover:text-foreground',
)}
>
{/* The active tab sits on a small plate, like a tile on a baseplate. */}
<span
className={cn(
'flex h-7 w-12 items-center justify-center rounded-[calc(var(--radius)*0.6)] border-[length:var(--edge-w)]',
active ? 'border-edge bg-secondary text-secondary-foreground' : 'border-transparent',
)}
>
<item.icon className="size-5" aria-hidden />
</span>
{item.label}
</Link>
);
}
export function BottomNav({ legoImport }: { legoImport: boolean }) {
const pathname = usePathname();
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"
>
<ul className="mx-auto flex max-w-lg">
{items.map((item) => {
const active = pathname === item.href || pathname.startsWith(`${item.href}/`);
return (
<li key={item.href} className="flex-1">
<Link
href={item.href}
aria-current={active ? 'page' : undefined}
className={cn(
'flex h-16 flex-col items-center justify-center gap-1 text-xs transition-colors',
active
? 'font-bold text-foreground'
: 'font-medium text-muted-foreground hover:text-foreground',
)}
>
{/* The active tab sits on a small plate, like a tile on a baseplate. */}
<span
className={cn(
'flex h-7 w-12 items-center justify-center rounded-[calc(var(--radius)*0.6)] border-[length:var(--edge-w)]',
active
? 'border-edge bg-secondary text-secondary-foreground'
: 'border-transparent',
)}
>
<item.icon className="size-5" aria-hidden />
</span>
{item.label}
</Link>
</li>
);
})}
<ul className="mx-auto grid max-w-lg grid-cols-5 items-center">
{items.slice(0, 2).map((item) => (
<li key={item.href}>
<NavLink item={item} pathname={pathname} />
</li>
))}
<li className="flex justify-center">
<AddMenu legoImport={legoImport} />
</li>
{items.slice(2).map((item) => (
<li key={item.href}>
<NavLink item={item} pathname={pathname} />
</li>
))}
</ul>
</nav>
);
}
export function DesktopNav() {
export function DesktopNav({ legoImport }: { legoImport: boolean }) {
const pathname = usePathname();
return (
<nav aria-label="Hauptnavigation" className="hidden items-center gap-1 md:flex">
{items.map((item) => {
const active = pathname === item.href || pathname.startsWith(`${item.href}/`);
const active = isActive(pathname, item.href);
return (
<Link
key={item.href}
@@ -80,6 +98,9 @@ export function DesktopNav() {
</Link>
);
})}
<span className="ml-2">
<AddMenu legoImport={legoImport} variant="button" />
</span>
</nav>
);
}
@@ -1,78 +0,0 @@
'use client';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { useTransition } from 'react';
import { cn } from 'cn';
import { collectorDotClass } from '@/lib/collector-colors';
export type CollectorOption = { id: string; name: string; color: string };
/**
* Switches the view between collectors.
*
* Rendered only when there is more than one - with a single collector the
* whole question does not exist, and the app should not ask it. The choice
* lives in the URL (`?sammler=`), so a filtered view stays shareable and the
* back button behaves.
*/
export function CollectorFilter({
collectors,
allLabel = 'Alle',
}: {
collectors: CollectorOption[];
allLabel?: string;
}) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [, startTransition] = useTransition();
if (collectors.length < 2) return null;
const current = searchParams.get('sammler') ?? '';
function select(id: string) {
const params = new URLSearchParams(searchParams.toString());
if (id) params.set('sammler', id);
else params.delete('sammler');
params.delete('seite');
const query = params.toString();
startTransition(() => router.push(query ? `${pathname}?${query}` : pathname));
}
const options = [{ id: '', name: allLabel, color: 'slate' }, ...collectors];
return (
<nav
aria-label="Sammler"
className="flex gap-1 overflow-x-auto rounded-lg bg-muted p-1"
>
{options.map((option) => {
const active = current === option.id;
return (
<button
key={option.id || 'alle'}
type="button"
aria-pressed={active}
onClick={() => select(option.id)}
className={cn(
'flex flex-1 items-center justify-center gap-2 rounded-md px-3 py-2 text-sm font-medium whitespace-nowrap transition-colors',
active
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground',
)}
>
{option.id ? (
<span
className={cn('size-2 rounded-full', collectorDotClass(option.color))}
aria-hidden
/>
) : null}
{option.name}
</button>
);
})}
</nav>
);
}
@@ -0,0 +1,90 @@
'use client';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { useTransition } from 'react';
import { toast } from 'sonner';
import { cn } from 'cn';
import { chooseCollectorAction } from '@/app/(app)/collector-actions';
import { collectorDotClass } from '@/lib/collector-colors';
export type CollectorOption = { id: string; name: string; color: string };
/**
* "Für wen?" in the header: picks the child the whole app is looking at.
*
* Only rendered with more than one collector - with a single one the question
* does not exist. The choice is remembered per device; a `?sammler=` in the
* URL (a shared link) takes precedence and is shown as active.
*/
export function CollectorSwitch({
collectors,
storedId,
}: {
collectors: CollectorOption[];
storedId: string | null;
}) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [pending, startTransition] = useTransition();
const fromUrl = searchParams.get('sammler');
const current = fromUrl === 'alle' ? '' : (fromUrl ?? storedId ?? '');
function select(id: string) {
startTransition(async () => {
const result = await chooseCollectorAction({ id });
if (!result.ok) {
toast.error(result.error);
return;
}
// The header choice replaces a collector that came in via the URL.
if (fromUrl !== null) {
const params = new URLSearchParams(searchParams.toString());
params.delete('sammler');
params.delete('seite');
const query = params.toString();
router.replace(query ? `${pathname}?${query}` : pathname);
}
router.refresh();
});
}
const options = [{ id: '', name: 'Alle', color: '' }, ...collectors];
return (
<div role="radiogroup" aria-label="Für wen?" className="flex gap-2 overflow-x-auto pb-1">
{options.map((option) => {
const active = current === option.id;
return (
<button
key={option.id || 'alle'}
type="button"
role="radio"
aria-checked={active}
disabled={pending}
onClick={() => select(option.id)}
className={cn(
'flex h-10 shrink-0 items-center gap-2 rounded-[calc(var(--radius)*0.7)] border-[length:max(1px,var(--edge-w))] px-3 text-sm font-bold transition-colors',
active
? 'border-edge bg-card text-card-foreground shadow-(--lift-sm)'
: 'border-transparent text-muted-foreground hover:bg-muted',
)}
>
<span
aria-hidden
className={cn(
'flex size-6 items-center justify-center rounded-md border-[length:var(--edge-w)] border-edge text-xs font-bold text-white',
option.id ? collectorDotClass(option.color) : 'bg-secondary text-secondary-foreground',
)}
>
{option.id ? option.name.charAt(0).toUpperCase() : '★'}
</span>
{option.name}
</button>
);
})}
</div>
);
}
+115
View File
@@ -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');
}
+29
View File
@@ -0,0 +1,29 @@
'use client';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { Loader2, LogOut } from 'lucide-react';
import { clearOfflineCaches } from '@/components/layout/service-worker';
import { authClient } from '@/lib/auth-client';
import { Button } from '@/components/ui/button';
export function SignOutButton() {
const router = useRouter();
const [pending, setPending] = useState(false);
async function signOut() {
setPending(true);
await authClient.signOut();
// Cached pages must not stay readable on a shared device.
await clearOfflineCaches();
router.push('/anmelden');
router.refresh();
}
return (
<Button variant="outline" size="lg" onClick={() => void signOut()} disabled={pending}>
{pending ? <Loader2 className="animate-spin" /> : <LogOut />}
Abmelden
</Button>
);
}
-65
View File
@@ -1,65 +0,0 @@
'use client';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { LogOut, Settings, UserRound } from 'lucide-react';
import { clearOfflineCaches } from '@/components/layout/service-worker';
import { authClient } from '@/lib/auth-client';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import type { SessionUser } from '@/lib/session';
export function UserMenu({ user }: { user: SessionUser }) {
const router = useRouter();
async function signOut() {
await authClient.signOut();
// Cached pages must not stay readable on a shared device.
await clearOfflineCaches();
router.push('/anmelden');
router.refresh();
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" aria-label="Konto-Menü">
<UserRound />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel className="font-normal">
<span className="block text-sm font-medium">{user.name}</span>
<span className="block truncate text-xs text-muted-foreground">{user.email}</span>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href="/konto">
<UserRound />
Konto und Passkeys
</Link>
</DropdownMenuItem>
{user.role === 'admin' ? (
<DropdownMenuItem asChild>
<Link href="/verwaltung/hersteller">
<Settings />
Verwaltung
</Link>
</DropdownMenuItem>
) : null}
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => void signOut()}>
<LogOut />
Abmelden
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -0,0 +1,28 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { ChevronLeft } from 'lucide-react';
import { MORE_ITEMS } from '@/components/layout/more-items';
/** Title of a master-data page, with the way back to "Mehr". */
export function VerwaltungHeader() {
const pathname = usePathname();
const item = MORE_ITEMS.find((entry) => entry.href === pathname);
return (
<div className="space-y-2">
<Link
href="/mehr"
className="inline-flex items-center gap-1 text-sm font-semibold text-muted-foreground hover:text-foreground"
>
<ChevronLeft className="size-4" aria-hidden />
Mehr
</Link>
<div>
<h1 className="text-2xl font-semibold tracking-tight">{item?.label ?? 'Verwaltung'}</h1>
{item ? <p className="text-sm text-muted-foreground">{item.description}</p> : null}
</div>
</div>
);
}
@@ -1,43 +0,0 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { cn } from 'cn';
const tabs = [
{ href: '/verwaltung/sammler', label: 'Sammler', adminOnly: false },
{ href: '/verwaltung/hersteller', label: 'Hersteller', adminOnly: false },
{ href: '/verwaltung/themen', label: 'Themen', adminOnly: false },
{ href: '/verwaltung/standorte', label: 'Standorte', adminOnly: false },
{ href: '/verwaltung/einladungen', label: 'Einladungen', adminOnly: true },
{ href: '/verwaltung/erinnerungen', label: 'Erinnerungen', adminOnly: true },
{ href: '/verwaltung/daten', label: 'Daten', adminOnly: true },
] as const;
export function VerwaltungNav({ isAdmin }: { isAdmin: boolean }) {
const pathname = usePathname();
const visible = tabs.filter((tab) => !tab.adminOnly || isAdmin);
return (
<nav className="flex gap-1 overflow-x-auto rounded-lg bg-muted p-1" aria-label="Stammdaten">
{visible.map((tab) => {
const active = pathname === tab.href;
return (
<Link
key={tab.href}
href={tab.href}
aria-current={active ? 'page' : undefined}
className={cn(
'flex-1 whitespace-nowrap rounded-md px-3 py-2 text-center text-sm font-medium transition-colors',
active
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground',
)}
>
{tab.label}
</Link>
);
})}
</nav>
);
}
+24
View File
@@ -0,0 +1,24 @@
import 'server-only';
import { cookies } from 'next/headers';
import { resolveCollectorId } from '@/lib/queries/collectors';
/**
* Which collector the app is looking at.
*
* The switch in the header remembers the choice per device (cookie), so Max's
* tablet opens on Max. A `?sammler=` in the URL still wins - shared links and
* the back button keep working - and `?sammler=alle` shows everybody.
*/
export const COLLECTOR_COOKIE = 'bricktrack-sammler';
export const ALL_COLLECTORS = 'alle';
export async function storedCollectorId(): Promise<string | undefined> {
return resolveCollectorId((await cookies()).get(COLLECTOR_COOKIE)?.value);
}
export async function activeCollectorId(param: string | undefined): Promise<string | undefined> {
if (param === ALL_COLLECTORS) return undefined;
if (param) return resolveCollectorId(param);
return storedCollectorId();
}