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 (