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>
|
||||
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import Link from 'next/link';
|
||||
import { Blocks } from 'lucide-react';
|
||||
import { BrickLogo } from '@/components/brick/logo';
|
||||
|
||||
export default function AuthLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex min-h-dvh flex-col items-center justify-center gap-8 px-4 py-10">
|
||||
<Link href="/" className="flex items-center gap-2 text-xl font-semibold tracking-tight">
|
||||
<Blocks className="size-6 text-primary" aria-hidden />
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-2 text-2xl font-bold tracking-tight [font-family:var(--font-display)]"
|
||||
>
|
||||
<BrickLogo className="h-8 w-9" />
|
||||
Bricktrack
|
||||
</Link>
|
||||
<main className="w-full max-w-sm">{children}</main>
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
/*
|
||||
* Selectable designs. Each one is a set of tokens under :root[data-design=...]
|
||||
* (the attribute is set in the root layout from a cookie, see src/lib/design.ts).
|
||||
* The neutral "schlicht" design uses the defaults from globals.css.
|
||||
*
|
||||
* Token additions on top of shadcn:
|
||||
* --font-display headings and big numbers
|
||||
* --edge, --edge-w outline of cards, buttons and badges
|
||||
* --lift(-sm) the hard bottom edge that makes a card look like a brick
|
||||
* --studs "flex" shows the studs on top of cards, "none" hides them
|
||||
* --tone-1..5 placeholder colours for sets without an image
|
||||
* --owned the "Vorhanden" badge
|
||||
*/
|
||||
|
||||
/* --- Klemmbaustein (default) --------------------------------------------- */
|
||||
:root[data-design='klemmbaustein'] {
|
||||
--font-sans: 'Nunito Variable', ui-sans-serif, system-ui, sans-serif;
|
||||
--font-display: 'Fredoka Variable', ui-sans-serif, system-ui, sans-serif;
|
||||
--radius: 1rem;
|
||||
|
||||
--background: #fff6e5;
|
||||
--foreground: #24203f;
|
||||
--card: #ffffff;
|
||||
--card-foreground: #24203f;
|
||||
--popover: #ffffff;
|
||||
--popover-foreground: #24203f;
|
||||
--primary: #d63a30;
|
||||
--primary-foreground: #ffffff;
|
||||
--secondary: #ffc93c;
|
||||
--secondary-foreground: #24203f;
|
||||
--muted: #f6ead0;
|
||||
--muted-foreground: #5f5a78;
|
||||
--accent: #ffe9b0;
|
||||
--accent-foreground: #24203f;
|
||||
--destructive: #c62828;
|
||||
--border: rgb(36 32 63 / 0.16);
|
||||
--input: #24203f;
|
||||
--ring: #2f6bea;
|
||||
|
||||
--logo: #ffc93c;
|
||||
--edge: #24203f;
|
||||
--edge-w: 2px;
|
||||
--lift: 0 4px 0 #24203f;
|
||||
--lift-sm: 0 3px 0 #24203f;
|
||||
--studs: flex;
|
||||
--owned: #2ebd85;
|
||||
--owned-foreground: #24203f;
|
||||
--tone-ink: #24203f;
|
||||
--tone-1: #ffe08a;
|
||||
--tone-2: #bfd6ff;
|
||||
--tone-3: #bdebd3;
|
||||
--tone-4: #ffc9d9;
|
||||
--tone-5: #e2d6ff;
|
||||
}
|
||||
|
||||
/* --- Warm ---------------------------------------------------------------- */
|
||||
:root[data-design='warm'] {
|
||||
--font-sans: 'Inter Variable', ui-sans-serif, system-ui, sans-serif;
|
||||
--font-display: 'Bricolage Grotesque Variable', ui-sans-serif, system-ui, sans-serif;
|
||||
--radius: 0.875rem;
|
||||
|
||||
--background: #f6f3ec;
|
||||
--foreground: #1d1b17;
|
||||
--card: #ffffff;
|
||||
--card-foreground: #1d1b17;
|
||||
--popover: #ffffff;
|
||||
--popover-foreground: #1d1b17;
|
||||
--primary: #c8381f;
|
||||
--primary-foreground: #ffffff;
|
||||
--secondary: #fbe3a6;
|
||||
--secondary-foreground: #1d1b17;
|
||||
--muted: #ece6da;
|
||||
--muted-foreground: #5f594f;
|
||||
--accent: #fbe3a6;
|
||||
--accent-foreground: #1d1b17;
|
||||
--destructive: #b42318;
|
||||
--border: #e2dbcd;
|
||||
--input: #d6cebf;
|
||||
--ring: #c8381f;
|
||||
|
||||
--edge: transparent;
|
||||
--edge-w: 0px;
|
||||
--lift: 0 1px 2px rgb(29 27 23 / 0.06);
|
||||
--lift-sm: none;
|
||||
--studs: none;
|
||||
--owned: #1f7a4d;
|
||||
--owned-foreground: #ffffff;
|
||||
--tone-ink: #1d1b17;
|
||||
--tone-1: #fbe3a6;
|
||||
--tone-2: #d6e6f5;
|
||||
--tone-3: #dde8d2;
|
||||
--tone-4: #f6d5cc;
|
||||
--tone-5: #e5ddf0;
|
||||
}
|
||||
|
||||
/* --- Nachtblau (dark) ---------------------------------------------------- */
|
||||
:root[data-design='nacht'] {
|
||||
color-scheme: dark;
|
||||
--font-sans: 'Inter Variable', ui-sans-serif, system-ui, sans-serif;
|
||||
--font-display: 'Outfit Variable', ui-sans-serif, system-ui, sans-serif;
|
||||
--radius: 1.125rem;
|
||||
|
||||
--background: #0e1422;
|
||||
--foreground: #f2f4f8;
|
||||
--card: #182136;
|
||||
--card-foreground: #f2f4f8;
|
||||
--popover: #1b2540;
|
||||
--popover-foreground: #f2f4f8;
|
||||
--primary: #ffc93c;
|
||||
--primary-foreground: #1a1300;
|
||||
--secondary: #222d45;
|
||||
--secondary-foreground: #f2f4f8;
|
||||
--muted: #222d45;
|
||||
--muted-foreground: #a3adc2;
|
||||
--accent: #2a3654;
|
||||
--accent-foreground: #f2f4f8;
|
||||
--destructive: #f87171;
|
||||
--border: #2c3854;
|
||||
--input: #34405e;
|
||||
--ring: #ffc93c;
|
||||
|
||||
--edge: transparent;
|
||||
--edge-w: 0px;
|
||||
--lift: none;
|
||||
--lift-sm: none;
|
||||
--studs: none;
|
||||
--owned: #1e4d3a;
|
||||
--owned-foreground: #8fe3b8;
|
||||
--tone-ink: #f2f4f8;
|
||||
--tone-1: #3a3218;
|
||||
--tone-2: #1b2c44;
|
||||
--tone-3: #22331e;
|
||||
--tone-4: #3d2230;
|
||||
--tone-5: #2c2546;
|
||||
}
|
||||
|
||||
/* --- Bonbon -------------------------------------------------------------- */
|
||||
:root[data-design='bonbon'] {
|
||||
--font-sans: 'Nunito Variable', ui-sans-serif, system-ui, sans-serif;
|
||||
--font-display: 'Fredoka Variable', ui-sans-serif, system-ui, sans-serif;
|
||||
--radius: 1.5rem;
|
||||
|
||||
--background: #f4f0ff;
|
||||
--foreground: #23194a;
|
||||
--card: #ffffff;
|
||||
--card-foreground: #23194a;
|
||||
--popover: #ffffff;
|
||||
--popover-foreground: #23194a;
|
||||
--primary: #6d4aff;
|
||||
--primary-foreground: #ffffff;
|
||||
--secondary: #ede7ff;
|
||||
--secondary-foreground: #4b2fc4;
|
||||
--muted: #ede7ff;
|
||||
--muted-foreground: #5e567c;
|
||||
--accent: #ede7ff;
|
||||
--accent-foreground: #23194a;
|
||||
--destructive: #c62828;
|
||||
--border: #e4ddf7;
|
||||
--input: #d8cff3;
|
||||
--ring: #6d4aff;
|
||||
|
||||
--edge: transparent;
|
||||
--edge-w: 0px;
|
||||
--lift: 0 8px 24px rgb(35 25 74 / 0.08);
|
||||
--lift-sm: none;
|
||||
--studs: none;
|
||||
--owned: #157a57;
|
||||
--owned-foreground: #ffffff;
|
||||
--tone-ink: #23194a;
|
||||
--tone-1: #fff1c7;
|
||||
--tone-2: #dff0ff;
|
||||
--tone-3: #ddf7ea;
|
||||
--tone-4: #ffe1d1;
|
||||
--tone-5: #ffddeb;
|
||||
}
|
||||
|
||||
/* --- Bauklotz ------------------------------------------------------------ */
|
||||
:root[data-design='bauklotz'] {
|
||||
--font-sans: 'Archivo Variable', ui-sans-serif, system-ui, sans-serif;
|
||||
--font-display: 'Archivo Variable', ui-sans-serif, system-ui, sans-serif;
|
||||
--radius: 0.75rem;
|
||||
|
||||
--background: #fffbef;
|
||||
--foreground: #111111;
|
||||
--card: #ffffff;
|
||||
--card-foreground: #111111;
|
||||
--popover: #ffffff;
|
||||
--popover-foreground: #111111;
|
||||
--primary: #1e5eff;
|
||||
--primary-foreground: #ffffff;
|
||||
--secondary: #ffd500;
|
||||
--secondary-foreground: #111111;
|
||||
--muted: #f3eedc;
|
||||
--muted-foreground: #4a4a4a;
|
||||
--accent: #ffd500;
|
||||
--accent-foreground: #111111;
|
||||
--destructive: #c62828;
|
||||
--border: rgb(17 17 17 / 0.2);
|
||||
--input: #111111;
|
||||
--ring: #1e5eff;
|
||||
|
||||
--logo: #ffd500;
|
||||
--edge: #111111;
|
||||
--edge-w: 2.5px;
|
||||
--lift: 4px 4px 0 #111111;
|
||||
--lift-sm: 3px 3px 0 #111111;
|
||||
--studs: none;
|
||||
--owned: #111111;
|
||||
--owned-foreground: #ffffff;
|
||||
--tone-ink: #111111;
|
||||
--tone-1: #ffd500;
|
||||
--tone-2: #7fb2ff;
|
||||
--tone-3: #8bd17c;
|
||||
--tone-4: #ff8a7a;
|
||||
--tone-5: #c9a7ff;
|
||||
}
|
||||
|
||||
:root[data-design='bauklotz'] :is(h1, h2) {
|
||||
font-weight: 900;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
/*
|
||||
* Surfaces. Unlayered on purpose: these must win over the utility classes of
|
||||
* the shadcn components, which carry the neutral look. Focused elements keep
|
||||
* their ring (also a box-shadow), so the lift steps aside for it.
|
||||
*/
|
||||
[data-slot='card'] {
|
||||
border: var(--edge-w) solid var(--edge);
|
||||
box-shadow: var(--lift);
|
||||
}
|
||||
|
||||
:root:is([data-design='klemmbaustein'], [data-design='bauklotz']) {
|
||||
& [data-slot='button']:is(
|
||||
[data-variant='default'],
|
||||
[data-variant='outline'],
|
||||
[data-variant='secondary'],
|
||||
[data-variant='destructive']
|
||||
) {
|
||||
border-width: var(--edge-w);
|
||||
border-color: var(--edge);
|
||||
font-weight: 700;
|
||||
|
||||
&:not(:focus-visible) {
|
||||
box-shadow: var(--lift-sm);
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
translate: 0 2px;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
& [data-slot='badge'] {
|
||||
border-width: 2px;
|
||||
border-color: var(--edge);
|
||||
border-radius: calc(var(--radius) * 0.5);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
& :is([data-slot='input'], [data-slot='textarea'], [data-slot='native-select'], [data-slot='select-trigger']) {
|
||||
border-width: var(--edge-w);
|
||||
border-color: var(--edge);
|
||||
}
|
||||
|
||||
& :is([data-slot='dialog-content'], [data-slot='alert-dialog-content'], [data-slot='popover-content'], [data-slot='dropdown-menu-content'], [data-slot='select-content']) {
|
||||
border: var(--edge-w) solid var(--edge);
|
||||
box-shadow: var(--lift);
|
||||
}
|
||||
}
|
||||
|
||||
/* A surface outside the shadcn components (set tiles, list entries). */
|
||||
@utility brick {
|
||||
border: var(--edge-w) solid var(--edge);
|
||||
box-shadow: var(--lift);
|
||||
}
|
||||
|
||||
/* --- Studs ---------------------------------------------------------------- */
|
||||
/* In a layer, so a utility class on a stud (collector colour) still wins. */
|
||||
@layer components {
|
||||
.studs {
|
||||
display: var(--studs);
|
||||
position: absolute;
|
||||
top: calc(var(--edge-w) - 10px);
|
||||
left: 16px;
|
||||
gap: 10px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.stud {
|
||||
display: block;
|
||||
width: 20px;
|
||||
height: 10px;
|
||||
box-sizing: border-box;
|
||||
border: var(--edge-w) solid var(--edge);
|
||||
border-bottom: 0;
|
||||
border-radius: 7px 7px 0 0;
|
||||
background: var(--card);
|
||||
}
|
||||
}
|
||||
+33
-2
@@ -1,6 +1,7 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
@import "./designs.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@@ -10,6 +11,7 @@
|
||||
"Segoe UI Emoji";
|
||||
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
|
||||
"Liberation Mono", monospace;
|
||||
--font-display: var(--font-sans);
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +20,16 @@
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-sans);
|
||||
--font-mono: var(--font-mono);
|
||||
--font-heading: var(--font-sans);
|
||||
--font-heading: var(--font-display);
|
||||
--color-edge: var(--edge);
|
||||
--color-owned: var(--owned);
|
||||
--color-owned-foreground: var(--owned-foreground);
|
||||
--color-tone-ink: var(--tone-ink);
|
||||
--color-tone-1: var(--tone-1);
|
||||
--color-tone-2: var(--tone-2);
|
||||
--color-tone-3: var(--tone-3);
|
||||
--color-tone-4: var(--tone-4);
|
||||
--color-tone-5: var(--tone-5);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
@@ -90,6 +101,21 @@
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
/* Brick look: outline, "lift" (the hard bottom edge) and studs. The
|
||||
neutral design has none of it; see designs.css for the others. */
|
||||
--edge: oklch(0.145 0 0 / 10%);
|
||||
--edge-w: 1px;
|
||||
--lift: none;
|
||||
--lift-sm: none;
|
||||
--studs: none;
|
||||
--owned: oklch(0.596 0.145 163.225);
|
||||
--owned-foreground: oklch(1 0 0);
|
||||
--tone-ink: oklch(0.556 0 0);
|
||||
--tone-1: oklch(0.97 0 0);
|
||||
--tone-2: oklch(0.97 0 0);
|
||||
--tone-3: oklch(0.97 0 0);
|
||||
--tone-4: oklch(0.97 0 0);
|
||||
--tone-5: oklch(0.97 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
@@ -136,4 +162,9 @@
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
}
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
}␍
|
||||
+30
-11
@@ -1,6 +1,14 @@
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import { cookies } from 'next/headers';
|
||||
import '@fontsource-variable/archivo';
|
||||
import '@fontsource-variable/bricolage-grotesque';
|
||||
import '@fontsource-variable/fredoka';
|
||||
import '@fontsource-variable/inter';
|
||||
import '@fontsource-variable/nunito';
|
||||
import '@fontsource-variable/outfit';
|
||||
import { ServiceWorkerRegistration } from '@/components/layout/service-worker';
|
||||
import { Toaster } from '@/components/ui/sonner';
|
||||
import { DESIGN_COOKIE, DESIGN_INFO, resolveDesign } from '@/lib/design';
|
||||
import './globals.css';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -25,19 +33,30 @@ export const metadata: Metadata = {
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
viewportFit: 'cover',
|
||||
themeColor: [
|
||||
{ media: '(prefers-color-scheme: light)', color: '#ffffff' },
|
||||
{ media: '(prefers-color-scheme: dark)', color: '#0a0a0a' },
|
||||
],
|
||||
};
|
||||
async function currentDesign() {
|
||||
return resolveDesign((await cookies()).get(DESIGN_COOKIE)?.value);
|
||||
}
|
||||
|
||||
export async function generateViewport(): Promise<Viewport> {
|
||||
const design = await currentDesign();
|
||||
return {
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
viewportFit: 'cover',
|
||||
themeColor: DESIGN_INFO[design].themeColor,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||
const design = await currentDesign();
|
||||
|
||||
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||
return (
|
||||
<html lang="de" suppressHydrationWarning>
|
||||
<html
|
||||
lang="de"
|
||||
data-design={design}
|
||||
className={DESIGN_INFO[design].dark ? 'dark' : undefined}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<body className="min-h-dvh bg-background text-foreground antialiased">
|
||||
{children}
|
||||
<Toaster position="top-center" richColors closeButton />
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { Blocks, CalendarDays, Gift } from 'lucide-react';
|
||||
import { CalendarDays, Gift } from 'lucide-react';
|
||||
import { BrickLogo } from '@/components/brick/logo';
|
||||
import { Studs } from '@/components/brick/studs';
|
||||
import { PublicWishlist, type PublicItem } from '@/components/wunschlisten/public-wishlist';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { formatDate, pluralize } from '@/lib/format';
|
||||
@@ -54,50 +56,56 @@ export default async function PublicWishlistPage({ params }: Props) {
|
||||
const openCount = items.filter((item) => !item.isReserved).length;
|
||||
|
||||
return (
|
||||
<div className="mx-auto min-h-dvh w-full max-w-2xl px-4 py-8">
|
||||
<header className="space-y-3 border-b border-border pb-6">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Blocks className="size-4 text-primary" aria-hidden />
|
||||
Bricktrack
|
||||
</div>
|
||||
<div className="min-h-dvh">
|
||||
<header className="border-b-[length:max(1px,var(--edge-w))] border-edge bg-primary text-primary-foreground">
|
||||
<div className="mx-auto w-full max-w-2xl space-y-3 px-4 pt-6 pb-10">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<BrickLogo className="h-5 w-6" />
|
||||
Bricktrack
|
||||
</div>
|
||||
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{list.title}</h1>
|
||||
<h1 className="text-3xl font-semibold tracking-tight">{list.title}</h1>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Gift className="size-3" aria-hidden />
|
||||
{occasionLabel(list.occasion)}
|
||||
</Badge>
|
||||
{list.eventDate ? (
|
||||
<Badge variant="outline" className="gap-1">
|
||||
<CalendarDays className="size-3" aria-hidden />
|
||||
{formatDate(list.eventDate)}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge className="gap-1 bg-card text-card-foreground">
|
||||
<Gift className="size-3" aria-hidden />
|
||||
{occasionLabel(list.occasion)}
|
||||
</Badge>
|
||||
) : null}
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{pluralize(openCount, 'Wunsch noch offen', 'Wünsche noch offen')}
|
||||
</span>
|
||||
{list.eventDate ? (
|
||||
<Badge className="gap-1 bg-card text-card-foreground">
|
||||
<CalendarDays className="size-3" aria-hidden />
|
||||
{formatDate(list.eventDate)}
|
||||
</Badge>
|
||||
) : null}
|
||||
<Badge variant="secondary">
|
||||
{pluralize(openCount, 'Wunsch noch offen', 'Wünsche noch offen')}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{list.note ? (
|
||||
<p className="rounded-lg bg-muted p-3 text-sm whitespace-pre-line">{list.note}</p>
|
||||
) : null}
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Klick auf „Das schenke ich“, damit niemand dasselbe doppelt kauft. Eine Anmeldung
|
||||
brauchst du dafür nicht.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<main className="py-6">
|
||||
{items.length === 0 ? (
|
||||
<p className="rounded-lg border border-dashed border-border p-8 text-center text-sm text-muted-foreground">
|
||||
Auf dieser Liste steht gerade nichts.
|
||||
</p>
|
||||
) : (
|
||||
<PublicWishlist shareToken={shareToken} items={items} />
|
||||
)}
|
||||
</main>
|
||||
<div className="mx-auto w-full max-w-2xl px-4 pb-8">
|
||||
<div className="relative -mt-5">
|
||||
<Studs />
|
||||
<div className="brick space-y-2 rounded-xl bg-card p-4 text-card-foreground">
|
||||
{list.note ? <p className="text-sm whitespace-pre-line">{list.note}</p> : null}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Klick auf „Das schenke ich“, damit niemand dasselbe doppelt kauft. Eine Anmeldung
|
||||
brauchst du dafür nicht.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main className="py-6">
|
||||
{items.length === 0 ? (
|
||||
<p className="rounded-lg border border-dashed border-border p-8 text-center text-sm text-muted-foreground">
|
||||
Auf dieser Liste steht gerade nichts.
|
||||
</p>
|
||||
) : (
|
||||
<PublicWishlist shareToken={shareToken} items={items} />
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/** The app mark: a 2x1 brick in the design's logo colour. */
|
||||
export function BrickLogo({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 38 32" aria-hidden className={className}>
|
||||
<g
|
||||
style={{
|
||||
fill: 'var(--logo, var(--primary))',
|
||||
stroke: 'var(--edge)',
|
||||
strokeWidth: 'var(--edge-w)',
|
||||
}}
|
||||
>
|
||||
<rect x="9" y="2" width="8" height="8" rx="2.5" />
|
||||
<rect x="21" y="2" width="8" height="8" rx="2.5" />
|
||||
<rect x="2" y="8" width="34" height="22" rx="6" />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { cn } from 'cn';
|
||||
import { toneIndex } from '@/lib/design';
|
||||
|
||||
const TONE_CLASSES: Record<number, string> = {
|
||||
1: 'bg-tone-1',
|
||||
2: 'bg-tone-2',
|
||||
3: 'bg-tone-3',
|
||||
4: 'bg-tone-4',
|
||||
5: 'bg-tone-5',
|
||||
};
|
||||
|
||||
/**
|
||||
* Stand-in for a set without an image: the manufacturer's tone, a drawn
|
||||
* brick and the set number. Reads as intended rather than as "image broken".
|
||||
*/
|
||||
export function SetPlaceholder({
|
||||
setNumber,
|
||||
manufacturerName,
|
||||
size = 'md',
|
||||
className,
|
||||
}: {
|
||||
setNumber: string;
|
||||
manufacturerName: string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
className?: string;
|
||||
}) {
|
||||
const tone = TONE_CLASSES[toneIndex(manufacturerName)];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex size-full items-end overflow-hidden text-tone-ink',
|
||||
size === 'sm' ? 'p-1.5' : 'p-3',
|
||||
tone,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{size !== 'sm' ? (
|
||||
<svg
|
||||
viewBox="0 0 70 44"
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'absolute opacity-40',
|
||||
size === 'lg' ? 'top-[18%] right-[12%] w-2/5' : 'top-3 right-3 w-1/3',
|
||||
)}
|
||||
>
|
||||
<g fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<rect x="12" y="2" width="16" height="10" rx="3" />
|
||||
<rect x="42" y="2" width="16" height="10" rx="3" />
|
||||
<rect x="2" y="10" width="66" height="32" rx="7" />
|
||||
</g>
|
||||
</svg>
|
||||
) : null}
|
||||
<span
|
||||
className={cn(
|
||||
'relative leading-none font-bold tracking-tight [font-family:var(--font-display)] tabular-nums',
|
||||
size === 'sm' && 'text-xs',
|
||||
size === 'md' && (setNumber.length > 6 ? 'text-lg' : 'text-2xl'),
|
||||
size === 'lg' && 'text-5xl',
|
||||
)}
|
||||
>
|
||||
{setNumber}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { cn } from 'cn';
|
||||
|
||||
/**
|
||||
* The row of studs on top of a card. Purely decorative and only visible in
|
||||
* designs that set `--studs: flex`. The parent must be `relative` and must not
|
||||
* clip its overflow - place it next to the card, not inside.
|
||||
*/
|
||||
export function Studs({
|
||||
count = 2,
|
||||
className,
|
||||
studClassName,
|
||||
}: {
|
||||
count?: number;
|
||||
className?: string;
|
||||
/** Colour of the studs, e.g. the collector's dot class. */
|
||||
studClassName?: string;
|
||||
}) {
|
||||
return (
|
||||
<span aria-hidden className={cn('studs', className)}>
|
||||
{Array.from({ length: count }, (_, index) => (
|
||||
<span key={index} className={cn('stud', studClassName)} />
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -217,7 +217,8 @@ export function CatalogGrid({
|
||||
'relative rounded-xl text-left outline-none transition-opacity',
|
||||
'focus-visible:ring-3 focus-visible:ring-ring/50',
|
||||
owned && 'cursor-not-allowed opacity-50',
|
||||
isSelected && '[&>div]:border-primary [&>div]:ring-2 [&>div]:ring-primary/40',
|
||||
isSelected &&
|
||||
'[&_.brick]:outline-3 [&_.brick]:outline-offset-2 [&_.brick]:outline-primary',
|
||||
)}
|
||||
>
|
||||
<SetCardContent set={item} />
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import Link from 'next/link';
|
||||
import { Check, Gift, ImageOff } from 'lucide-react';
|
||||
import { Check, Gift } from 'lucide-react';
|
||||
import { cn } from 'cn';
|
||||
import { SetPlaceholder } from '@/components/brick/set-placeholder';
|
||||
import { Studs } from '@/components/brick/studs';
|
||||
import type { CatalogRow } from '@/lib/queries/catalog';
|
||||
import { formatEuro } from '@/lib/money';
|
||||
import { setImageSrc } from '@/lib/images';
|
||||
@@ -17,56 +19,59 @@ export function SetCardContent({ set }: { set: CatalogRow }) {
|
||||
const owned = set.ownedCopies > 0;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden rounded-xl border border-border bg-card transition-colors">
|
||||
<div className="relative aspect-square bg-muted">
|
||||
{image ? (
|
||||
// Plain img: images are either local files or a URL the user entered,
|
||||
// and the optimizer must never fetch arbitrary remote hosts.
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={image}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="size-full object-contain p-2"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex size-full items-center justify-center text-muted-foreground">
|
||||
<ImageOff className="size-8" aria-hidden />
|
||||
<div className="relative h-full">
|
||||
<Studs count={3} />
|
||||
<div className="brick flex h-full flex-col overflow-hidden rounded-xl bg-card transition-colors">
|
||||
<div className="relative aspect-square border-b-[length:var(--edge-w)] border-edge bg-muted">
|
||||
{image ? (
|
||||
// Plain img: images are either local files or a URL the user entered,
|
||||
// and the optimizer must never fetch arbitrary remote hosts.
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={image}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="size-full object-contain p-2"
|
||||
/>
|
||||
) : (
|
||||
<SetPlaceholder setNumber={set.setNumber} manufacturerName={set.manufacturerName} />
|
||||
)}
|
||||
|
||||
<div className="absolute top-2 left-2 flex flex-col items-start gap-1">
|
||||
{owned ? (
|
||||
<Badge className="gap-1 bg-owned text-owned-foreground">
|
||||
<Check className="size-3" aria-hidden />
|
||||
{set.ownedCopies > 1 ? `${set.ownedCopies}× vorhanden` : 'Vorhanden'}
|
||||
</Badge>
|
||||
) : null}
|
||||
{set.wishlistCount > 0 ? (
|
||||
<Badge className="gap-1">
|
||||
<Gift className="size-3" aria-hidden />
|
||||
Auf Wunschliste
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="absolute top-2 left-2 flex flex-col items-start gap-1">
|
||||
{owned ? (
|
||||
<Badge className="gap-1 bg-emerald-600 text-white hover:bg-emerald-600">
|
||||
<Check className="size-3" aria-hidden />
|
||||
{set.ownedCopies > 1 ? `${set.ownedCopies}× vorhanden` : 'Vorhanden'}
|
||||
</Badge>
|
||||
) : null}
|
||||
{set.wishlistCount > 0 ? (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Gift className="size-3" aria-hidden />
|
||||
Auf Wunschliste
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-1 p-3">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Badge variant="outline" className="font-mono">
|
||||
{set.setNumber}
|
||||
</Badge>
|
||||
<span className="truncate">{set.manufacturerName}</span>
|
||||
</div>
|
||||
<p className="line-clamp-2 text-sm leading-snug font-medium">{set.name}</p>
|
||||
<div className="mt-auto flex items-center justify-between gap-2 pt-1 text-xs text-muted-foreground">
|
||||
<span className="truncate">{[set.themeName, set.year].filter(Boolean).join(' · ')}</span>
|
||||
{set.retailPriceCents !== null ? (
|
||||
<span className="shrink-0 font-medium text-foreground">
|
||||
{formatEuro(set.retailPriceCents)}
|
||||
<div className="flex flex-1 flex-col gap-1 p-3">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Badge variant="outline" className="font-mono">
|
||||
{set.setNumber}
|
||||
</Badge>
|
||||
<span className="truncate">{set.manufacturerName}</span>
|
||||
</div>
|
||||
<p className="line-clamp-2 text-sm leading-snug font-medium">{set.name}</p>
|
||||
<div className="mt-auto flex items-center justify-between gap-2 pt-1 text-xs text-muted-foreground">
|
||||
<span className="truncate">
|
||||
{[set.themeName, set.year].filter(Boolean).join(' · ')}
|
||||
</span>
|
||||
) : null}
|
||||
{set.retailPriceCents !== null ? (
|
||||
<span className="shrink-0 font-medium text-foreground">
|
||||
{formatEuro(set.retailPriceCents)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -78,7 +83,7 @@ export function SetCard({ set, className }: { set: CatalogRow; className?: strin
|
||||
<Link
|
||||
href={`/katalog/${set.id}`}
|
||||
className={cn(
|
||||
'rounded-xl outline-none focus-visible:ring-3 focus-visible:ring-ring/50 [&>div]:hover:border-primary/40',
|
||||
'rounded-xl outline-none focus-visible:ring-3 focus-visible:ring-ring/50',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState, useTransition } from 'react';
|
||||
import { Check } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from 'cn';
|
||||
import { setDesignAction } from '@/app/(app)/konto/actions';
|
||||
import { DESIGN_INFO, DESIGNS, type Design } from '@/lib/design';
|
||||
|
||||
/** Switches the look right away and stores it in the cookie in the background. */
|
||||
function applyDesign(design: Design) {
|
||||
const root = document.documentElement;
|
||||
root.dataset.design = design;
|
||||
root.classList.toggle('dark', DESIGN_INFO[design].dark);
|
||||
}
|
||||
|
||||
export function DesignPicker({ current }: { current: Design }) {
|
||||
const router = useRouter();
|
||||
const [selected, setSelected] = useState<Design>(current);
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
function choose(design: Design) {
|
||||
const previous = selected;
|
||||
setSelected(design);
|
||||
applyDesign(design);
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await setDesignAction({ design });
|
||||
if (!result.ok) {
|
||||
setSelected(previous);
|
||||
applyDesign(previous);
|
||||
toast.error(result.error);
|
||||
return;
|
||||
}
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<fieldset>
|
||||
<legend className="sr-only">Design</legend>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{DESIGNS.map((design) => {
|
||||
const info = DESIGN_INFO[design];
|
||||
const active = design === selected;
|
||||
return (
|
||||
<label
|
||||
key={design}
|
||||
className={cn(
|
||||
'relative flex cursor-pointer items-center gap-3 rounded-lg border-[length:max(1px,var(--edge-w))] p-3 transition-colors',
|
||||
'has-focus-visible:ring-3 has-focus-visible:ring-ring/50',
|
||||
active ? 'border-primary bg-accent' : 'border-border hover:bg-muted/60',
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="design"
|
||||
value={design}
|
||||
checked={active}
|
||||
onChange={() => choose(design)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span
|
||||
aria-hidden
|
||||
className="grid size-12 shrink-0 grid-cols-2 overflow-hidden rounded-md border border-black/15"
|
||||
>
|
||||
{info.swatch.map((color, index) => (
|
||||
<span key={index} style={{ backgroundColor: color }} />
|
||||
))}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-sm font-semibold">
|
||||
{info.label}
|
||||
{design === 'klemmbaustein' ? (
|
||||
<span className="font-normal text-muted-foreground"> · Standard</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="block text-xs text-muted-foreground">{info.description}</span>
|
||||
</span>
|
||||
{active ? <Check className="size-5 shrink-0 text-primary" aria-hidden /> : null}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import Link from 'next/link';
|
||||
import { Blocks } from 'lucide-react';
|
||||
import { BrickLogo } from '@/components/brick/logo';
|
||||
import { DesktopNav } from './bottom-nav';
|
||||
import { UserMenu } from './user-menu';
|
||||
import type { SessionUser } from '@/lib/session';
|
||||
@@ -8,8 +8,11 @@ export function AppHeader({ user }: { user: SessionUser }) {
|
||||
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">
|
||||
<Link href="/" className="flex items-center gap-2 font-semibold tracking-tight">
|
||||
<Blocks className="size-5 text-primary" aria-hidden />
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-2 text-lg font-bold tracking-tight [font-family:var(--font-display)]"
|
||||
>
|
||||
<BrickLogo className="h-6 w-7" />
|
||||
<span>Bricktrack</span>
|
||||
</Link>
|
||||
<div className="flex-1" />
|
||||
|
||||
@@ -18,7 +18,7 @@ export function BottomNav() {
|
||||
return (
|
||||
<nav
|
||||
aria-label="Hauptnavigation"
|
||||
className="fixed inset-x-0 bottom-0 z-40 border-t border-border bg-background/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">
|
||||
{items.map((item) => {
|
||||
@@ -30,10 +30,22 @@ export function BottomNav() {
|
||||
aria-current={active ? 'page' : undefined}
|
||||
className={cn(
|
||||
'flex h-16 flex-col items-center justify-center gap-1 text-xs transition-colors',
|
||||
active ? 'text-primary' : 'text-muted-foreground hover:text-foreground',
|
||||
active
|
||||
? 'font-bold text-foreground'
|
||||
: 'font-medium text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<item.icon className="size-5" aria-hidden />
|
||||
{/* 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>
|
||||
@@ -59,7 +71,7 @@ export function DesktopNav() {
|
||||
className={cn(
|
||||
'flex h-10 items-center gap-2 rounded-lg px-3 text-sm font-medium transition-colors',
|
||||
active
|
||||
? 'bg-muted text-foreground'
|
||||
? 'border-[length:var(--edge-w)] border-edge bg-secondary text-secondary-foreground'
|
||||
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useTransition } from 'react';
|
||||
import { Check, ExternalLink, Gift, ImageOff, Loader2, Undo2 } from 'lucide-react';
|
||||
import { Check, ExternalLink, Gift, Loader2, Undo2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { cancelReservation, reserveItem } from '@/app/w/actions';
|
||||
import { setImageSrc } from '@/lib/images';
|
||||
import { SetPlaceholder } from '@/components/brick/set-placeholder';
|
||||
import { Studs } from '@/components/brick/studs';
|
||||
import { formatEuro } from '@/lib/money';
|
||||
import { priorityLabel } from '@/lib/wishlist-logic';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -125,90 +127,92 @@ export function PublicWishlist({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-5 pt-2">
|
||||
{items.map((item) => {
|
||||
const image = setImageSrc(item);
|
||||
const mine = Boolean(tokens[item.id]);
|
||||
|
||||
return (
|
||||
<article
|
||||
key={item.id}
|
||||
className="flex gap-3 rounded-xl border border-border bg-card p-3"
|
||||
>
|
||||
<div className="size-20 shrink-0 overflow-hidden rounded-lg border border-border bg-muted sm:size-28">
|
||||
{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-6" aria-hidden />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Badge variant="outline" className="font-mono text-xs">
|
||||
{item.setNumber}
|
||||
</Badge>
|
||||
<Badge variant="secondary">{item.manufacturerName}</Badge>
|
||||
{item.priority === 1 ? <Badge>{priorityLabel(1)}</Badge> : null}
|
||||
</div>
|
||||
<h2 className="mt-1 font-medium">{item.setName}</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{[
|
||||
item.themeName,
|
||||
item.year,
|
||||
item.pieceCount ? `${item.pieceCount} Teile` : null,
|
||||
item.retailPriceCents !== null ? formatEuro(item.retailPriceCents) : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</p>
|
||||
{item.note ? <p className="mt-1 text-sm">{item.note}</p> : null}
|
||||
<article key={item.id} className="relative">
|
||||
{/* Studs turn green once someone has taken the wish. */}
|
||||
<Studs count={3} studClassName={mine || item.isReserved ? 'bg-owned' : undefined} />
|
||||
<div className="brick flex gap-3 rounded-xl bg-card p-3">
|
||||
<div className="size-20 shrink-0 overflow-hidden rounded-lg border-[length:max(1px,var(--edge-w))] border-edge bg-muted sm:size-28">
|
||||
{image ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={image} alt="" loading="lazy" className="size-full object-contain" />
|
||||
) : (
|
||||
<SetPlaceholder
|
||||
setNumber={item.setNumber}
|
||||
manufacturerName={item.manufacturerName}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{mine ? (
|
||||
<>
|
||||
<Badge className="gap-1 bg-emerald-600 text-white hover:bg-emerald-600">
|
||||
<Check className="size-3" aria-hidden />
|
||||
Das schenkst du
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<Badge variant="outline" className="font-mono text-xs">
|
||||
{item.setNumber}
|
||||
</Badge>
|
||||
<Button variant="ghost" size="sm" onClick={() => undo(item)} disabled={pending}>
|
||||
<Undo2 />
|
||||
Doch nicht
|
||||
</Button>
|
||||
</>
|
||||
) : item.isReserved ? (
|
||||
<Badge variant="secondary">
|
||||
{item.reservedByName
|
||||
? `Übernimmt ${item.reservedByName}`
|
||||
: 'Schon reserviert'}
|
||||
</Badge>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setReserving(item);
|
||||
setCancelLink(null);
|
||||
}}
|
||||
disabled={pending}
|
||||
>
|
||||
<Gift />
|
||||
Das schenke ich
|
||||
</Button>
|
||||
)}
|
||||
<Badge variant="secondary">{item.manufacturerName}</Badge>
|
||||
{item.priority === 1 ? <Badge>{priorityLabel(1)}</Badge> : null}
|
||||
</div>
|
||||
<h2 className="mt-1 font-medium">{item.setName}</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{[
|
||||
item.themeName,
|
||||
item.year,
|
||||
item.pieceCount ? `${item.pieceCount} Teile` : null,
|
||||
item.retailPriceCents !== null ? formatEuro(item.retailPriceCents) : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</p>
|
||||
{item.note ? <p className="mt-1 text-sm">{item.note}</p> : null}
|
||||
</div>
|
||||
|
||||
{item.manufacturerWebsite ? (
|
||||
<Button asChild variant="link" size="sm" className="px-0">
|
||||
<a href={item.manufacturerWebsite} target="_blank" rel="noreferrer noopener">
|
||||
Zum Shop
|
||||
<ExternalLink />
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{mine ? (
|
||||
<>
|
||||
<Badge className="gap-1 bg-owned text-owned-foreground">
|
||||
<Check className="size-3" aria-hidden />
|
||||
Das schenkst du
|
||||
</Badge>
|
||||
<Button variant="ghost" size="sm" onClick={() => undo(item)} disabled={pending}>
|
||||
<Undo2 />
|
||||
Doch nicht
|
||||
</Button>
|
||||
</>
|
||||
) : item.isReserved ? (
|
||||
<Badge variant="secondary">
|
||||
{item.reservedByName
|
||||
? `Übernimmt ${item.reservedByName}`
|
||||
: 'Schon reserviert'}
|
||||
</Badge>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setReserving(item);
|
||||
setCancelLink(null);
|
||||
}}
|
||||
disabled={pending}
|
||||
>
|
||||
<Gift />
|
||||
Das schenke ich
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{item.manufacturerWebsite ? (
|
||||
<Button asChild variant="link" size="sm" className="px-0">
|
||||
<a href={item.manufacturerWebsite} target="_blank" rel="noreferrer noopener">
|
||||
Zum Shop
|
||||
<ExternalLink />
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState, useTransition } from 'react';
|
||||
import { ArrowDown, ArrowUp, Check, ImageOff, Loader2, Trash2 } from 'lucide-react';
|
||||
import { ArrowDown, ArrowUp, Check, Loader2, Trash2 } from 'lucide-react';
|
||||
import { SetPlaceholder } from '@/components/brick/set-placeholder';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
removeWishlistItem,
|
||||
@@ -122,15 +123,17 @@ export function WishlistItems({
|
||||
|
||||
<Link
|
||||
href={`/katalog/${item.setId}`}
|
||||
className="size-16 shrink-0 overflow-hidden rounded-lg border border-border bg-muted"
|
||||
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"
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* The progress row on the dashboard: a fixed number of slots, filled with one
|
||||
* brick per share of the catalog that is already in the collection. Each
|
||||
* brick takes the colour of a collector, in proportion to what they own.
|
||||
*/
|
||||
|
||||
export type BrickShare = { key: string; count: number };
|
||||
|
||||
/**
|
||||
* Returns `slots` entries: a collector key for a filled slot, `null` for an
|
||||
* empty one. Filled slots come first, grouped by collector in input order.
|
||||
*/
|
||||
export function brickRow(
|
||||
ownedSets: number,
|
||||
totalSets: number,
|
||||
shares: BrickShare[],
|
||||
slots = 10,
|
||||
): Array<string | null> {
|
||||
const empty = Array<string | null>(slots).fill(null);
|
||||
if (totalSets <= 0 || ownedSets <= 0) return empty;
|
||||
|
||||
// Never round a non-empty collection down to an empty row, and only show a
|
||||
// full row when everything is really there.
|
||||
const ratio = Math.min(ownedSets, totalSets) / totalSets;
|
||||
let filled = Math.round(ratio * slots);
|
||||
filled = Math.max(1, filled);
|
||||
if (ratio < 1) filled = Math.min(filled, slots - 1);
|
||||
|
||||
const positive = shares.filter((share) => share.count > 0);
|
||||
const shareTotal = positive.reduce((sum, share) => sum + share.count, 0);
|
||||
if (shareTotal === 0) return [...Array<string | null>(filled).fill(''), ...empty].slice(0, slots);
|
||||
|
||||
// Largest remainder: floor first, then hand out the rest by biggest fraction.
|
||||
const exact = positive.map((share) => (share.count / shareTotal) * filled);
|
||||
const counts = exact.map(Math.floor);
|
||||
let rest = filled - counts.reduce((sum, value) => sum + value, 0);
|
||||
const order = exact
|
||||
.map((value, index) => ({ index, fraction: value - Math.floor(value) }))
|
||||
.sort((a, b) => b.fraction - a.fraction || a.index - b.index);
|
||||
for (const { index } of order) {
|
||||
if (rest === 0) break;
|
||||
counts[index]! += 1;
|
||||
rest -= 1;
|
||||
}
|
||||
|
||||
const row = positive.flatMap((share, index) => Array<string>(counts[index]!).fill(share.key));
|
||||
return [...row, ...empty].slice(0, slots);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* The selectable looks of the app.
|
||||
*
|
||||
* A design is nothing but a set of CSS tokens under `[data-design=...]` in
|
||||
* `src/app/designs.css`; this module only knows the names. The choice lives in
|
||||
* a cookie, so it is per device and also applies to pages without a session
|
||||
* (login, public wishlist).
|
||||
*/
|
||||
|
||||
export const DESIGNS = ['klemmbaustein', 'warm', 'nacht', 'bonbon', 'bauklotz', 'schlicht'] as const;
|
||||
|
||||
export type Design = (typeof DESIGNS)[number];
|
||||
|
||||
export const DEFAULT_DESIGN: Design = 'klemmbaustein';
|
||||
|
||||
export const DESIGN_COOKIE = 'bricktrack-design';
|
||||
|
||||
export type DesignInfo = {
|
||||
label: string;
|
||||
description: string;
|
||||
/** Dark designs also set the `.dark` class so `dark:` variants apply. */
|
||||
dark: boolean;
|
||||
/** Browser chrome colour (address bar, PWA title bar). */
|
||||
themeColor: string;
|
||||
/** Four colours for the preview swatch on the account page. */
|
||||
swatch: [string, string, string, string];
|
||||
};
|
||||
|
||||
export const DESIGN_INFO: Record<Design, DesignInfo> = {
|
||||
klemmbaustein: {
|
||||
label: 'Klemmbaustein',
|
||||
description: 'Noppen, Konturen und kräftige Steinfarben.',
|
||||
dark: false,
|
||||
themeColor: '#fff6e5',
|
||||
swatch: ['#fff6e5', '#d63a30', '#ffc93c', '#2f6bea'],
|
||||
},
|
||||
warm: {
|
||||
label: 'Warm',
|
||||
description: 'Ruhiger Sandton mit Ziegelrot.',
|
||||
dark: false,
|
||||
themeColor: '#f6f3ec',
|
||||
swatch: ['#f6f3ec', '#c8381f', '#fbe3a6', '#1d1b17'],
|
||||
},
|
||||
nacht: {
|
||||
label: 'Nachtblau',
|
||||
description: 'Dunkel mit gelbem Akzent, schont abends die Augen.',
|
||||
dark: true,
|
||||
themeColor: '#0e1422',
|
||||
swatch: ['#0e1422', '#ffc93c', '#182136', '#60a5fa'],
|
||||
},
|
||||
bonbon: {
|
||||
label: 'Bonbon',
|
||||
description: 'Weiches Lila mit Pastellfarben.',
|
||||
dark: false,
|
||||
themeColor: '#f4f0ff',
|
||||
swatch: ['#f4f0ff', '#6d4aff', '#d3f5e7', '#ffe1d1'],
|
||||
},
|
||||
bauklotz: {
|
||||
label: 'Bauklotz',
|
||||
description: 'Primärfarben und dicke schwarze Konturen.',
|
||||
dark: false,
|
||||
themeColor: '#ffd500',
|
||||
swatch: ['#fffbef', '#1e5eff', '#ffd500', '#e63b2e'],
|
||||
},
|
||||
schlicht: {
|
||||
label: 'Schlicht',
|
||||
description: 'Neutrales Grau, wie bis Version 0.3.',
|
||||
dark: false,
|
||||
themeColor: '#ffffff',
|
||||
swatch: ['#ffffff', '#171717', '#f5f5f5', '#a3a3a3'],
|
||||
},
|
||||
};
|
||||
|
||||
export function isDesign(value: unknown): value is Design {
|
||||
return typeof value === 'string' && (DESIGNS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
/** Cookie value to design; anything unknown falls back to the default. */
|
||||
export function resolveDesign(value: string | null | undefined): Design {
|
||||
return isDesign(value) ? value : DEFAULT_DESIGN;
|
||||
}
|
||||
|
||||
/** Number of placeholder tones every design defines (`--tone-1` ...). */
|
||||
export const TONE_COUNT = 5;
|
||||
|
||||
/** The manufacturers named in the brief get a fixed, recognisable tone. */
|
||||
const KNOWN_TONES: Record<string, number> = {
|
||||
lego: 1,
|
||||
playmobil: 2,
|
||||
cobi: 3,
|
||||
bluebrixx: 4,
|
||||
megabloks: 5,
|
||||
};
|
||||
|
||||
/**
|
||||
* Stable tone for a set without an image, derived from its manufacturer, so
|
||||
* all Lego placeholders share a colour and Playmobil gets another one.
|
||||
* Returns 1 ... TONE_COUNT.
|
||||
*/
|
||||
export function toneIndex(manufacturerName: string): number {
|
||||
const key = manufacturerName.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
const known = KNOWN_TONES[key];
|
||||
if (known) return known;
|
||||
|
||||
let hash = 0;
|
||||
for (const char of key) {
|
||||
hash = (hash * 31 + char.codePointAt(0)!) >>> 0;
|
||||
}
|
||||
return (hash % TONE_COUNT) + 1;
|
||||
}
|
||||
Reference in New Issue
Block a user