From 1b825b44c49234b5fc40c4e3f3ff7b48b5a200db Mon Sep 17 00:00:00 2001 From: unknown Date: Sun, 25 Oct 2026 15:01:33 +0100 Subject: [PATCH 1/3] 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 --- e2e/screenshots.spec.ts | 17 + package.json | 6 + pnpm-lock.yaml | 48 +++ src/app/(app)/katalog/[id]/page.tsx | 28 +- src/app/(app)/konto/actions.ts | 29 +- src/app/(app)/konto/page.tsx | 17 +- src/app/(app)/page.tsx | 220 ++++++++++--- src/app/(app)/sammlung/page.tsx | 28 +- src/app/(auth)/layout.tsx | 9 +- src/app/designs.css | 301 ++++++++++++++++++ src/app/globals.css | 35 +- src/app/layout.tsx | 41 ++- src/app/w/[shareToken]/page.tsx | 86 ++--- src/components/brick/logo.tsx | 18 ++ src/components/brick/set-placeholder.tsx | 66 ++++ src/components/brick/studs.tsx | 25 ++ src/components/katalog/catalog-grid.tsx | 3 +- src/components/katalog/set-card.tsx | 101 +++--- src/components/konto/design-picker.tsx | 88 +++++ src/components/layout/app-header.tsx | 9 +- src/components/layout/bottom-nav.tsx | 20 +- .../wunschlisten/public-wishlist.tsx | 156 ++++----- .../wunschlisten/wishlist-items.tsx | 13 +- src/lib/brick-row.ts | 48 +++ src/lib/design.ts | 110 +++++++ tests/unit/design.test.ts | 80 +++++ 26 files changed, 1343 insertions(+), 259 deletions(-) create mode 100644 src/app/designs.css create mode 100644 src/components/brick/logo.tsx create mode 100644 src/components/brick/set-placeholder.tsx create mode 100644 src/components/brick/studs.tsx create mode 100644 src/components/konto/design-picker.tsx create mode 100644 src/lib/brick-row.ts create mode 100644 src/lib/design.ts create mode 100644 tests/unit/design.test.ts diff --git a/e2e/screenshots.spec.ts b/e2e/screenshots.spec.ts index c0b784e..93f99be 100644 --- a/e2e/screenshots.spec.ts +++ b/e2e/screenshots.spec.ts @@ -19,6 +19,9 @@ function shotPath(name: string) { async function shoot(page: Page, name: string) { // Let fonts and images settle so the picture is not half-rendered. await page.waitForLoadState('networkidle'); + // A fixed bottom bar would be painted where the first viewport ends, in the + // middle of a full-page picture. In flow it lands at the end, where it belongs. + await page.addStyleTag({ content: '.fixed.bottom-0 { position: static !important; }' }); await page.screenshot({ path: shotPath(name), fullPage: true }); } @@ -117,6 +120,8 @@ test('Screenshots fuer die Anleitung', async ({ page, browser }) => { await visitor.getByRole('button', { name: 'Reservieren' }).click(); await expect(visitor.getByText('Reservierung gespeichert')).toBeVisible(); await visitor.getByRole('button', { name: 'Alles klar' }).click(); + // Otherwise the picture catches the dialog halfway through fading out. + await expect(visitor.getByRole('dialog')).toBeHidden(); await shoot(visitor, '14-reserviert'); await visitorContext.close(); @@ -129,6 +134,18 @@ test('Screenshots fuer die Anleitung', async ({ page, browser }) => { await expect(page.getByText('Passkeys', { exact: true })).toBeVisible(); await shoot(page, '16-konto-passkeys'); + // The same start page in the dark design, then back to the default. + await page.getByText('Nachtblau').click(); + await expect(page.locator('html')).toHaveAttribute('data-design', 'nacht'); + // The look switches at once; the cookie follows with the server action. + await page.waitForLoadState('networkidle'); + await visit(page, '/'); + await expect(page.locator('html')).toHaveAttribute('data-design', 'nacht'); + await shoot(page, '25-design-nachtblau'); + await visit(page, '/konto'); + await page.getByText('Klemmbaustein').click(); + await expect(page.locator('html')).toHaveAttribute('data-design', 'klemmbaustein'); + // --- 12. Lego import ------------------------------------------------------ // The screenshot container runs with a placeholder API key so the manual // shows the form, not the "not configured" hint. Nothing is submitted. diff --git a/package.json b/package.json index e593f1a..076b208 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,12 @@ }, "dependencies": { "@better-auth/passkey": "^1.7.3", + "@fontsource-variable/archivo": "^5.3.0", + "@fontsource-variable/bricolage-grotesque": "^5.3.0", + "@fontsource-variable/fredoka": "^5.3.0", + "@fontsource-variable/inter": "^5.3.0", + "@fontsource-variable/nunito": "^5.3.0", + "@fontsource-variable/outfit": "^5.3.0", "@zxing/browser": "^0.2.1", "@zxing/library": "^0.23.0", "better-auth": "^1.7.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8784d08..222e5fb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,24 @@ importers: '@better-auth/passkey': specifier: ^1.7.3 version: 1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.7.3(better-sqlite3@13.0.3)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3)(kysely@0.29.5))(next@15.5.25(@babel/core@7.29.7)(@playwright/test@1.63.0)(@types/node@20.19.43)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(vitest@5.0.0(@types/node@20.19.43)(vite@8.2.2(@types/node@20.19.43)(esbuild@0.25.12)(jiti@2.7.0)(tsx@4.23.13))))(better-call@1.4.0(zod@4.5.4))(nanostores@1.5.3) + '@fontsource-variable/archivo': + specifier: ^5.3.0 + version: 5.3.0 + '@fontsource-variable/bricolage-grotesque': + specifier: ^5.3.0 + version: 5.3.0 + '@fontsource-variable/fredoka': + specifier: ^5.3.0 + version: 5.3.0 + '@fontsource-variable/inter': + specifier: ^5.3.0 + version: 5.3.0 + '@fontsource-variable/nunito': + specifier: ^5.3.0 + version: 5.3.0 + '@fontsource-variable/outfit': + specifier: ^5.3.0 + version: 5.3.0 '@zxing/browser': specifier: ^0.2.1 version: 0.2.1(@zxing/library@0.23.0) @@ -887,6 +905,24 @@ packages: '@floating-ui/utils@0.2.12': resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} + '@fontsource-variable/archivo@5.3.0': + resolution: {integrity: sha512-HogK8FJelrD1o7TlZlkIVtHgc20bO5PZRWE7mUeUTdMN055alznQV6/00J00IBeu8FQAH4s3zW9UJNvKExXf+g==} + + '@fontsource-variable/bricolage-grotesque@5.3.0': + resolution: {integrity: sha512-TLi9Q4hJjS2UvoTMRSS2nHu6c4R56lAw60NR9QYtVRCHn0XtsFpiEhNffZ8Glsoxu6wEEwLKBP8lb94J52PNBA==} + + '@fontsource-variable/fredoka@5.3.0': + resolution: {integrity: sha512-13w98iw2RcmYfZQRZPLSu+0YJK3HHm5A2XrpU26moFIAePmfiKfvkmEJUgkc8r5GNR+pbDijOueS1KdGAMAWmQ==} + + '@fontsource-variable/inter@5.3.0': + resolution: {integrity: sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==} + + '@fontsource-variable/nunito@5.3.0': + resolution: {integrity: sha512-tw0ch7evIDSwoeUy+0XzocYHfbRWLeCRPJy22KgbrLjDLAbNw/fErBiil10Ssrs3Sjr55zaZrr36a975j8cBkQ==} + + '@fontsource-variable/outfit@5.3.0': + resolution: {integrity: sha512-fPb5XnYK0k7SbR++HEmm4+ZqDszmLKQ+sPRUr8oQ/KNC0vy6r6hazJFe8iYozv4cqlHbvJ/JECkJWrfDodUMVQ==} + '@hexagon/base64@1.1.28': resolution: {integrity: sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==} @@ -5659,6 +5695,18 @@ snapshots: '@floating-ui/utils@0.2.12': {} + '@fontsource-variable/archivo@5.3.0': {} + + '@fontsource-variable/bricolage-grotesque@5.3.0': {} + + '@fontsource-variable/fredoka@5.3.0': {} + + '@fontsource-variable/inter@5.3.0': {} + + '@fontsource-variable/nunito@5.3.0': {} + + '@fontsource-variable/outfit@5.3.0': {} + '@hexagon/base64@1.1.28': {} '@hono/node-server@2.1.1(hono@4.13.7)': diff --git a/src/app/(app)/katalog/[id]/page.tsx b/src/app/(app)/katalog/[id]/page.tsx index 8650504..527ec1d 100644 --- a/src/app/(app)/katalog/[id]/page.tsx +++ b/src/app/(app)/katalog/[id]/page.tsx @@ -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) {
-
- {image ? ( - // eslint-disable-next-line @next/next/no-img-element - - ) : ( -
- -
- )} +
+ +
+ {image ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( + + )} +
diff --git a/src/app/(app)/konto/actions.ts b/src/app/(app)/konto/actions.ts index 1cc91d6..cabfa87 100644 --- a/src/app/(app)/konto/actions.ts +++ b/src/app/(app)/konto/actions.ts @@ -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 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 { + 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 }; +} diff --git a/src/app/(app)/konto/page.tsx b/src/app/(app)/konto/page.tsx index a08252b..80116c0 100644 --- a/src/app/(app)/konto/page.tsx +++ b/src/app/(app)/konto/page.tsx @@ -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 (
@@ -45,6 +48,18 @@ export default async function AccountPage() { + + + Design + + Gilt für dieses Gerät. Öffentliche Wunschlisten sehen Gäste im Standard-Design. + + + + + + + Passkeys diff --git a/src/app/(app)/page.tsx b/src/app/(app)/page.tsx index dad922a..60b2f5f 100644 --- a/src/app/(app)/page.tsx +++ b/src/app/(app)/page.tsx @@ -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 ( -
-
-

Hallo {user.name}

-

Was möchtest du tun?

-
+ const stats = getCollectionStats(); + const missing = countMissingSets(); + const totalSets = stats.setCount + missing; -
- {shortcuts.map((shortcut) => ( - - - - - {shortcut.title} - {shortcut.description} - - + 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 ( +
+

Hallo {user.name}!

+ +
+ +
+
+

Unsere Sammlung

+

+ {formatNumber(stats.setCount)} von {pluralize(totalSets, 'Set', 'Sets')} +

+
+ +
    + {bricks.map((key, index) => ( +
  1. + {key === null ? ( + + ) : ( + <> + + + + )} +
  2. + ))} +
+ + {showCollectors ? ( +
    + {collectors.map((entry) => ( +
  • + + {entry.name} +
  • + ))} +
  • + + fehlt noch +
  • +
+ ) : null} +
+
+ +
+
+ +
+

+ {formatNumber(stats.pieceCount)} +

+

Teile verbaut

+
+
+
+ +
+

+ {formatEuro(stats.currentValueCents)} +

+

+ {stats.currentValueCents === stats.retailValueCents ? 'Wert nach UVP' : 'Aktueller Wert'} +

+
+
+
+ + {nextEvent?.eventDate ? ( + + +
+
+ + {formatDate(nextEvent.eventDate).slice(0, 5)} + +
+
+

+ {nextEvent.title} +

+

+ {daysLabel(daysUntil(nextEvent.eventDate, now))} ·{' '} + {nextEvent.openCount === 1 + ? '1 Wunsch noch offen' + : `${formatNumber(nextEvent.openCount)} Wünsche noch offen`} +

+
+ +
+ + ) : null} + +
+
+ + + +
); } diff --git a/src/app/(app)/sammlung/page.tsx b/src/app/(app)/sammlung/page.tsx index d9d0bfd..f69cd15 100644 --- a/src/app/(app)/sammlung/page.tsx +++ b/src/app/(app)/sammlung/page.tsx @@ -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
) : ( -
    +
      {items.map((item) => { const image = setImageSrc(item); return ( -
    • +
    • + {/* The studs carry the collector's colour once there is more than one. */} + 1 ? collectorDotClass(item.collectorColor) : undefined + } + /> -
      +
      {image ? ( // eslint-disable-next-line @next/next/no-img-element ) : ( -
      - -
      + )}
      diff --git a/src/app/(auth)/layout.tsx b/src/app/(auth)/layout.tsx index 09aa574..e5684a1 100644 --- a/src/app/(auth)/layout.tsx +++ b/src/app/(auth)/layout.tsx @@ -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 (
      - - + + Bricktrack
      {children}
      diff --git a/src/app/designs.css b/src/app/designs.css new file mode 100644 index 0000000..b2ffe40 --- /dev/null +++ b/src/app/designs.css @@ -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); + } +} diff --git a/src/app/globals.css b/src/app/globals.css index 6b1710d..4fbe71f 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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; } -} \ No newline at end of file + h1, + h2, + h3 { + font-family: var(--font-display); + } +} \ No newline at end of file diff --git a/src/app/layout.tsx b/src/app/layout.tsx index e8c1ba4..fd149f7 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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 { + 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 ( - + {children} diff --git a/src/app/w/[shareToken]/page.tsx b/src/app/w/[shareToken]/page.tsx index 2ebc158..34b8ceb 100644 --- a/src/app/w/[shareToken]/page.tsx +++ b/src/app/w/[shareToken]/page.tsx @@ -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 ( -
      -
      -
      - - Bricktrack -
      +
      +
      +
      +
      + + Bricktrack +
      -

      {list.title}

      +

      {list.title}

      -
      - - - {occasionLabel(list.occasion)} - - {list.eventDate ? ( - - - {formatDate(list.eventDate)} +
      + + + {occasionLabel(list.occasion)} - ) : null} - - {pluralize(openCount, 'Wunsch noch offen', 'Wünsche noch offen')} - + {list.eventDate ? ( + + + {formatDate(list.eventDate)} + + ) : null} + + {pluralize(openCount, 'Wunsch noch offen', 'Wünsche noch offen')} + +
      - - {list.note ? ( -

      {list.note}

      - ) : null} - -

      - Klick auf „Das schenke ich“, damit niemand dasselbe doppelt kauft. Eine Anmeldung - brauchst du dafür nicht. -

      -
      - {items.length === 0 ? ( -

      - Auf dieser Liste steht gerade nichts. -

      - ) : ( - - )} -
      +
      +
      + +
      + {list.note ?

      {list.note}

      : null} +

      + Klick auf „Das schenke ich“, damit niemand dasselbe doppelt kauft. Eine Anmeldung + brauchst du dafür nicht. +

      +
      +
      + +
      + {items.length === 0 ? ( +

      + Auf dieser Liste steht gerade nichts. +

      + ) : ( + + )} +
      +
      ); } diff --git a/src/components/brick/logo.tsx b/src/components/brick/logo.tsx new file mode 100644 index 0000000..1d36239 --- /dev/null +++ b/src/components/brick/logo.tsx @@ -0,0 +1,18 @@ +/** The app mark: a 2x1 brick in the design's logo colour. */ +export function BrickLogo({ className }: { className?: string }) { + return ( + + + + + + + + ); +} diff --git a/src/components/brick/set-placeholder.tsx b/src/components/brick/set-placeholder.tsx new file mode 100644 index 0000000..12c4a60 --- /dev/null +++ b/src/components/brick/set-placeholder.tsx @@ -0,0 +1,66 @@ +import { cn } from 'cn'; +import { toneIndex } from '@/lib/design'; + +const TONE_CLASSES: Record = { + 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 ( +
      + {size !== 'sm' ? ( + + + + + + + + ) : null} + 6 ? 'text-lg' : 'text-2xl'), + size === 'lg' && 'text-5xl', + )} + > + {setNumber} + +
      + ); +} diff --git a/src/components/brick/studs.tsx b/src/components/brick/studs.tsx new file mode 100644 index 0000000..06f5c1f --- /dev/null +++ b/src/components/brick/studs.tsx @@ -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 ( + + {Array.from({ length: count }, (_, index) => ( + + ))} + + ); +} diff --git a/src/components/katalog/catalog-grid.tsx b/src/components/katalog/catalog-grid.tsx index c24b85c..3890ff2 100644 --- a/src/components/katalog/catalog-grid.tsx +++ b/src/components/katalog/catalog-grid.tsx @@ -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', )} > diff --git a/src/components/katalog/set-card.tsx b/src/components/katalog/set-card.tsx index 7b83260..5371a3d 100644 --- a/src/components/katalog/set-card.tsx +++ b/src/components/katalog/set-card.tsx @@ -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 ( -
      -
      - {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 - - ) : ( -
      - +
      + +
      +
      + {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 + + ) : ( + + )} + +
      + {owned ? ( + + + {set.ownedCopies > 1 ? `${set.ownedCopies}× vorhanden` : 'Vorhanden'} + + ) : null} + {set.wishlistCount > 0 ? ( + + + Auf Wunschliste + + ) : null}
      - )} - -
      - {owned ? ( - - - {set.ownedCopies > 1 ? `${set.ownedCopies}× vorhanden` : 'Vorhanden'} - - ) : null} - {set.wishlistCount > 0 ? ( - - - Auf Wunschliste - - ) : null}
      -
      -
      -
      - - {set.setNumber} - - {set.manufacturerName} -
      -

      {set.name}

      -
      - {[set.themeName, set.year].filter(Boolean).join(' · ')} - {set.retailPriceCents !== null ? ( - - {formatEuro(set.retailPriceCents)} +
      +
      + + {set.setNumber} + + {set.manufacturerName} +
      +

      {set.name}

      +
      + + {[set.themeName, set.year].filter(Boolean).join(' · ')} - ) : null} + {set.retailPriceCents !== null ? ( + + {formatEuro(set.retailPriceCents)} + + ) : null} +
      @@ -78,7 +83,7 @@ export function SetCard({ set, className }: { set: CatalogRow; className?: strin div]:hover:border-primary/40', + 'rounded-xl outline-none focus-visible:ring-3 focus-visible:ring-ring/50', className, )} > diff --git a/src/components/konto/design-picker.tsx b/src/components/konto/design-picker.tsx new file mode 100644 index 0000000..b0a12e1 --- /dev/null +++ b/src/components/konto/design-picker.tsx @@ -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(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 ( +
      + Design +
      + {DESIGNS.map((design) => { + const info = DESIGN_INFO[design]; + const active = design === selected; + return ( + + ); + })} +
      +
      + ); +} diff --git a/src/components/layout/app-header.tsx b/src/components/layout/app-header.tsx index bdb8d67..1d8b2fc 100644 --- a/src/components/layout/app-header.tsx +++ b/src/components/layout/app-header.tsx @@ -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 (
      - - + + Bricktrack
      diff --git a/src/components/layout/bottom-nav.tsx b/src/components/layout/bottom-nav.tsx index 80685cb..ff1a9b5 100644 --- a/src/components/layout/bottom-nav.tsx +++ b/src/components/layout/bottom-nav.tsx @@ -18,7 +18,7 @@ export function BottomNav() { return (