feat(design): designauswahl mit klemmbaustein-look als standard
Sechs Designs (Klemmbaustein, Warm, Nachtblau, Bonbon, Bauklotz, Schlicht) als Tokens je data-design, gewaehlt unter Konto und pro Geraet im Cookie gespeichert. Dazu Noppen, Steinkontur, Platzhalter fuer Sets ohne Bild, neues Logo, lokal gebuendelte Schriften und eine Startseite mit Steinreihe und naechstem Termin. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
e9093d0fad
commit
1b825b44c4
@@ -1,7 +1,9 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { Check, ExternalLink, Gift, ImageOff, Pencil } from 'lucide-react';
|
||||
import { Check, ExternalLink, Gift, Pencil } from 'lucide-react';
|
||||
import { SetPlaceholder } from '@/components/brick/set-placeholder';
|
||||
import { Studs } from '@/components/brick/studs';
|
||||
import { InstructionsSection } from '@/components/katalog/instructions-section';
|
||||
import { PhotosSection } from '@/components/katalog/photos-section';
|
||||
import { PriceSection } from '@/components/katalog/price-section';
|
||||
@@ -114,15 +116,21 @@ export default async function SetDetailPage({ params, searchParams }: Props) {
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-[minmax(0,320px)_1fr]">
|
||||
<div className="overflow-hidden rounded-xl border border-border bg-muted">
|
||||
{image ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={image} alt="" className="aspect-square w-full object-contain p-3" />
|
||||
) : (
|
||||
<div className="flex aspect-square items-center justify-center text-muted-foreground">
|
||||
<ImageOff className="size-10" aria-hidden />
|
||||
</div>
|
||||
)}
|
||||
<div className="relative self-start">
|
||||
<Studs count={4} />
|
||||
<div className="brick overflow-hidden rounded-xl bg-muted">
|
||||
{image ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={image} alt="" className="aspect-square w-full object-contain p-3" />
|
||||
) : (
|
||||
<SetPlaceholder
|
||||
setNumber={set.setNumber}
|
||||
manufacturerName={set.manufacturerName}
|
||||
size="lg"
|
||||
className="aspect-square"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { headers } from 'next/headers';
|
||||
import { cookies, headers } from 'next/headers';
|
||||
import { z } from 'zod';
|
||||
import { env } from '@/env';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { DESIGN_COOKIE, DESIGNS } from '@/lib/design';
|
||||
import { requireUser } from '@/lib/session';
|
||||
|
||||
const idSchema = z.object({ id: z.string().min(1) });
|
||||
const renameSchema = idSchema.extend({ name: z.string().trim().min(1).max(60) });
|
||||
const designSchema = z.object({ design: z.enum(DESIGNS) });
|
||||
|
||||
export type ActionResult = { ok: true } | { ok: false; error: string };
|
||||
|
||||
@@ -43,3 +47,26 @@ export async function renamePasskeyAction(input: unknown): Promise<ActionResult>
|
||||
revalidatePath('/konto');
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* The design is a per-device choice: a cookie, not a column on the user. That
|
||||
* way it also covers the login page and lets two children's tablets look
|
||||
* different.
|
||||
*/
|
||||
export async function setDesignAction(input: unknown): Promise<ActionResult> {
|
||||
await requireUser();
|
||||
|
||||
const parsed = designSchema.safeParse(input);
|
||||
if (!parsed.success) return { ok: false, error: 'Unbekanntes Design.' };
|
||||
|
||||
(await cookies()).set(DESIGN_COOKIE, parsed.data.design, {
|
||||
httpOnly: true,
|
||||
secure: env.isProduction,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
maxAge: 60 * 60 * 24 * 365,
|
||||
});
|
||||
|
||||
revalidatePath('/', 'layout');
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { headers } from 'next/headers';
|
||||
import { cookies, headers } from 'next/headers';
|
||||
import { PasskeyManager, type PasskeyRow } from '@/components/auth/passkey-manager';
|
||||
import { DesignPicker } from '@/components/konto/design-picker';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { DESIGN_COOKIE, resolveDesign } from '@/lib/design';
|
||||
import { requireUser } from '@/lib/session';
|
||||
|
||||
export const metadata: Metadata = { title: 'Konto' };
|
||||
@@ -17,6 +19,7 @@ export default async function AccountPage() {
|
||||
name: passkey.name ?? null,
|
||||
createdAt: passkey.createdAt ? new Date(passkey.createdAt) : null,
|
||||
}));
|
||||
const design = resolveDesign((await cookies()).get(DESIGN_COOKIE)?.value);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -45,6 +48,18 @@ export default async function AccountPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Design</CardTitle>
|
||||
<CardDescription>
|
||||
Gilt für dieses Gerät. Öffentliche Wunschlisten sehen Gäste im Standard-Design.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DesignPicker current={design} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Passkeys</CardTitle>
|
||||
|
||||
+174
-46
@@ -1,58 +1,186 @@
|
||||
import Link from 'next/link';
|
||||
import { Boxes, Gift, LayoutGrid, PackageSearch } from 'lucide-react';
|
||||
import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { ChevronRight, Gift, PackageSearch, Plus } from 'lucide-react';
|
||||
import { cn } from 'cn';
|
||||
import { Studs } from '@/components/brick/studs';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { brickRow } from '@/lib/brick-row';
|
||||
import { collectorDotClass } from '@/lib/collector-colors';
|
||||
import { formatDate, formatNumber, pluralize } from '@/lib/format';
|
||||
import { formatEuro } from '@/lib/money';
|
||||
import { countMissingSets, getCollectionStats } from '@/lib/queries/collection';
|
||||
import { countItemsPerCollector, listCollectors } from '@/lib/queries/collectors';
|
||||
import { listWishlists } from '@/lib/queries/wishlists';
|
||||
import { daysUntil } from '@/lib/reminders';
|
||||
import { requireUser } from '@/lib/session';
|
||||
|
||||
const shortcuts = [
|
||||
{
|
||||
href: '/katalog',
|
||||
icon: LayoutGrid,
|
||||
title: 'Katalog',
|
||||
description: 'Alle bekannten Sets durchsuchen und neue anlegen.',
|
||||
},
|
||||
{
|
||||
href: '/katalog?besitz=fehlt',
|
||||
icon: PackageSearch,
|
||||
title: 'Fehlende Sets',
|
||||
description: 'Was noch nicht in der Sammlung ist – direkt auf die Wunschliste.',
|
||||
},
|
||||
{
|
||||
href: '/sammlung',
|
||||
icon: Boxes,
|
||||
title: 'Sammlung',
|
||||
description: 'Alles, was tatsächlich da ist – mit Standort und Zustand.',
|
||||
},
|
||||
{
|
||||
href: '/wunschlisten',
|
||||
icon: Gift,
|
||||
title: 'Wunschlisten',
|
||||
description: 'Listen anlegen und per Link mit Familie und Freunden teilen.',
|
||||
},
|
||||
] as const;
|
||||
function daysLabel(days: number): string {
|
||||
if (days === 0) return 'heute';
|
||||
if (days === 1) return 'morgen';
|
||||
return `in ${days} Tagen`;
|
||||
}
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const user = await requireUser();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Hallo {user.name}</h1>
|
||||
<p className="text-sm text-muted-foreground">Was möchtest du tun?</p>
|
||||
</div>
|
||||
const stats = getCollectionStats();
|
||||
const missing = countMissingSets();
|
||||
const totalSets = stats.setCount + missing;
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{shortcuts.map((shortcut) => (
|
||||
<Link key={shortcut.href} href={shortcut.href} className="group">
|
||||
<Card className="h-full transition-colors group-hover:border-primary/40">
|
||||
<CardHeader>
|
||||
<shortcut.icon className="size-6 text-primary" aria-hidden />
|
||||
<CardTitle>{shortcut.title}</CardTitle>
|
||||
<CardDescription>{shortcut.description}</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
const collectors = listCollectors();
|
||||
const perCollector = countItemsPerCollector();
|
||||
const showCollectors = collectors.length > 1;
|
||||
const bricks = brickRow(
|
||||
stats.setCount,
|
||||
totalSets,
|
||||
showCollectors
|
||||
? collectors.map((entry) => ({ key: entry.id, count: perCollector.get(entry.id) ?? 0 }))
|
||||
: [],
|
||||
);
|
||||
const colorById = new Map(collectors.map((entry) => [entry.id, entry.color]));
|
||||
|
||||
// The next date someone could bring a present for.
|
||||
const now = new Date();
|
||||
const nextEvent =
|
||||
listWishlists(user.id)
|
||||
.filter((list) => list.eventDate && daysUntil(list.eventDate, now) >= 0)
|
||||
.sort((a, b) => a.eventDate!.getTime() - b.eventDate!.getTime())[0] ?? null;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl space-y-7">
|
||||
<h1 className="text-3xl font-semibold tracking-tight">Hallo {user.name}!</h1>
|
||||
|
||||
<section className="relative">
|
||||
<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>
|
||||
<p className="text-sm font-semibold text-muted-foreground">
|
||||
{formatNumber(stats.setCount)} von {pluralize(totalSets, 'Set', 'Sets')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ol
|
||||
aria-label={`${formatNumber(stats.setCount)} von ${formatNumber(totalSets)} Sets vorhanden`}
|
||||
className="mt-5 grid grid-cols-10 items-end gap-1.5"
|
||||
>
|
||||
{bricks.map((key, index) => (
|
||||
<li key={index} aria-hidden className="relative">
|
||||
{key === null ? (
|
||||
<span className="block h-7 rounded-[6px] border-2 border-dashed border-muted-foreground/50" />
|
||||
) : (
|
||||
<>
|
||||
<span
|
||||
className={cn(
|
||||
'stud mx-auto mb-[calc(var(--edge-w)*-1)] !h-1.5 !w-3 !rounded-t-[4px] [display:var(--studs)]',
|
||||
key ? collectorDotClass(colorById.get(key)) : 'bg-secondary',
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'block h-7 rounded-[6px] border-[length:var(--edge-w)] border-edge',
|
||||
key ? collectorDotClass(colorById.get(key)) : 'bg-secondary',
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
{showCollectors ? (
|
||||
<ul className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs font-semibold">
|
||||
{collectors.map((entry) => (
|
||||
<li key={entry.id} className="flex items-center gap-1.5">
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'size-3 rounded-[3px] border-[length:var(--edge-w)] border-edge',
|
||||
collectorDotClass(entry.color),
|
||||
)}
|
||||
/>
|
||||
{entry.name}
|
||||
</li>
|
||||
))}
|
||||
<li className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<span
|
||||
aria-hidden
|
||||
className="size-3 rounded-[3px] border-2 border-dashed border-muted-foreground/50"
|
||||
/>
|
||||
fehlt noch
|
||||
</li>
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid grid-cols-2 gap-3">
|
||||
<div className="relative">
|
||||
<Studs studClassName="bg-secondary" />
|
||||
<div className="brick h-full rounded-xl bg-secondary p-4 text-secondary-foreground">
|
||||
<p className="text-2xl font-bold tabular-nums [font-family:var(--font-display)]">
|
||||
{formatNumber(stats.pieceCount)}
|
||||
</p>
|
||||
<p className="text-sm font-semibold">Teile verbaut</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Studs studClassName="bg-owned" />
|
||||
<div className="brick h-full rounded-xl bg-owned p-4 text-owned-foreground">
|
||||
<p className="text-2xl font-bold tabular-nums [font-family:var(--font-display)]">
|
||||
{formatEuro(stats.currentValueCents)}
|
||||
</p>
|
||||
<p className="text-sm font-semibold">
|
||||
{stats.currentValueCents === stats.retailValueCents ? 'Wert nach UVP' : 'Aktueller Wert'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{nextEvent?.eventDate ? (
|
||||
<Link href={`/wunschlisten/${nextEvent.id}`} className="relative block rounded-xl">
|
||||
<Studs count={4} studClassName="bg-primary" />
|
||||
<div className="brick flex items-center gap-4 rounded-xl bg-primary p-4 text-primary-foreground">
|
||||
<div className="flex size-14 shrink-0 flex-col items-center justify-center rounded-lg border-[length:var(--edge-w)] border-edge bg-card text-card-foreground">
|
||||
<span className="text-xl leading-none font-bold [font-family:var(--font-display)]">
|
||||
{formatDate(nextEvent.eventDate).slice(0, 5)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-lg font-semibold [font-family:var(--font-display)]">
|
||||
{nextEvent.title}
|
||||
</p>
|
||||
<p className="text-sm font-semibold">
|
||||
{daysLabel(daysUntil(nextEvent.eventDate, now))} ·{' '}
|
||||
{nextEvent.openCount === 1
|
||||
? '1 Wunsch noch offen'
|
||||
: `${formatNumber(nextEvent.openCount)} Wünsche noch offen`}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRight className="size-5 shrink-0" aria-hidden />
|
||||
</div>
|
||||
</Link>
|
||||
) : null}
|
||||
|
||||
<section className="flex flex-wrap gap-3">
|
||||
<Button asChild variant="outline" size="lg">
|
||||
<Link href="/katalog?besitz=fehlt">
|
||||
<PackageSearch aria-hidden />
|
||||
{missing === 1 ? '1 Set fehlt noch' : `${formatNumber(missing)} Sets fehlen noch`}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="lg">
|
||||
<Link href="/wunschlisten">
|
||||
<Gift aria-hidden />
|
||||
Wunschlisten
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild size="lg">
|
||||
<Link href="/katalog/neu">
|
||||
<Plus aria-hidden />
|
||||
Set anlegen
|
||||
</Link>
|
||||
</Button>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { Boxes, Euro, ImageOff, Layers, PackageSearch } from 'lucide-react';
|
||||
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';
|
||||
import { formatDate, formatNumber, pluralize } from '@/lib/format';
|
||||
import { collectorBadgeClass } from '@/lib/collector-colors';
|
||||
import { collectorBadgeClass, collectorDotClass } from '@/lib/collector-colors';
|
||||
import { setImageSrc } from '@/lib/images';
|
||||
import { completenessLabel, statusLabel } from '@/lib/labels';
|
||||
import { formatEuro } from '@/lib/money';
|
||||
@@ -122,23 +124,31 @@ export default async function SammlungPage({ searchParams }: { searchParams: Sea
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-border rounded-lg border border-border">
|
||||
<ul className="space-y-5 pt-2">
|
||||
{items.map((item) => {
|
||||
const image = setImageSrc(item);
|
||||
return (
|
||||
<li key={item.id}>
|
||||
<li key={item.id} className="relative">
|
||||
{/* The studs carry the collector's colour once there is more than one. */}
|
||||
<Studs
|
||||
studClassName={
|
||||
collectors.length > 1 ? collectorDotClass(item.collectorColor) : undefined
|
||||
}
|
||||
/>
|
||||
<Link
|
||||
href={`/katalog/${item.setId}`}
|
||||
className="flex items-center gap-3 p-3 transition-colors hover:bg-muted/50"
|
||||
className="brick flex items-center gap-3 rounded-xl bg-card p-3 transition-colors hover:bg-muted/50"
|
||||
>
|
||||
<div className="size-14 shrink-0 overflow-hidden rounded-lg border border-border bg-muted">
|
||||
<div className="size-16 shrink-0 overflow-hidden rounded-lg border-[length:max(1px,var(--edge-w))] border-edge bg-muted">
|
||||
{image ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={image} alt="" loading="lazy" className="size-full object-contain" />
|
||||
) : (
|
||||
<div className="flex size-full items-center justify-center text-muted-foreground">
|
||||
<ImageOff className="size-5" aria-hidden />
|
||||
</div>
|
||||
<SetPlaceholder
|
||||
setNumber={item.setNumber}
|
||||
manufacturerName={item.manufacturerName}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user