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:
co-authored by
Claude Opus 5.5
parent
d578eac743
commit
d5ffcbae60
+16
-2
@@ -52,6 +52,17 @@ test('Screenshots fuer die Anleitung', async ({ page, browser }) => {
|
|||||||
await visit(page, '/');
|
await visit(page, '/');
|
||||||
await shoot(page, '02-start');
|
await shoot(page, '02-start');
|
||||||
|
|
||||||
|
// The plus in the middle of the navigation, opened.
|
||||||
|
await page.getByRole('button', { name: 'Neues Set' }).click();
|
||||||
|
await expect(page.getByText('Was kommt dazu?')).toBeVisible();
|
||||||
|
await page.waitForTimeout(400); // let the sheet finish sliding in
|
||||||
|
await page.screenshot({ path: shotPath('26-plus-menue') });
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
|
||||||
|
await visit(page, '/mehr');
|
||||||
|
await expect(page.getByText('Katalog pflegen')).toBeVisible();
|
||||||
|
await shoot(page, '27-mehr');
|
||||||
|
|
||||||
// --- 3. master data -------------------------------------------------------
|
// --- 3. master data -------------------------------------------------------
|
||||||
await visit(page, '/verwaltung/hersteller');
|
await visit(page, '/verwaltung/hersteller');
|
||||||
await expect(page.getByText('Lego', { exact: true })).toBeVisible();
|
await expect(page.getByText('Lego', { exact: true })).toBeVisible();
|
||||||
@@ -186,10 +197,13 @@ test('Screenshots fuer die Anleitung', async ({ page, browser }) => {
|
|||||||
|
|
||||||
// The switcher only exists because the seed created two collectors.
|
// The switcher only exists because the seed created two collectors.
|
||||||
await visit(page, '/katalog?besitz=fehlt');
|
await visit(page, '/katalog?besitz=fehlt');
|
||||||
await expect(page.getByRole('button', { name: /Max/ })).toBeVisible();
|
await expect(page.getByRole('radio', { name: /Max/ })).toBeVisible();
|
||||||
await page.getByRole('button', { name: /Max/ }).click();
|
await page.getByRole('radio', { name: /Max/ }).click();
|
||||||
await expect(page.getByText('Sets, die Max noch nicht hat')).toBeVisible();
|
await expect(page.getByText('Sets, die Max noch nicht hat')).toBeVisible();
|
||||||
await shoot(page, '22-sammler-filter');
|
await shoot(page, '22-sammler-filter');
|
||||||
|
// Back to everybody, so the following pictures show the whole family.
|
||||||
|
await page.getByRole('radio', { name: /Alle/ }).click();
|
||||||
|
await expect(page.getByText('Sets, die noch nicht in der Sammlung sind')).toBeVisible();
|
||||||
|
|
||||||
// --- 17. prices -----------------------------------------------------------
|
// --- 17. prices -----------------------------------------------------------
|
||||||
await visit(page, '/katalog?q=60398');
|
await visit(page, '/katalog?q=60398');
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { revalidatePath } from 'next/cache';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { env } from '@/env';
|
||||||
|
import { actionError, actionOk, type ActionResult } from '@/lib/action-result';
|
||||||
|
import { COLLECTOR_COOKIE } from '@/lib/collector-choice';
|
||||||
|
import { resolveCollectorId } from '@/lib/queries/collectors';
|
||||||
|
import { requireUser } from '@/lib/session';
|
||||||
|
|
||||||
|
const schema = z.object({ id: z.string().max(64) });
|
||||||
|
|
||||||
|
/** Remembers the collector chosen in the header; an empty id means "alle". */
|
||||||
|
export async function chooseCollectorAction(input: unknown): Promise<ActionResult> {
|
||||||
|
await requireUser();
|
||||||
|
|
||||||
|
const parsed = schema.safeParse(input);
|
||||||
|
if (!parsed.success) return actionError('Ungültige Auswahl.');
|
||||||
|
|
||||||
|
const store = await cookies();
|
||||||
|
if (parsed.data.id === '') {
|
||||||
|
store.delete(COLLECTOR_COOKIE);
|
||||||
|
} else {
|
||||||
|
const id = resolveCollectorId(parsed.data.id);
|
||||||
|
if (!id) return actionError('Diesen Sammler gibt es nicht mehr.');
|
||||||
|
store.set(COLLECTOR_COOKIE, id, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: env.isProduction,
|
||||||
|
sameSite: 'lax',
|
||||||
|
path: '/',
|
||||||
|
maxAge: 60 * 60 * 24 * 365,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath('/', 'layout');
|
||||||
|
return actionOk();
|
||||||
|
}
|
||||||
@@ -23,7 +23,8 @@ import {
|
|||||||
listPricePoints,
|
listPricePoints,
|
||||||
} from '@/lib/queries/catalog';
|
} from '@/lib/queries/catalog';
|
||||||
import { listMissingParts } from '@/lib/queries/collection';
|
import { listMissingParts } from '@/lib/queries/collection';
|
||||||
import { listCollectors, resolveCollectorId } from '@/lib/queries/collectors';
|
import { activeCollectorId } from '@/lib/collector-choice';
|
||||||
|
import { listCollectors } from '@/lib/queries/collectors';
|
||||||
import { listLocations } from '@/lib/queries/master-data';
|
import { listLocations } from '@/lib/queries/master-data';
|
||||||
import { listWishlistsForSet, listWishlistTargets } from '@/lib/queries/wishlists';
|
import { listWishlistsForSet, listWishlistTargets } from '@/lib/queries/wishlists';
|
||||||
import { requireUser } from '@/lib/session';
|
import { requireUser } from '@/lib/session';
|
||||||
@@ -47,7 +48,7 @@ export default async function SetDetailPage({ params, searchParams }: Props) {
|
|||||||
// Keeps the "vorhanden / fehlt" badge in step with the collector the user
|
// Keeps the "vorhanden / fehlt" badge in step with the collector the user
|
||||||
// was looking at in the catalog.
|
// was looking at in the catalog.
|
||||||
const rawCollector = Array.isArray(query.sammler) ? query.sammler[0] : query.sammler;
|
const rawCollector = Array.isArray(query.sammler) ? query.sammler[0] : query.sammler;
|
||||||
const collectorId = resolveCollectorId(rawCollector);
|
const collectorId = await activeCollectorId(rawCollector);
|
||||||
const collectors = listCollectors();
|
const collectors = listCollectors();
|
||||||
|
|
||||||
const set = getSetById(id, collectorId);
|
const set = getSetById(id, collectorId);
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export default async function NeuesSetPage({ searchParams }: { searchParams: Sea
|
|||||||
await requireUser();
|
await requireUser();
|
||||||
const params = await searchParams;
|
const params = await searchParams;
|
||||||
const ean = typeof params.ean === 'string' ? params.ean : undefined;
|
const ean = typeof params.ean === 'string' ? params.ean : undefined;
|
||||||
|
// Which way the plus menu chose: the matching field gets the cursor.
|
||||||
|
const way = typeof params.weg === 'string' ? params.weg : undefined;
|
||||||
|
|
||||||
const manufacturers = listManufacturers().map((row) => ({ id: row.id, name: row.name }));
|
const manufacturers = listManufacturers().map((row) => ({ id: row.id, name: row.name }));
|
||||||
const themes = listThemes().map((row) => ({
|
const themes = listThemes().map((row) => ({
|
||||||
@@ -38,9 +40,14 @@ export default async function NeuesSetPage({ searchParams }: { searchParams: Sea
|
|||||||
</div>
|
</div>
|
||||||
{/* The Lego import creates its manufacturer itself, so it works even
|
{/* The Lego import creates its manufacturer itself, so it works even
|
||||||
on a completely empty instance. */}
|
on a completely empty instance. */}
|
||||||
<RebrickableImport enabled={importEnabled} />
|
<RebrickableImport enabled={importEnabled} autoFocus={way === 'lego'} />
|
||||||
|
|
||||||
<NewSetEntry manufacturers={manufacturers} themes={themes} defaultEan={ean}>
|
<NewSetEntry
|
||||||
|
manufacturers={manufacturers}
|
||||||
|
themes={themes}
|
||||||
|
defaultEan={ean}
|
||||||
|
focus={way === 'link' || way === 'hand' ? way : undefined}
|
||||||
|
>
|
||||||
<p className="rounded-lg border border-dashed border-border p-6 text-sm text-muted-foreground">
|
<p className="rounded-lg border border-dashed border-border p-6 text-sm text-muted-foreground">
|
||||||
Für ein Set von Hand brauchst du zuerst einen Hersteller – Lego, Playmobil, Cobi oder
|
Für ein Set von Hand brauchst du zuerst einen Hersteller – Lego, Playmobil, Cobi oder
|
||||||
was auch immer im Kinderzimmer steht.
|
was auch immer im Kinderzimmer steht.
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Plus } from 'lucide-react';
|
import { Plus } from 'lucide-react';
|
||||||
import { CollectorFilter } from '@/components/layout/collector-filter';
|
|
||||||
import { BarcodeScannerButton } from '@/components/katalog/barcode-scanner';
|
import { BarcodeScannerButton } from '@/components/katalog/barcode-scanner';
|
||||||
import { CatalogFilters } from '@/components/katalog/catalog-filters';
|
import { CatalogFilters } from '@/components/katalog/catalog-filters';
|
||||||
import { CatalogGrid } from '@/components/katalog/catalog-grid';
|
import { CatalogGrid } from '@/components/katalog/catalog-grid';
|
||||||
@@ -10,7 +9,8 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { pluralize } from '@/lib/format';
|
import { pluralize } from '@/lib/format';
|
||||||
import { OWNERSHIP_FILTER_LABELS, parseOwnershipFilter } from '@/lib/ownership';
|
import { OWNERSHIP_FILTER_LABELS, parseOwnershipFilter } from '@/lib/ownership';
|
||||||
import { CATALOG_PAGE_SIZE, listSets, listSetYears } from '@/lib/queries/catalog';
|
import { CATALOG_PAGE_SIZE, listSets, listSetYears } from '@/lib/queries/catalog';
|
||||||
import { listCollectors, resolveCollectorId } from '@/lib/queries/collectors';
|
import { activeCollectorId } from '@/lib/collector-choice';
|
||||||
|
import { listCollectors } from '@/lib/queries/collectors';
|
||||||
import { listManufacturers, listThemes } from '@/lib/queries/master-data';
|
import { listManufacturers, listThemes } from '@/lib/queries/master-data';
|
||||||
import { listWishlistTargets } from '@/lib/queries/wishlists';
|
import { listWishlistTargets } from '@/lib/queries/wishlists';
|
||||||
import { requireUser } from '@/lib/session';
|
import { requireUser } from '@/lib/session';
|
||||||
@@ -30,7 +30,7 @@ export default async function KatalogPage({ searchParams }: { searchParams: Sear
|
|||||||
|
|
||||||
const ownership = parseOwnershipFilter(single(params.besitz));
|
const ownership = parseOwnershipFilter(single(params.besitz));
|
||||||
const collectors = listCollectors();
|
const collectors = listCollectors();
|
||||||
const collectorId = resolveCollectorId(single(params.sammler));
|
const collectorId = await activeCollectorId(single(params.sammler));
|
||||||
const activeCollector = collectors.find((row) => row.id === collectorId) ?? null;
|
const activeCollector = collectors.find((row) => row.id === collectorId) ?? null;
|
||||||
const yearParam = single(params.jahr);
|
const yearParam = single(params.jahr);
|
||||||
const page = Number(single(params.seite) ?? '1') || 1;
|
const page = Number(single(params.seite) ?? '1') || 1;
|
||||||
@@ -80,11 +80,6 @@ export default async function KatalogPage({ searchParams }: { searchParams: Sear
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CollectorFilter
|
|
||||||
collectors={collectors.map((row) => ({ id: row.id, name: row.name, color: row.color }))}
|
|
||||||
allLabel="Alle zusammen"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<CatalogFilters options={{ manufacturers, themes, years: listSetYears() }} />
|
<CatalogFilters options={{ manufacturers, themes, years: listSetYears() }} />
|
||||||
|
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { AppHeader } from '@/components/layout/app-header';
|
import { AppHeader } from '@/components/layout/app-header';
|
||||||
import { BottomNav } from '@/components/layout/bottom-nav';
|
import { BottomNav } from '@/components/layout/bottom-nav';
|
||||||
|
import { storedCollectorId } from '@/lib/collector-choice';
|
||||||
|
import { listCollectors } from '@/lib/queries/collectors';
|
||||||
|
import { isRebrickableEnabled } from '@/lib/rebrickable';
|
||||||
import { ensureReminderScheduler } from '@/lib/reminder-job';
|
import { ensureReminderScheduler } from '@/lib/reminder-job';
|
||||||
import { requireUser } from '@/lib/session';
|
import { requireUser } from '@/lib/session';
|
||||||
|
|
||||||
@@ -9,13 +12,24 @@ export default async function AppLayout({ children }: { children: React.ReactNod
|
|||||||
ensureReminderScheduler();
|
ensureReminderScheduler();
|
||||||
|
|
||||||
const user = await requireUser();
|
const user = await requireUser();
|
||||||
|
const collectors = listCollectors().map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
color: row.color,
|
||||||
|
}));
|
||||||
|
const legoImport = isRebrickableEnabled();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-dvh flex-col">
|
<div className="flex min-h-dvh flex-col">
|
||||||
<AppHeader user={user} />
|
<AppHeader
|
||||||
|
user={user}
|
||||||
|
collectors={collectors}
|
||||||
|
storedCollectorId={(await storedCollectorId()) ?? null}
|
||||||
|
legoImport={legoImport}
|
||||||
|
/>
|
||||||
{/* Bottom padding keeps the mobile nav from covering the last row. */}
|
{/* Bottom padding keeps the mobile nav from covering the last row. */}
|
||||||
<main className="mx-auto w-full max-w-5xl flex-1 px-4 pt-4 pb-24 md:pb-10">{children}</main>
|
<main className="mx-auto w-full max-w-5xl flex-1 px-4 pt-4 pb-28 md:pb-10">{children}</main>
|
||||||
<BottomNav />
|
<BottomNav legoImport={legoImport} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { cn } from 'cn';
|
||||||
|
import { Studs } from '@/components/brick/studs';
|
||||||
|
import { MORE_GROUPS, moreItemsFor } from '@/components/layout/more-items';
|
||||||
|
import { SignOutButton } from '@/components/layout/sign-out-button';
|
||||||
|
import { requireUser } from '@/lib/session';
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: 'Mehr' };
|
||||||
|
|
||||||
|
const GROUP_TONES: Record<string, string> = {
|
||||||
|
ich: 'bg-tone-1',
|
||||||
|
familie: 'bg-tone-2',
|
||||||
|
katalog: 'bg-tone-3',
|
||||||
|
daten: 'bg-tone-4',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function MehrPage() {
|
||||||
|
const user = await requireUser();
|
||||||
|
const items = moreItemsFor(user.role);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-7">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">{user.name}</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{user.email} · {user.role === 'admin' ? 'Administrator' : 'Mitglied'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{MORE_GROUPS.map((group) => {
|
||||||
|
const entries = items.filter((item) => item.group === group.id);
|
||||||
|
if (entries.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<section key={group.id} className="space-y-4">
|
||||||
|
<h2 className="text-xs font-bold tracking-widest text-muted-foreground uppercase">
|
||||||
|
{group.label}
|
||||||
|
</h2>
|
||||||
|
<ul className="grid grid-cols-2 gap-x-3 gap-y-5 sm:grid-cols-3">
|
||||||
|
{entries.map((item) => (
|
||||||
|
<li key={item.href} className="relative">
|
||||||
|
<Studs studClassName={GROUP_TONES[group.id]} />
|
||||||
|
<Link
|
||||||
|
href={item.href}
|
||||||
|
className={cn(
|
||||||
|
'brick flex h-full flex-col gap-1 rounded-xl p-3.5 text-tone-ink outline-none focus-visible:ring-3 focus-visible:ring-ring/50',
|
||||||
|
GROUP_TONES[group.id],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<item.icon className="size-6" aria-hidden />
|
||||||
|
<span className="font-bold">{item.label}</span>
|
||||||
|
<span className="text-xs leading-snug opacity-80">{item.description}</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<SignOutButton />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+21
-9
@@ -4,6 +4,7 @@ import { cn } from 'cn';
|
|||||||
import { Studs } from '@/components/brick/studs';
|
import { Studs } from '@/components/brick/studs';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { brickRow } from '@/lib/brick-row';
|
import { brickRow } from '@/lib/brick-row';
|
||||||
|
import { activeCollectorId } from '@/lib/collector-choice';
|
||||||
import { collectorDotClass } from '@/lib/collector-colors';
|
import { collectorDotClass } from '@/lib/collector-colors';
|
||||||
import { formatDate, formatNumber, pluralize } from '@/lib/format';
|
import { formatDate, formatNumber, pluralize } from '@/lib/format';
|
||||||
import { formatEuro } from '@/lib/money';
|
import { formatEuro } from '@/lib/money';
|
||||||
@@ -19,22 +20,31 @@ function daysLabel(days: number): string {
|
|||||||
return `in ${days} Tagen`;
|
return `in ${days} Tagen`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function DashboardPage() {
|
type SearchParams = Promise<Record<string, string | string[] | undefined>>;
|
||||||
const user = await requireUser();
|
|
||||||
|
|
||||||
const stats = getCollectionStats();
|
export default async function DashboardPage({ searchParams }: { searchParams: SearchParams }) {
|
||||||
const missing = countMissingSets();
|
const user = await requireUser();
|
||||||
|
const params = await searchParams;
|
||||||
|
const rawCollector = Array.isArray(params.sammler) ? params.sammler[0] : params.sammler;
|
||||||
|
|
||||||
|
// With a child chosen in the header, the start page is about that child.
|
||||||
|
const collectorId = await activeCollectorId(rawCollector);
|
||||||
|
const stats = getCollectionStats(collectorId);
|
||||||
|
const missing = countMissingSets(collectorId);
|
||||||
const totalSets = stats.setCount + missing;
|
const totalSets = stats.setCount + missing;
|
||||||
|
|
||||||
const collectors = listCollectors();
|
const collectors = listCollectors();
|
||||||
|
const activeCollector = collectors.find((entry) => entry.id === collectorId) ?? null;
|
||||||
const perCollector = countItemsPerCollector();
|
const perCollector = countItemsPerCollector();
|
||||||
const showCollectors = collectors.length > 1;
|
const showCollectors = collectors.length > 1 && !activeCollector;
|
||||||
const bricks = brickRow(
|
const bricks = brickRow(
|
||||||
stats.setCount,
|
stats.setCount,
|
||||||
totalSets,
|
totalSets,
|
||||||
showCollectors
|
activeCollector
|
||||||
? collectors.map((entry) => ({ key: entry.id, count: perCollector.get(entry.id) ?? 0 }))
|
? [{ 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]));
|
const colorById = new Map(collectors.map((entry) => [entry.id, entry.color]));
|
||||||
|
|
||||||
@@ -53,7 +63,9 @@ export default async function DashboardPage() {
|
|||||||
<Studs count={3} />
|
<Studs count={3} />
|
||||||
<div className="brick rounded-xl bg-card p-4 text-card-foreground">
|
<div className="brick rounded-xl bg-card p-4 text-card-foreground">
|
||||||
<div className="flex items-baseline justify-between gap-3">
|
<div className="flex items-baseline justify-between gap-3">
|
||||||
<h2 className="text-lg font-semibold">Unsere Sammlung</h2>
|
<h2 className="text-lg font-semibold">
|
||||||
|
{activeCollector ? `Sammlung von ${activeCollector.name}` : 'Unsere Sammlung'}
|
||||||
|
</h2>
|
||||||
<p className="text-sm font-semibold text-muted-foreground">
|
<p className="text-sm font-semibold text-muted-foreground">
|
||||||
{formatNumber(stats.setCount)} von {pluralize(totalSets, 'Set', 'Sets')}
|
{formatNumber(stats.setCount)} von {pluralize(totalSets, 'Set', 'Sets')}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import Link from 'next/link';
|
|||||||
import { Boxes, Euro, Layers, PackageSearch } from 'lucide-react';
|
import { Boxes, Euro, Layers, PackageSearch } from 'lucide-react';
|
||||||
import { SetPlaceholder } from '@/components/brick/set-placeholder';
|
import { SetPlaceholder } from '@/components/brick/set-placeholder';
|
||||||
import { Studs } from '@/components/brick/studs';
|
import { Studs } from '@/components/brick/studs';
|
||||||
import { CollectorFilter } from '@/components/layout/collector-filter';
|
|
||||||
import { CollectionFilters } from '@/components/sammlung/collection-filters';
|
import { CollectionFilters } from '@/components/sammlung/collection-filters';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
@@ -19,7 +18,8 @@ import {
|
|||||||
listCollectionItems,
|
listCollectionItems,
|
||||||
type CollectionFilters as Filters,
|
type CollectionFilters as Filters,
|
||||||
} from '@/lib/queries/collection';
|
} from '@/lib/queries/collection';
|
||||||
import { listCollectors, resolveCollectorId } from '@/lib/queries/collectors';
|
import { activeCollectorId } from '@/lib/collector-choice';
|
||||||
|
import { listCollectors } from '@/lib/queries/collectors';
|
||||||
import { listLocations, listManufacturers } from '@/lib/queries/master-data';
|
import { listLocations, listManufacturers } from '@/lib/queries/master-data';
|
||||||
import { requireUser } from '@/lib/session';
|
import { requireUser } from '@/lib/session';
|
||||||
import type { CollectionStatus, Completeness } from '@/db/types';
|
import type { CollectionStatus, Completeness } from '@/db/types';
|
||||||
@@ -38,7 +38,7 @@ export default async function SammlungPage({ searchParams }: { searchParams: Sea
|
|||||||
const params = await searchParams;
|
const params = await searchParams;
|
||||||
|
|
||||||
const collectors = listCollectors();
|
const collectors = listCollectors();
|
||||||
const collectorId = resolveCollectorId(single(params.sammler));
|
const collectorId = await activeCollectorId(single(params.sammler));
|
||||||
const activeCollector = collectors.find((row) => row.id === collectorId) ?? null;
|
const activeCollector = collectors.find((row) => row.id === collectorId) ?? null;
|
||||||
|
|
||||||
const filters: Filters = {
|
const filters: Filters = {
|
||||||
@@ -78,11 +78,6 @@ export default async function SammlungPage({ searchParams }: { searchParams: Sea
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CollectorFilter
|
|
||||||
collectors={collectors.map((row) => ({ id: row.id, name: row.name, color: row.color }))}
|
|
||||||
allLabel="Alle zusammen"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
{tiles.map((tile) => (
|
{tiles.map((tile) => (
|
||||||
<Card key={tile.label}>
|
<Card key={tile.label}>
|
||||||
|
|||||||
@@ -1,21 +1,15 @@
|
|||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import { VerwaltungNav } from '@/components/verwaltung/verwaltung-nav';
|
import { VerwaltungHeader } from '@/components/verwaltung/verwaltung-header';
|
||||||
import { requireUser } from '@/lib/session';
|
import { requireUser } from '@/lib/session';
|
||||||
|
|
||||||
export const metadata: Metadata = { title: 'Verwaltung' };
|
export const metadata: Metadata = { title: 'Verwaltung' };
|
||||||
|
|
||||||
export default async function VerwaltungLayout({ children }: { children: React.ReactNode }) {
|
export default async function VerwaltungLayout({ children }: { children: React.ReactNode }) {
|
||||||
const user = await requireUser();
|
await requireUser();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<VerwaltungHeader />
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Verwaltung</h1>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Stammdaten, die im Katalog und in der Sammlung ausgewählt werden.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<VerwaltungNav isAdmin={user.role === 'admin'} />
|
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -25,13 +25,18 @@ type Controls = { stop: () => void };
|
|||||||
* loaded only when the scanner is opened - nothing leaves the device, and the
|
* loaded only when the scanner is opened - nothing leaves the device, and the
|
||||||
* catalog pages stay light for everyone who never scans.
|
* catalog pages stay light for everyone who never scans.
|
||||||
*/
|
*/
|
||||||
export function BarcodeScannerButton() {
|
export function BarcodeScannerDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange: setOpen,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
const controlsRef = useRef<Controls | null>(null);
|
const controlsRef = useRef<Controls | null>(null);
|
||||||
const handledRef = useRef(false);
|
const handledRef = useRef(false);
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [status, setStatus] = useState<'starting' | 'scanning' | 'checking' | 'error'>('starting');
|
const [status, setStatus] = useState<'starting' | 'scanning' | 'checking' | 'error'>('starting');
|
||||||
const [message, setMessage] = useState<string | null>(null);
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -68,7 +73,7 @@ export function BarcodeScannerButton() {
|
|||||||
router.refresh();
|
router.refresh();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[router, stop],
|
[router, setOpen, stop],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -134,6 +139,61 @@ export function BarcodeScannerButton() {
|
|||||||
};
|
};
|
||||||
}, [open, onDetected, stop]);
|
}, [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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
@@ -145,55 +205,7 @@ export function BarcodeScannerButton() {
|
|||||||
>
|
>
|
||||||
<ScanLine />
|
<ScanLine />
|
||||||
</Button>
|
</Button>
|
||||||
|
<BarcodeScannerDialog open={open} onOpenChange={setOpen} />
|
||||||
<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>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useState, useTransition } from 'react';
|
import { useEffect, useState, useTransition } from 'react';
|
||||||
import { Link2, Loader2 } from 'lucide-react';
|
import { Link2, Loader2 } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { previewSetFromLink, type LinkPreview } from '@/app/(app)/katalog/link-actions';
|
import { previewSetFromLink, type LinkPreview } from '@/app/(app)/katalog/link-actions';
|
||||||
@@ -35,11 +35,14 @@ export function NewSetEntry({
|
|||||||
manufacturers,
|
manufacturers,
|
||||||
themes,
|
themes,
|
||||||
defaultEan,
|
defaultEan,
|
||||||
|
focus,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
manufacturers: Array<{ id: string; name: string }>;
|
manufacturers: Array<{ id: string; name: string }>;
|
||||||
themes: Array<{ id: string; name: string; manufacturerId: string }>;
|
themes: Array<{ id: string; name: string; manufacturerId: string }>;
|
||||||
defaultEan?: string;
|
defaultEan?: string;
|
||||||
|
/** Set by the plus menu: start in the link field or in the form. */
|
||||||
|
focus?: 'link' | 'hand';
|
||||||
/** Shown instead of the form while there is no manufacturer yet. */
|
/** Shown instead of the form while there is no manufacturer yet. */
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
@@ -50,6 +53,13 @@ export function NewSetEntry({
|
|||||||
const [existing, setExisting] = useState<{ id: string; label: string } | null>(null);
|
const [existing, setExisting] = useState<{ id: string; label: string } | null>(null);
|
||||||
const [pending, startTransition] = useTransition();
|
const [pending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (focus !== 'hand') return;
|
||||||
|
const field = document.getElementById('setNumber');
|
||||||
|
field?.scrollIntoView({ block: 'center' });
|
||||||
|
field?.focus({ preventScroll: true });
|
||||||
|
}, [focus]);
|
||||||
|
|
||||||
function submit(event: React.FormEvent<HTMLFormElement>) {
|
function submit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setExisting(null);
|
setExisting(null);
|
||||||
@@ -114,6 +124,7 @@ export function NewSetEntry({
|
|||||||
onChange={(event) => setUrl(event.target.value)}
|
onChange={(event) => setUrl(event.target.value)}
|
||||||
placeholder="https://…"
|
placeholder="https://…"
|
||||||
maxLength={2000}
|
maxLength={2000}
|
||||||
|
autoFocus={focus === 'link'}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Button type="submit" size="lg" disabled={pending || url.trim().length === 0}>
|
<Button type="submit" size="lg" disabled={pending || url.trim().length === 0}>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { Input } from '@/components/ui/input';
|
|||||||
* One-field Lego import: type the number from the box, get a finished catalog
|
* One-field Lego import: type the number from the box, get a finished catalog
|
||||||
* entry. Hidden entirely when no API key is configured.
|
* entry. Hidden entirely when no API key is configured.
|
||||||
*/
|
*/
|
||||||
export function RebrickableImport({ enabled }: { enabled: boolean }) {
|
export function RebrickableImport({ enabled, autoFocus }: { enabled: boolean; autoFocus?: boolean }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [setNumber, setSetNumber] = useState('');
|
const [setNumber, setSetNumber] = useState('');
|
||||||
const [pending, startTransition] = useTransition();
|
const [pending, startTransition] = useTransition();
|
||||||
@@ -80,6 +80,7 @@ export function RebrickableImport({ enabled }: { enabled: boolean }) {
|
|||||||
onChange={(event) => setSetNumber(event.target.value)}
|
onChange={(event) => setSetNumber(event.target.value)}
|
||||||
placeholder="60398"
|
placeholder="60398"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
|
autoFocus={autoFocus}
|
||||||
maxLength={40}
|
maxLength={40}
|
||||||
className="font-mono"
|
className="font-mono"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Hash, Link2, PencilLine, Plus, ScanLine, type LucideIcon } from 'lucide-react';
|
||||||
|
import { cn } from 'cn';
|
||||||
|
import { Studs } from '@/components/brick/studs';
|
||||||
|
import { BarcodeScannerDialog } from '@/components/katalog/barcode-scanner';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetDescription,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
SheetTrigger,
|
||||||
|
} from '@/components/ui/sheet';
|
||||||
|
|
||||||
|
type Action = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
hint: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
tone: string;
|
||||||
|
href?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The plus in the middle of the navigation: every way a set gets into the
|
||||||
|
* catalog, in one place. Opens as a sheet from the bottom.
|
||||||
|
*/
|
||||||
|
export function AddMenu({
|
||||||
|
legoImport,
|
||||||
|
variant = 'nav',
|
||||||
|
}: {
|
||||||
|
/** Rebrickable is configured. */
|
||||||
|
legoImport: boolean;
|
||||||
|
variant?: 'nav' | 'button';
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [scanning, setScanning] = useState(false);
|
||||||
|
|
||||||
|
const actions: Action[] = [
|
||||||
|
{ id: 'scan', label: 'Scannen', hint: 'Strichcode der Packung', icon: ScanLine, tone: 'bg-tone-1' },
|
||||||
|
{
|
||||||
|
id: 'link',
|
||||||
|
label: 'Aus Link',
|
||||||
|
hint: 'Amazon, idealo, Shop',
|
||||||
|
icon: Link2,
|
||||||
|
tone: 'bg-tone-2',
|
||||||
|
href: '/katalog/neu?weg=link',
|
||||||
|
},
|
||||||
|
...(legoImport
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: 'lego',
|
||||||
|
label: 'Lego-Nummer',
|
||||||
|
hint: 'über Rebrickable',
|
||||||
|
icon: Hash,
|
||||||
|
tone: 'bg-tone-3',
|
||||||
|
href: '/katalog/neu?weg=lego',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
{
|
||||||
|
id: 'hand',
|
||||||
|
label: 'Von Hand',
|
||||||
|
hint: 'alles selbst eintragen',
|
||||||
|
icon: PencilLine,
|
||||||
|
tone: 'bg-card',
|
||||||
|
href: '/katalog/neu?weg=hand',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function run(action: Action) {
|
||||||
|
setOpen(false);
|
||||||
|
if (action.id === 'scan') {
|
||||||
|
setScanning(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action.href) router.push(action.href);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Sheet open={open} onOpenChange={setOpen}>
|
||||||
|
<SheetTrigger asChild>
|
||||||
|
{variant === 'nav' ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Neues Set"
|
||||||
|
className="relative -mt-7 flex h-14 w-16 items-center justify-center rounded-[calc(var(--radius)*0.9)] border-[length:var(--edge-w)] border-edge bg-primary text-primary-foreground shadow-(--lift) outline-none focus-visible:ring-3 focus-visible:ring-ring/50 active:translate-y-0.5"
|
||||||
|
>
|
||||||
|
<Studs count={2} className="left-2.5 gap-3 [&>.stud]:w-3.5 [&>.stud]:bg-primary" />
|
||||||
|
<Plus className={cn('size-7 transition-transform', open && 'rotate-45')} strokeWidth={3} />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<Button size="lg">
|
||||||
|
<Plus />
|
||||||
|
Neu
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</SheetTrigger>
|
||||||
|
<SheetContent
|
||||||
|
side="bottom"
|
||||||
|
className="rounded-t-[calc(var(--radius)*1.6)] border-t-[length:max(1px,var(--edge-w))] border-edge bg-background pb-[calc(env(safe-area-inset-bottom)+1.5rem)]"
|
||||||
|
>
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle className="text-xl">Was kommt dazu?</SheetTitle>
|
||||||
|
<SheetDescription>Ein Set in den Katalog bringen – auf dem schnellsten Weg.</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
<div className="mx-auto grid w-full max-w-lg grid-cols-2 gap-x-3 gap-y-5 px-4 pt-2">
|
||||||
|
{actions.map((action) => (
|
||||||
|
<button
|
||||||
|
key={action.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => run(action)}
|
||||||
|
className="relative text-left outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||||
|
>
|
||||||
|
<Studs studClassName={action.tone} />
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'brick flex h-full flex-col gap-1 rounded-xl p-3.5 text-foreground',
|
||||||
|
action.tone,
|
||||||
|
action.tone !== 'bg-card' && 'text-tone-ink',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<action.icon className="size-6" aria-hidden />
|
||||||
|
<span className="text-base font-bold">{action.label}</span>
|
||||||
|
<span className="text-xs font-semibold opacity-80">{action.hint}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
|
||||||
|
<BarcodeScannerDialog open={scanning} onOpenChange={setScanning} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,27 @@
|
|||||||
|
import { Suspense } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { BrickLogo } from '@/components/brick/logo';
|
import { BrickLogo } from '@/components/brick/logo';
|
||||||
import { DesktopNav } from './bottom-nav';
|
import { DesktopNav } from './bottom-nav';
|
||||||
import { UserMenu } from './user-menu';
|
import { CollectorSwitch, type CollectorOption } from './collector-switch';
|
||||||
import type { SessionUser } from '@/lib/session';
|
import type { SessionUser } from '@/lib/session';
|
||||||
|
|
||||||
export function AppHeader({ user }: { user: SessionUser }) {
|
function initials(name: string): string {
|
||||||
|
const parts = name.trim().split(/\s+/);
|
||||||
|
const letters = (parts[0]?.[0] ?? '') + (parts.length > 1 ? (parts.at(-1)?.[0] ?? '') : '');
|
||||||
|
return letters.toUpperCase() || '?';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppHeader({
|
||||||
|
user,
|
||||||
|
collectors,
|
||||||
|
storedCollectorId,
|
||||||
|
legoImport,
|
||||||
|
}: {
|
||||||
|
user: SessionUser;
|
||||||
|
collectors: CollectorOption[];
|
||||||
|
storedCollectorId: string | null;
|
||||||
|
legoImport: boolean;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-40 border-b border-border bg-background/95 backdrop-blur">
|
<header className="sticky top-0 z-40 border-b border-border bg-background/95 backdrop-blur">
|
||||||
<div className="mx-auto flex h-14 max-w-5xl items-center gap-3 px-4">
|
<div className="mx-auto flex h-14 max-w-5xl items-center gap-3 px-4">
|
||||||
@@ -16,9 +33,26 @@ export function AppHeader({ user }: { user: SessionUser }) {
|
|||||||
<span>Bricktrack</span>
|
<span>Bricktrack</span>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<DesktopNav />
|
<DesktopNav legoImport={legoImport} />
|
||||||
<UserMenu user={user} />
|
{/* Account, design and master data: everything that is not daily use. */}
|
||||||
|
<Link
|
||||||
|
href="/mehr"
|
||||||
|
aria-label="Mehr: Konto, Design und Verwaltung"
|
||||||
|
title="Mehr"
|
||||||
|
className="flex size-10 items-center justify-center rounded-[calc(var(--radius)*0.7)] border-[length:max(1px,var(--edge-w))] border-edge bg-card text-sm font-bold shadow-(--lift-sm) outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||||
|
>
|
||||||
|
{initials(user.name)}
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{collectors.length > 1 ? (
|
||||||
|
<div className="mx-auto max-w-5xl px-4 pb-2">
|
||||||
|
{/* useSearchParams needs a boundary; the switch reads ?sammler=. */}
|
||||||
|
<Suspense fallback={<div className="h-11" />}>
|
||||||
|
<CollectorSwitch collectors={collectors} storedId={storedCollectorId} />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,17 +2,52 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { Boxes, Gift, LayoutGrid, User } from 'lucide-react';
|
import { Boxes, Gift, House, LayoutGrid } from 'lucide-react';
|
||||||
import { cn } from 'cn';
|
import { cn } from 'cn';
|
||||||
|
import { AddMenu } from './add-menu';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Four places and the plus in the middle. Account, design and master data
|
||||||
|
* sit behind "Mehr" in the header - they are not part of the daily routine.
|
||||||
|
*/
|
||||||
const items = [
|
const items = [
|
||||||
|
{ href: '/', label: 'Start', icon: House },
|
||||||
{ href: '/katalog', label: 'Katalog', icon: LayoutGrid },
|
{ href: '/katalog', label: 'Katalog', icon: LayoutGrid },
|
||||||
{ href: '/sammlung', label: 'Sammlung', icon: Boxes },
|
{ href: '/sammlung', label: 'Sammlung', icon: Boxes },
|
||||||
{ href: '/wunschlisten', label: 'Wünsche', icon: Gift },
|
{ href: '/wunschlisten', label: 'Wünsche', icon: Gift },
|
||||||
{ href: '/konto', label: 'Konto', icon: User },
|
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export function BottomNav() {
|
function isActive(pathname: string, href: string) {
|
||||||
|
if (href === '/') return pathname === '/';
|
||||||
|
return pathname === href || pathname.startsWith(`${href}/`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavLink({ item, pathname }: { item: (typeof items)[number]; pathname: string }) {
|
||||||
|
const active = isActive(pathname, item.href);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={item.href}
|
||||||
|
aria-current={active ? 'page' : undefined}
|
||||||
|
className={cn(
|
||||||
|
'flex h-16 flex-col items-center justify-center gap-1 text-xs transition-colors',
|
||||||
|
active ? 'font-bold text-foreground' : 'font-medium text-muted-foreground hover:text-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* The active tab sits on a small plate, like a tile on a baseplate. */}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'flex h-7 w-12 items-center justify-center rounded-[calc(var(--radius)*0.6)] border-[length:var(--edge-w)]',
|
||||||
|
active ? 'border-edge bg-secondary text-secondary-foreground' : 'border-transparent',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<item.icon className="size-5" aria-hidden />
|
||||||
|
</span>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BottomNav({ legoImport }: { legoImport: boolean }) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -20,49 +55,32 @@ export function BottomNav() {
|
|||||||
aria-label="Hauptnavigation"
|
aria-label="Hauptnavigation"
|
||||||
className="fixed inset-x-0 bottom-0 z-40 border-t-[length:max(1px,var(--edge-w))] border-border bg-card/95 pb-[env(safe-area-inset-bottom)] backdrop-blur md:hidden"
|
className="fixed inset-x-0 bottom-0 z-40 border-t-[length:max(1px,var(--edge-w))] border-border bg-card/95 pb-[env(safe-area-inset-bottom)] backdrop-blur md:hidden"
|
||||||
>
|
>
|
||||||
<ul className="mx-auto flex max-w-lg">
|
<ul className="mx-auto grid max-w-lg grid-cols-5 items-center">
|
||||||
{items.map((item) => {
|
{items.slice(0, 2).map((item) => (
|
||||||
const active = pathname === item.href || pathname.startsWith(`${item.href}/`);
|
<li key={item.href}>
|
||||||
return (
|
<NavLink item={item} pathname={pathname} />
|
||||||
<li key={item.href} className="flex-1">
|
</li>
|
||||||
<Link
|
))}
|
||||||
href={item.href}
|
<li className="flex justify-center">
|
||||||
aria-current={active ? 'page' : undefined}
|
<AddMenu legoImport={legoImport} />
|
||||||
className={cn(
|
</li>
|
||||||
'flex h-16 flex-col items-center justify-center gap-1 text-xs transition-colors',
|
{items.slice(2).map((item) => (
|
||||||
active
|
<li key={item.href}>
|
||||||
? 'font-bold text-foreground'
|
<NavLink item={item} pathname={pathname} />
|
||||||
: 'font-medium text-muted-foreground hover:text-foreground',
|
</li>
|
||||||
)}
|
))}
|
||||||
>
|
|
||||||
{/* 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>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DesktopNav() {
|
export function DesktopNav({ legoImport }: { legoImport: boolean }) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav aria-label="Hauptnavigation" className="hidden items-center gap-1 md:flex">
|
<nav aria-label="Hauptnavigation" className="hidden items-center gap-1 md:flex">
|
||||||
{items.map((item) => {
|
{items.map((item) => {
|
||||||
const active = pathname === item.href || pathname.startsWith(`${item.href}/`);
|
const active = isActive(pathname, item.href);
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={item.href}
|
key={item.href}
|
||||||
@@ -80,6 +98,9 @@ export function DesktopNav() {
|
|||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
<span className="ml-2">
|
||||||
|
<AddMenu legoImport={legoImport} variant="button" />
|
||||||
|
</span>
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
|
||||||
import { useTransition } from 'react';
|
|
||||||
import { cn } from 'cn';
|
|
||||||
import { collectorDotClass } from '@/lib/collector-colors';
|
|
||||||
|
|
||||||
export type CollectorOption = { id: string; name: string; color: string };
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Switches the view between collectors.
|
|
||||||
*
|
|
||||||
* Rendered only when there is more than one - with a single collector the
|
|
||||||
* whole question does not exist, and the app should not ask it. The choice
|
|
||||||
* lives in the URL (`?sammler=`), so a filtered view stays shareable and the
|
|
||||||
* back button behaves.
|
|
||||||
*/
|
|
||||||
export function CollectorFilter({
|
|
||||||
collectors,
|
|
||||||
allLabel = 'Alle',
|
|
||||||
}: {
|
|
||||||
collectors: CollectorOption[];
|
|
||||||
allLabel?: string;
|
|
||||||
}) {
|
|
||||||
const router = useRouter();
|
|
||||||
const pathname = usePathname();
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const [, startTransition] = useTransition();
|
|
||||||
|
|
||||||
if (collectors.length < 2) return null;
|
|
||||||
|
|
||||||
const current = searchParams.get('sammler') ?? '';
|
|
||||||
|
|
||||||
function select(id: string) {
|
|
||||||
const params = new URLSearchParams(searchParams.toString());
|
|
||||||
if (id) params.set('sammler', id);
|
|
||||||
else params.delete('sammler');
|
|
||||||
params.delete('seite');
|
|
||||||
|
|
||||||
const query = params.toString();
|
|
||||||
startTransition(() => router.push(query ? `${pathname}?${query}` : pathname));
|
|
||||||
}
|
|
||||||
|
|
||||||
const options = [{ id: '', name: allLabel, color: 'slate' }, ...collectors];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<nav
|
|
||||||
aria-label="Sammler"
|
|
||||||
className="flex gap-1 overflow-x-auto rounded-lg bg-muted p-1"
|
|
||||||
>
|
|
||||||
{options.map((option) => {
|
|
||||||
const active = current === option.id;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={option.id || 'alle'}
|
|
||||||
type="button"
|
|
||||||
aria-pressed={active}
|
|
||||||
onClick={() => select(option.id)}
|
|
||||||
className={cn(
|
|
||||||
'flex flex-1 items-center justify-center gap-2 rounded-md px-3 py-2 text-sm font-medium whitespace-nowrap transition-colors',
|
|
||||||
active
|
|
||||||
? 'bg-background text-foreground shadow-sm'
|
|
||||||
: 'text-muted-foreground hover:text-foreground',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{option.id ? (
|
|
||||||
<span
|
|
||||||
className={cn('size-2 rounded-full', collectorDotClass(option.color))}
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{option.name}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import { useTransition } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { cn } from 'cn';
|
||||||
|
import { chooseCollectorAction } from '@/app/(app)/collector-actions';
|
||||||
|
import { collectorDotClass } from '@/lib/collector-colors';
|
||||||
|
|
||||||
|
export type CollectorOption = { id: string; name: string; color: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Für wen?" in the header: picks the child the whole app is looking at.
|
||||||
|
*
|
||||||
|
* Only rendered with more than one collector - with a single one the question
|
||||||
|
* does not exist. The choice is remembered per device; a `?sammler=` in the
|
||||||
|
* URL (a shared link) takes precedence and is shown as active.
|
||||||
|
*/
|
||||||
|
export function CollectorSwitch({
|
||||||
|
collectors,
|
||||||
|
storedId,
|
||||||
|
}: {
|
||||||
|
collectors: CollectorOption[];
|
||||||
|
storedId: string | null;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const fromUrl = searchParams.get('sammler');
|
||||||
|
const current = fromUrl === 'alle' ? '' : (fromUrl ?? storedId ?? '');
|
||||||
|
|
||||||
|
function select(id: string) {
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await chooseCollectorAction({ id });
|
||||||
|
if (!result.ok) {
|
||||||
|
toast.error(result.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The header choice replaces a collector that came in via the URL.
|
||||||
|
if (fromUrl !== null) {
|
||||||
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
|
params.delete('sammler');
|
||||||
|
params.delete('seite');
|
||||||
|
const query = params.toString();
|
||||||
|
router.replace(query ? `${pathname}?${query}` : pathname);
|
||||||
|
}
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = [{ id: '', name: 'Alle', color: '' }, ...collectors];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div role="radiogroup" aria-label="Für wen?" className="flex gap-2 overflow-x-auto pb-1">
|
||||||
|
{options.map((option) => {
|
||||||
|
const active = current === option.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.id || 'alle'}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={active}
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() => select(option.id)}
|
||||||
|
className={cn(
|
||||||
|
'flex h-10 shrink-0 items-center gap-2 rounded-[calc(var(--radius)*0.7)] border-[length:max(1px,var(--edge-w))] px-3 text-sm font-bold transition-colors',
|
||||||
|
active
|
||||||
|
? 'border-edge bg-card text-card-foreground shadow-(--lift-sm)'
|
||||||
|
: 'border-transparent text-muted-foreground hover:bg-muted',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className={cn(
|
||||||
|
'flex size-6 items-center justify-center rounded-md border-[length:var(--edge-w)] border-edge text-xs font-bold text-white',
|
||||||
|
option.id ? collectorDotClass(option.color) : 'bg-secondary text-secondary-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{option.id ? option.name.charAt(0).toUpperCase() : '★'}
|
||||||
|
</span>
|
||||||
|
{option.name}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import {
|
||||||
|
Bell,
|
||||||
|
Building2,
|
||||||
|
Database,
|
||||||
|
Mail,
|
||||||
|
MapPin,
|
||||||
|
Palette,
|
||||||
|
Tag,
|
||||||
|
UserRound,
|
||||||
|
Users,
|
||||||
|
type LucideIcon,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything that is not part of the daily routine lives behind "Mehr":
|
||||||
|
* the account, the design, family settings and master data. One list feeds
|
||||||
|
* the "Mehr" page and the title bar of each of those pages.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type MoreGroup = 'ich' | 'familie' | 'katalog' | 'daten';
|
||||||
|
|
||||||
|
export type MoreItem = {
|
||||||
|
href: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
group: MoreGroup;
|
||||||
|
icon: LucideIcon;
|
||||||
|
adminOnly: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MORE_GROUPS: Array<{ id: MoreGroup; label: string }> = [
|
||||||
|
{ id: 'ich', label: 'Ich' },
|
||||||
|
{ id: 'familie', label: 'Familie' },
|
||||||
|
{ id: 'katalog', label: 'Katalog pflegen' },
|
||||||
|
{ id: 'daten', label: 'Daten' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const MORE_ITEMS: MoreItem[] = [
|
||||||
|
{
|
||||||
|
href: '/konto',
|
||||||
|
label: 'Konto & Passkeys',
|
||||||
|
description: 'Profil, Anmeldung per Passkey.',
|
||||||
|
group: 'ich',
|
||||||
|
icon: UserRound,
|
||||||
|
adminOnly: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/konto#design',
|
||||||
|
label: 'Design',
|
||||||
|
description: 'Wie Bricktrack auf diesem Gerät aussieht.',
|
||||||
|
group: 'ich',
|
||||||
|
icon: Palette,
|
||||||
|
adminOnly: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/verwaltung/sammler',
|
||||||
|
label: 'Sammler',
|
||||||
|
description: 'Wer sammelt – jedes Kind mit eigener Sammlung und eigenen Wunschlisten.',
|
||||||
|
group: 'familie',
|
||||||
|
icon: Users,
|
||||||
|
adminOnly: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/verwaltung/einladungen',
|
||||||
|
label: 'Einladungen',
|
||||||
|
description: 'Weitere Konten für die Familie.',
|
||||||
|
group: 'familie',
|
||||||
|
icon: Mail,
|
||||||
|
adminOnly: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/verwaltung/erinnerungen',
|
||||||
|
label: 'Erinnerungen',
|
||||||
|
description: 'E-Mail, wenn ein Wunschlisten-Termin näher rückt.',
|
||||||
|
group: 'familie',
|
||||||
|
icon: Bell,
|
||||||
|
adminOnly: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/verwaltung/hersteller',
|
||||||
|
label: 'Hersteller',
|
||||||
|
description: 'Die Marken, deren Sets im Katalog stehen.',
|
||||||
|
group: 'katalog',
|
||||||
|
icon: Building2,
|
||||||
|
adminOnly: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/verwaltung/themen',
|
||||||
|
label: 'Themen',
|
||||||
|
description: 'Serien je Hersteller, auch verschachtelt.',
|
||||||
|
group: 'katalog',
|
||||||
|
icon: Tag,
|
||||||
|
adminOnly: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/verwaltung/standorte',
|
||||||
|
label: 'Standorte',
|
||||||
|
description: 'Wo die Sets aufbewahrt werden.',
|
||||||
|
group: 'katalog',
|
||||||
|
icon: MapPin,
|
||||||
|
adminOnly: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/verwaltung/daten',
|
||||||
|
label: 'Export & Import',
|
||||||
|
description: 'Bestand sichern, umziehen oder als Tabelle auswerten.',
|
||||||
|
group: 'daten',
|
||||||
|
icon: Database,
|
||||||
|
adminOnly: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function moreItemsFor(role: string): MoreItem[] {
|
||||||
|
return MORE_ITEMS.filter((item) => !item.adminOnly || role === 'admin');
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Loader2, LogOut } from 'lucide-react';
|
||||||
|
import { clearOfflineCaches } from '@/components/layout/service-worker';
|
||||||
|
import { authClient } from '@/lib/auth-client';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
export function SignOutButton() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
|
||||||
|
async function signOut() {
|
||||||
|
setPending(true);
|
||||||
|
await authClient.signOut();
|
||||||
|
// Cached pages must not stay readable on a shared device.
|
||||||
|
await clearOfflineCaches();
|
||||||
|
router.push('/anmelden');
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button variant="outline" size="lg" onClick={() => void signOut()} disabled={pending}>
|
||||||
|
{pending ? <Loader2 className="animate-spin" /> : <LogOut />}
|
||||||
|
Abmelden
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
import { LogOut, Settings, UserRound } from 'lucide-react';
|
|
||||||
import { clearOfflineCaches } from '@/components/layout/service-worker';
|
|
||||||
import { authClient } from '@/lib/auth-client';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@/components/ui/dropdown-menu';
|
|
||||||
import type { SessionUser } from '@/lib/session';
|
|
||||||
|
|
||||||
export function UserMenu({ user }: { user: SessionUser }) {
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
async function signOut() {
|
|
||||||
await authClient.signOut();
|
|
||||||
// Cached pages must not stay readable on a shared device.
|
|
||||||
await clearOfflineCaches();
|
|
||||||
router.push('/anmelden');
|
|
||||||
router.refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="ghost" size="icon" aria-label="Konto-Menü">
|
|
||||||
<UserRound />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end" className="w-56">
|
|
||||||
<DropdownMenuLabel className="font-normal">
|
|
||||||
<span className="block text-sm font-medium">{user.name}</span>
|
|
||||||
<span className="block truncate text-xs text-muted-foreground">{user.email}</span>
|
|
||||||
</DropdownMenuLabel>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem asChild>
|
|
||||||
<Link href="/konto">
|
|
||||||
<UserRound />
|
|
||||||
Konto und Passkeys
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
{user.role === 'admin' ? (
|
|
||||||
<DropdownMenuItem asChild>
|
|
||||||
<Link href="/verwaltung/hersteller">
|
|
||||||
<Settings />
|
|
||||||
Verwaltung
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
) : null}
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem onSelect={() => void signOut()}>
|
|
||||||
<LogOut />
|
|
||||||
Abmelden
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
import { ChevronLeft } from 'lucide-react';
|
||||||
|
import { MORE_ITEMS } from '@/components/layout/more-items';
|
||||||
|
|
||||||
|
/** Title of a master-data page, with the way back to "Mehr". */
|
||||||
|
export function VerwaltungHeader() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const item = MORE_ITEMS.find((entry) => entry.href === pathname);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Link
|
||||||
|
href="/mehr"
|
||||||
|
className="inline-flex items-center gap-1 text-sm font-semibold text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="size-4" aria-hidden />
|
||||||
|
Mehr
|
||||||
|
</Link>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">{item?.label ?? 'Verwaltung'}</h1>
|
||||||
|
{item ? <p className="text-sm text-muted-foreground">{item.description}</p> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { usePathname } from 'next/navigation';
|
|
||||||
import { cn } from 'cn';
|
|
||||||
|
|
||||||
const tabs = [
|
|
||||||
{ href: '/verwaltung/sammler', label: 'Sammler', adminOnly: false },
|
|
||||||
{ href: '/verwaltung/hersteller', label: 'Hersteller', adminOnly: false },
|
|
||||||
{ href: '/verwaltung/themen', label: 'Themen', adminOnly: false },
|
|
||||||
{ href: '/verwaltung/standorte', label: 'Standorte', adminOnly: false },
|
|
||||||
{ href: '/verwaltung/einladungen', label: 'Einladungen', adminOnly: true },
|
|
||||||
{ href: '/verwaltung/erinnerungen', label: 'Erinnerungen', adminOnly: true },
|
|
||||||
{ href: '/verwaltung/daten', label: 'Daten', adminOnly: true },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export function VerwaltungNav({ isAdmin }: { isAdmin: boolean }) {
|
|
||||||
const pathname = usePathname();
|
|
||||||
const visible = tabs.filter((tab) => !tab.adminOnly || isAdmin);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<nav className="flex gap-1 overflow-x-auto rounded-lg bg-muted p-1" aria-label="Stammdaten">
|
|
||||||
{visible.map((tab) => {
|
|
||||||
const active = pathname === tab.href;
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={tab.href}
|
|
||||||
href={tab.href}
|
|
||||||
aria-current={active ? 'page' : undefined}
|
|
||||||
className={cn(
|
|
||||||
'flex-1 whitespace-nowrap rounded-md px-3 py-2 text-center text-sm font-medium transition-colors',
|
|
||||||
active
|
|
||||||
? 'bg-background text-foreground shadow-sm'
|
|
||||||
: 'text-muted-foreground hover:text-foreground',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{tab.label}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import 'server-only';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { resolveCollectorId } from '@/lib/queries/collectors';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which collector the app is looking at.
|
||||||
|
*
|
||||||
|
* The switch in the header remembers the choice per device (cookie), so Max's
|
||||||
|
* tablet opens on Max. A `?sammler=` in the URL still wins - shared links and
|
||||||
|
* the back button keep working - and `?sammler=alle` shows everybody.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const COLLECTOR_COOKIE = 'bricktrack-sammler';
|
||||||
|
export const ALL_COLLECTORS = 'alle';
|
||||||
|
|
||||||
|
export async function storedCollectorId(): Promise<string | undefined> {
|
||||||
|
return resolveCollectorId((await cookies()).get(COLLECTOR_COOKIE)?.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function activeCollectorId(param: string | undefined): Promise<string | undefined> {
|
||||||
|
if (param === ALL_COLLECTORS) return undefined;
|
||||||
|
if (param) return resolveCollectorId(param);
|
||||||
|
return storedCollectorId();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user