From 52d69e565481ae596687fcf8e8d19e2fc7442d31 Mon Sep 17 00:00:00 2001
From: unknown
Date: Mon, 7 Sep 2026 07:01:55 +0200
Subject: [PATCH 1/7] feat(einladungen): admin erzeugt einmal gueltige
registrierungslinks
Nach dem ersten Nutzer bleibt die Registrierung geschlossen; weitere
Konten entstehen nur ueber einen Einladungslink. Der Klartext-Token
wird genau einmal angezeigt, in der Datenbank liegt sein SHA-256-Hash.
Optional laesst sich eine Einladung an eine E-Mail-Adresse binden, die
Rolle und die Gueltigkeit sind waehlbar.
Die Freigabe zum Anlegen eines Kontos laeuft ueber AsyncLocalStorage:
Der Server-Action, der den Token geprueft hat, umschliesst damit den
signUp-Aufruf, und nur in diesem Kontext laesst der user-create-Hook von
better-auth die Registrierung zu. Die Rolle kommt aus der Einladung und
kann nicht ueber das Formular gesetzt werden.
Der Smoke-Test deckt den ganzen Weg ab: Admin erstellt die Einladung,
der zweite Elternteil registriert sich damit, bekommt die Rolle
"Mitglied" (und wird von der Einladungsverwaltung weggeleitet), und der
Link funktioniert kein zweites Mal.
Refs Phase 2
Co-Authored-By: Claude Opus 5 (1M context)
---
e2e/smoke.spec.ts | 47 ++++
.../(app)/verwaltung/einladungen/actions.ts | 44 ++++
src/app/(app)/verwaltung/einladungen/page.tsx | 22 ++
src/app/(app)/verwaltung/layout.tsx | 4 +-
src/app/(auth)/einladung/[token]/actions.ts | 63 ++++++
src/app/(auth)/einladung/[token]/page.tsx | 54 +++++
src/components/auth/invite-form.tsx | 83 +++++++
src/components/verwaltung/invite-list.tsx | 212 ++++++++++++++++++
src/components/verwaltung/verwaltung-nav.tsx | 12 +-
src/lib/auth.ts | 16 +-
src/lib/invites.ts | 140 ++++++++++++
11 files changed, 686 insertions(+), 11 deletions(-)
create mode 100644 src/app/(app)/verwaltung/einladungen/actions.ts
create mode 100644 src/app/(app)/verwaltung/einladungen/page.tsx
create mode 100644 src/app/(auth)/einladung/[token]/actions.ts
create mode 100644 src/app/(auth)/einladung/[token]/page.tsx
create mode 100644 src/components/auth/invite-form.tsx
create mode 100644 src/components/verwaltung/invite-list.tsx
create mode 100644 src/lib/invites.ts
diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts
index 9a4047f..c3f21cf 100644
--- a/e2e/smoke.spec.ts
+++ b/e2e/smoke.spec.ts
@@ -81,6 +81,53 @@ test('Registrieren, Set anlegen, Wunschliste teilen und reservieren', async ({ p
await visitorContext.close();
});
+test('Einladung: zweiter Elternteil bekommt ein eigenes Konto', async ({ page, browser }) => {
+ // The admin from the first test is still the only account, so log in.
+ await page.goto('/anmelden');
+ await page.getByLabel('E-Mail').fill('test@bricktrack.local');
+ await page.getByLabel('Passwort').fill('smoketest-passwort');
+ await page.getByRole('button', { name: 'Anmelden', exact: true }).click();
+ await expect(page.getByRole('heading', { name: 'Hallo Florian' })).toBeVisible();
+
+ // --- the admin issues an invitation --------------------------------------
+ await page.goto('/verwaltung/einladungen');
+ await page.getByRole('button', { name: 'Einladung erstellen' }).click();
+ await page.getByRole('button', { name: 'Erstellen' }).click();
+
+ const inviteUrl = await page
+ .getByRole('textbox', { name: 'Einladungslink' })
+ .inputValue();
+ expect(inviteUrl).toContain('/einladung/');
+ await page.getByRole('button', { name: 'Fertig' }).click();
+ await expect(page.getByText('Offen').first()).toBeVisible();
+
+ // --- the invited person registers ----------------------------------------
+ const guestContext = await browser.newContext();
+ const guest = await guestContext.newPage();
+ await guest.goto(inviteUrl);
+ await expect(guest.getByRole('button', { name: 'Konto anlegen' })).toBeVisible();
+
+ await guest.getByLabel('Name').fill('Mama');
+ await guest.getByLabel('E-Mail').fill('mama@bricktrack.local');
+ await guest.getByLabel('Passwort').fill('zweites-passwort');
+ await guest.getByRole('button', { name: 'Konto anlegen' }).click();
+
+ await expect(guest.getByRole('heading', { name: 'Hallo Mama' })).toBeVisible();
+
+ // A member is not an admin: no invitation management for them.
+ await guest.goto('/verwaltung/einladungen');
+ await expect(guest).toHaveURL(/\/$|\/anmelden/);
+
+ // --- the link is single use ----------------------------------------------
+ const secondTry = await browser.newContext();
+ const secondPage = await secondTry.newPage();
+ await secondPage.goto(inviteUrl);
+ await expect(secondPage.getByText('Einladung ungültig')).toBeVisible();
+
+ await guestContext.close();
+ await secondTry.close();
+});
+
test('Ohne Anmeldung fuehrt jeder geschuetzte Pfad zur Anmeldung', async ({ page }) => {
await page.goto('/sammlung');
await expect(page).toHaveURL(/\/anmelden|\/registrieren/);
diff --git a/src/app/(app)/verwaltung/einladungen/actions.ts b/src/app/(app)/verwaltung/einladungen/actions.ts
new file mode 100644
index 0000000..ee9544a
--- /dev/null
+++ b/src/app/(app)/verwaltung/einladungen/actions.ts
@@ -0,0 +1,44 @@
+'use server';
+
+import { revalidatePath } from 'next/cache';
+import { z } from 'zod';
+import { actionData, actionOk, parseForm, type ActionResult } from '@/lib/action-result';
+import { createInvite, deleteInvite, INVITE_VALID_DAYS } from '@/lib/invites';
+import { requireAdmin } from '@/lib/session';
+import { optionalText } from '@/lib/validation';
+
+const schema = z.object({
+ // Optional: when set, the invitation only works for that address.
+ email: optionalText(160),
+ role: z.enum(['admin', 'member']).default('member'),
+ validDays: z.coerce.number().int().min(1).max(90).default(INVITE_VALID_DAYS),
+});
+
+export async function createInviteAction(
+ input: unknown,
+): Promise> {
+ const admin = await requireAdmin();
+
+ const parsed = parseForm(schema, input);
+ if (!parsed.ok) return parsed;
+
+ const created = createInvite({
+ createdByUserId: admin.id,
+ email: parsed.data.email,
+ role: parsed.data.role,
+ validDays: parsed.data.validDays,
+ });
+
+ revalidatePath('/verwaltung/einladungen');
+ // The plaintext token is returned once and never stored - only its hash is.
+ return actionData({ token: created.token });
+}
+
+export async function deleteInviteAction(id: string): Promise {
+ await requireAdmin();
+
+ deleteInvite(id);
+
+ revalidatePath('/verwaltung/einladungen');
+ return actionOk();
+}
diff --git a/src/app/(app)/verwaltung/einladungen/page.tsx b/src/app/(app)/verwaltung/einladungen/page.tsx
new file mode 100644
index 0000000..f342e44
--- /dev/null
+++ b/src/app/(app)/verwaltung/einladungen/page.tsx
@@ -0,0 +1,22 @@
+import type { Metadata } from 'next';
+import { InviteList, type InviteListRow } from '@/components/verwaltung/invite-list';
+import { inviteState, listInvites } from '@/lib/invites';
+import { requireAdmin } from '@/lib/session';
+
+export const metadata: Metadata = { title: 'Einladungen' };
+
+export default async function EinladungenPage() {
+ await requireAdmin();
+
+ const invites: InviteListRow[] = listInvites().map((row) => ({
+ id: row.id,
+ email: row.email,
+ role: row.role,
+ expiresAt: row.expiresAt,
+ usedAt: row.usedAt,
+ usedByName: row.usedByName,
+ state: inviteState(row),
+ }));
+
+ return ;
+}
diff --git a/src/app/(app)/verwaltung/layout.tsx b/src/app/(app)/verwaltung/layout.tsx
index 7a57af8..ae01e31 100644
--- a/src/app/(app)/verwaltung/layout.tsx
+++ b/src/app/(app)/verwaltung/layout.tsx
@@ -5,7 +5,7 @@ import { requireUser } from '@/lib/session';
export const metadata: Metadata = { title: 'Verwaltung' };
export default async function VerwaltungLayout({ children }: { children: React.ReactNode }) {
- await requireUser();
+ const user = await requireUser();
return (
@@ -15,7 +15,7 @@ export default async function VerwaltungLayout({ children }: { children: React.R
Stammdaten, die im Katalog und in der Sammlung ausgewählt werden.
-
+
{children}
);
diff --git a/src/app/(auth)/einladung/[token]/actions.ts b/src/app/(auth)/einladung/[token]/actions.ts
new file mode 100644
index 0000000..3f39573
--- /dev/null
+++ b/src/app/(auth)/einladung/[token]/actions.ts
@@ -0,0 +1,63 @@
+'use server';
+
+import { headers } from 'next/headers';
+import { z } from 'zod';
+import { actionError, actionOk, parseForm, type ActionResult } from '@/lib/action-result';
+import { auth } from '@/lib/auth';
+import { findUsableInvite, markInviteUsed, withInviteGrant } from '@/lib/invites';
+
+const schema = z.object({
+ token: z.string().trim().min(1),
+ name: z.string().trim().min(1, 'Bitte gib deinen Namen an.').max(80),
+ email: z.email('Bitte gib eine gültige E-Mail-Adresse an.'),
+ password: z.string().min(10, 'Das Passwort braucht mindestens 10 Zeichen.'),
+});
+
+/**
+ * Registration through an invitation link.
+ *
+ * The token is checked here, on the server, and only then is the sign-up
+ * wrapped in a grant that the user-create hook can see. The role comes from
+ * the invitation, never from the submitted form.
+ */
+export async function registerWithInvite(input: unknown): Promise {
+ const parsed = parseForm(schema, input);
+ if (!parsed.ok) return parsed;
+
+ const invitation = findUsableInvite(parsed.data.token);
+ if (!invitation) {
+ return actionError('Diese Einladung ist abgelaufen oder wurde bereits verwendet.');
+ }
+
+ if (invitation.email && invitation.email.toLowerCase() !== parsed.data.email.toLowerCase()) {
+ return actionError(`Diese Einladung gilt für ${invitation.email}.`);
+ }
+
+ const requestHeaders = await headers();
+
+ try {
+ const result = await withInviteGrant(
+ { inviteId: invitation.id, role: invitation.role },
+ () =>
+ auth.api.signUpEmail({
+ body: {
+ name: parsed.data.name,
+ email: parsed.data.email,
+ password: parsed.data.password,
+ },
+ headers: requestHeaders,
+ }),
+ );
+
+ markInviteUsed(invitation.id, result.user.id);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : '';
+ if (message.toLowerCase().includes('exist')) {
+ return actionError('Zu dieser E-Mail-Adresse gibt es schon ein Konto.');
+ }
+ console.error('[invite] Registrierung fehlgeschlagen', error);
+ return actionError('Die Registrierung hat nicht geklappt. Bitte versuch es noch einmal.');
+ }
+
+ return actionOk();
+}
diff --git a/src/app/(auth)/einladung/[token]/page.tsx b/src/app/(auth)/einladung/[token]/page.tsx
new file mode 100644
index 0000000..ef65089
--- /dev/null
+++ b/src/app/(auth)/einladung/[token]/page.tsx
@@ -0,0 +1,54 @@
+import type { Metadata } from 'next';
+import Link from 'next/link';
+import { redirect } from 'next/navigation';
+import { InviteForm } from '@/components/auth/invite-form';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { formatDate } from '@/lib/format';
+import { findUsableInvite } from '@/lib/invites';
+import { getSession } from '@/lib/session';
+
+export const metadata: Metadata = {
+ title: 'Einladung',
+ robots: { index: false, follow: false },
+};
+
+export default async function InvitePage({ params }: { params: Promise<{ token: string }> }) {
+ if (await getSession()) redirect('/');
+
+ const { token } = await params;
+ const invitation = findUsableInvite(token);
+
+ if (!invitation) {
+ return (
+
+
+ Einladung ungültig
+
+ Dieser Link ist abgelaufen oder wurde schon benutzt. Lass dir einfach einen neuen
+ schicken.
+
+
+
+
+ Zur Anmeldung
+
+
+
+ );
+ }
+
+ return (
+
+
+ Konto anlegen
+
+ Du wurdest zu Bricktrack eingeladen. Der Link gilt noch bis zum{' '}
+ {formatDate(invitation.expiresAt)}.
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/auth/invite-form.tsx b/src/components/auth/invite-form.tsx
new file mode 100644
index 0000000..27a4321
--- /dev/null
+++ b/src/components/auth/invite-form.tsx
@@ -0,0 +1,83 @@
+'use client';
+
+import { useRouter } from 'next/navigation';
+import { useState, useTransition } from 'react';
+import { Loader2 } from 'lucide-react';
+import { toast } from 'sonner';
+import { registerWithInvite } from '@/app/(auth)/einladung/[token]/actions';
+import { Button } from '@/components/ui/button';
+import { Field } from '@/components/ui/field';
+import { Input } from '@/components/ui/input';
+
+const MIN_PASSWORD_LENGTH = 10;
+
+export function InviteForm({ token, email }: { token: string; email: string | null }) {
+ const router = useRouter();
+ const [pending, startTransition] = useTransition();
+ const [password, setPassword] = useState('');
+
+ function onSubmit(event: React.FormEvent) {
+ event.preventDefault();
+ const input = Object.fromEntries(new FormData(event.currentTarget).entries());
+
+ startTransition(async () => {
+ const result = await registerWithInvite({ ...input, token });
+ if (!result.ok) {
+ toast.error(result.error);
+ return;
+ }
+ toast.success('Konto angelegt. Willkommen bei Bricktrack!');
+ router.push('/');
+ router.refresh();
+ });
+ }
+
+ return (
+
+ );
+}
diff --git a/src/components/verwaltung/invite-list.tsx b/src/components/verwaltung/invite-list.tsx
new file mode 100644
index 0000000..de7440a
--- /dev/null
+++ b/src/components/verwaltung/invite-list.tsx
@@ -0,0 +1,212 @@
+'use client';
+
+import { useRouter } from 'next/navigation';
+import { useState, useTransition } from 'react';
+import { Check, Copy, Loader2, Mail, Plus, Trash2 } from 'lucide-react';
+import { toast } from 'sonner';
+import {
+ createInviteAction,
+ deleteInviteAction,
+} from '@/app/(app)/verwaltung/einladungen/actions';
+import { formatDate } from '@/lib/format';
+import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import { Field } from '@/components/ui/field';
+import { Input } from '@/components/ui/input';
+import { NativeSelect } from '@/components/ui/native-select';
+
+export type InviteListRow = {
+ id: string;
+ email: string | null;
+ role: 'admin' | 'member';
+ expiresAt: Date;
+ usedAt: Date | null;
+ usedByName: string | null;
+ state: 'open' | 'used' | 'expired';
+};
+
+export function InviteList({ invites }: { invites: InviteListRow[] }) {
+ const router = useRouter();
+ const [pending, startTransition] = useTransition();
+ const [createOpen, setCreateOpen] = useState(false);
+ const [link, setLink] = useState(null);
+ const [copied, setCopied] = useState(false);
+
+ function submit(event: React.FormEvent) {
+ event.preventDefault();
+ const input = Object.fromEntries(new FormData(event.currentTarget).entries());
+
+ startTransition(async () => {
+ const result = await createInviteAction(input);
+ if (!result.ok) {
+ toast.error(result.error);
+ return;
+ }
+ setCreateOpen(false);
+ setLink(`${window.location.origin}/einladung/${result.data.token}`);
+ router.refresh();
+ });
+ }
+
+ function remove(id: string) {
+ startTransition(async () => {
+ const result = await deleteInviteAction(id);
+ if (!result.ok) {
+ toast.error(result.error);
+ return;
+ }
+ toast.success('Einladung gelöscht.');
+ router.refresh();
+ });
+ }
+
+ async function copy() {
+ if (!link) return;
+ try {
+ await navigator.clipboard.writeText(link);
+ setCopied(true);
+ toast.success('Link kopiert.');
+ setTimeout(() => setCopied(false), 2000);
+ } catch {
+ toast.error('Kopieren hat nicht geklappt. Markiere den Link und kopiere ihn von Hand.');
+ }
+ }
+
+ return (
+
+
setCreateOpen(true)}>
+
+ Einladung erstellen
+
+
+ {invites.length === 0 ? (
+
+ Noch keine Einladung. Damit bekommt zum Beispiel der zweite Elternteil ein eigenes
+ Konto – ohne dass die Registrierung für alle offen steht.
+
+ ) : (
+
+ {invites.map((item) => (
+
+
+
+
+ {item.email ?? 'Für beliebige Adresse'}
+
+
+ {item.state === 'used'
+ ? `Verwendet von ${item.usedByName ?? 'jemandem'} am ${formatDate(item.usedAt)}`
+ : `Gültig bis ${formatDate(item.expiresAt)}`}
+
+
+
+
+ {item.state === 'open' ? 'Offen' : item.state === 'used' ? 'Verwendet' : 'Abgelaufen'}
+
+ {item.role === 'admin' ? Admin : null}
+
+ remove(item.id)}
+ disabled={pending}
+ >
+
+
+
+ ))}
+
+ )}
+
+
+
+
+ Einladung erstellen
+
+ Du bekommst einen Link, den du weitergibst. Er funktioniert genau einmal.
+
+
+
+
+
+
+ setCreateOpen(false)} disabled={pending}>
+ Abbrechen
+
+
+ {pending ? : }
+ Erstellen
+
+
+
+
+
+
!open && setLink(null)}>
+
+
+ Einladungslink
+
+ Der Link wird nur jetzt angezeigt – in der Datenbank liegt nur sein Hash. Kopiere
+ ihn und schick ihn weiter.
+
+
+
+ event.currentTarget.select()}
+ className="font-mono text-xs"
+ />
+
+
+
+ {copied ? : }
+ Link kopieren
+
+ setLink(null)}>Fertig
+
+
+
+
+ );
+}
diff --git a/src/components/verwaltung/verwaltung-nav.tsx b/src/components/verwaltung/verwaltung-nav.tsx
index fd83275..017ac23 100644
--- a/src/components/verwaltung/verwaltung-nav.tsx
+++ b/src/components/verwaltung/verwaltung-nav.tsx
@@ -5,17 +5,19 @@ import { usePathname } from 'next/navigation';
import { cn } from 'cn';
const tabs = [
- { href: '/verwaltung/hersteller', label: 'Hersteller' },
- { href: '/verwaltung/themen', label: 'Themen' },
- { href: '/verwaltung/standorte', label: 'Standorte' },
+ { href: '/verwaltung/hersteller', label: 'Hersteller', adminOnly: false },
+ { href: '/verwaltung/themen', label: 'Themen', adminOnly: false },
+ { href: '/verwaltung/standorte', label: 'Standorte', adminOnly: false },
+ { href: '/verwaltung/einladungen', label: 'Einladungen', adminOnly: true },
] as const;
-export function VerwaltungNav() {
+export function VerwaltungNav({ isAdmin }: { isAdmin: boolean }) {
const pathname = usePathname();
+ const visible = tabs.filter((tab) => !tab.adminOnly || isAdmin);
return (
- {tabs.map((tab) => {
+ {visible.map((tab) => {
const active = pathname === tab.href;
return (
{
- const firstRun = isFirstRun();
- if (!firstRun) {
- // Phase 2 opens this up again for invited users.
+ // The very first account owns the instance.
+ if (isFirstRun()) {
+ return { data: { ...user, role: 'admin' as const } };
+ }
+
+ // Everyone after that needs an invitation. The grant is set by the
+ // server action that verified the link token, never by the client.
+ const grant = currentInviteGrant();
+ if (!grant) {
throw new APIError('FORBIDDEN', {
message:
'Die Registrierung ist geschlossen. Bitte lass dir vom Administrator einen Einladungslink schicken.',
});
}
- return { data: { ...user, role: 'admin' as const } };
+
+ return { data: { ...user, role: grant.role } };
},
},
},
diff --git a/src/lib/invites.ts b/src/lib/invites.ts
new file mode 100644
index 0000000..31a7cb2
--- /dev/null
+++ b/src/lib/invites.ts
@@ -0,0 +1,140 @@
+import 'server-only';
+import { AsyncLocalStorage } from 'node:async_hooks';
+import { and, desc, eq, isNull, gt } from 'drizzle-orm';
+import { db } from '@/db';
+import { invite, user } from '@/db/schema';
+import type { UserRole } from '@/db/types';
+import { createToken, hashToken } from './tokens';
+
+/**
+ * Invitations.
+ *
+ * After the first user, sign-up is closed. An admin issues a link; the
+ * plaintext token only ever lives in that link, the database keeps its
+ * SHA-256 hash.
+ */
+
+export type InviteGrant = { inviteId: string; role: UserRole };
+
+/**
+ * Marks the current call stack as "covered by a valid invitation".
+ *
+ * better-auth's user-create hook has no way of receiving extra arguments, and
+ * the sign-up payload must not be trusted with a role. AsyncLocalStorage keeps
+ * the grant tied to the one server action that verified the token - it can
+ * neither leak into another request nor be forged from the client.
+ */
+const grantStorage = new AsyncLocalStorage();
+
+export function withInviteGrant(grant: InviteGrant, fn: () => Promise): Promise {
+ return grantStorage.run(grant, fn);
+}
+
+export function currentInviteGrant(): InviteGrant | undefined {
+ return grantStorage.getStore();
+}
+
+export const INVITE_VALID_DAYS = 14;
+
+export function createInvite(input: {
+ createdByUserId: string;
+ email: string | null;
+ role: UserRole;
+ validDays?: number;
+}): { id: string; token: string; expiresAt: Date } {
+ const token = createToken();
+ const expiresAt = new Date(
+ Date.now() + (input.validDays ?? INVITE_VALID_DAYS) * 24 * 60 * 60 * 1000,
+ );
+ const id = crypto.randomUUID();
+
+ db.insert(invite)
+ .values({
+ id,
+ tokenHash: hashToken(token),
+ email: input.email,
+ role: input.role,
+ createdByUserId: input.createdByUserId,
+ expiresAt,
+ createdAt: new Date(),
+ })
+ .run();
+
+ return { id, token, expiresAt };
+}
+
+export type UsableInvite = {
+ id: string;
+ email: string | null;
+ role: UserRole;
+ expiresAt: Date;
+};
+
+/** Returns the invite behind a link token, or null if it cannot be used. */
+export function findUsableInvite(token: string): UsableInvite | null {
+ if (!token) return null;
+
+ const row = db
+ .select({
+ id: invite.id,
+ email: invite.email,
+ role: invite.role,
+ expiresAt: invite.expiresAt,
+ })
+ .from(invite)
+ .where(
+ and(
+ eq(invite.tokenHash, hashToken(token)),
+ isNull(invite.usedAt),
+ gt(invite.expiresAt, new Date()),
+ ),
+ )
+ .get();
+
+ return row ?? null;
+}
+
+export function markInviteUsed(inviteId: string, userId: string): void {
+ db.update(invite)
+ .set({ usedAt: new Date(), usedByUserId: userId })
+ .where(eq(invite.id, inviteId))
+ .run();
+}
+
+export function deleteInvite(inviteId: string): void {
+ db.delete(invite).where(eq(invite.id, inviteId)).run();
+}
+
+export type InviteRow = {
+ id: string;
+ email: string | null;
+ role: UserRole;
+ expiresAt: Date;
+ usedAt: Date | null;
+ usedByName: string | null;
+ createdAt: Date;
+};
+
+export function listInvites(): InviteRow[] {
+ return db
+ .select({
+ id: invite.id,
+ email: invite.email,
+ role: invite.role,
+ expiresAt: invite.expiresAt,
+ usedAt: invite.usedAt,
+ usedByName: user.name,
+ createdAt: invite.createdAt,
+ })
+ .from(invite)
+ .leftJoin(user, eq(user.id, invite.usedByUserId))
+ .orderBy(desc(invite.createdAt))
+ .all();
+}
+
+/** An invite is only interesting while it can still be used. */
+export function inviteState(row: InviteRow): 'used' | 'expired' | 'open' {
+ if (row.usedAt) return 'used';
+ if (row.expiresAt.getTime() <= Date.now()) return 'expired';
+ return 'open';
+}
From 4e3cbb81ce61fb303c5204711ab6f56c9a7e208c Mon Sep 17 00:00:00 2001
From: unknown
Date: Mon, 7 Sep 2026 07:12:22 +0200
Subject: [PATCH 2/7] feat(import): lego-sets ueber die rebrickable-api
uebernehmen
Auf "Neues Set" reicht die Setnummer von der Packung: Name, Jahr,
Teile, Minifiguren und Thema kommen aus der API, das Bild wird lokal
gespeichert und nicht nur verlinkt. Hersteller und Thema werden bei
Bedarf angelegt, ein Oberthema inklusive. Alle Aufrufe laufen
serverseitig; der Browser spricht nie mit rebrickable.com.
Ohne REBRICKABLE_API_KEY bleibt der Import sichtbar, aber abgeschaltet,
mit dem Hinweis, was zu tun ist - von Hand anlegen geht unveraendert.
Rebrickable liefert keine Anleitungsdateien. Statt das zu verschleiern,
legt der Import einen externen Link auf die LEGO-Serviceseite zur
Setnummer an.
Nebenbei: src/env.ts behandelt leere Werte jetzt wie nicht gesetzt - ein
"BASE_URL=" in der .env fuehrte sonst zum Start-Abbruch mit "Invalid
URL" statt zum Standardwert.
14 neue Unit-Tests decken Antwortverarbeitung, Setnummern-Varianten und
alle Fehlerfaelle der API ab (404, 401, 429, Netzwerk).
Refs Phase 2
Co-Authored-By: Claude Opus 5 (1M context)
---
.env.example | 6 +-
src/app/(app)/katalog/import-actions.ts | 237 ++++++++++++++++++
src/app/(app)/katalog/neu/page.tsx | 21 +-
src/components/katalog/rebrickable-import.tsx | 95 +++++++
src/env.ts | 17 +-
src/lib/rebrickable.ts | 140 +++++++++++
src/lib/set-number.ts | 32 +++
src/lib/uploads.ts | 19 +-
tests/setup-env.ts | 17 ++
tests/stubs/server-only.ts | 8 +
tests/unit/rebrickable.test.ts | 179 +++++++++++++
tests/unit/set-number.test.ts | 44 ++++
vitest.config.mts | 4 +
13 files changed, 813 insertions(+), 6 deletions(-)
create mode 100644 src/app/(app)/katalog/import-actions.ts
create mode 100644 src/components/katalog/rebrickable-import.tsx
create mode 100644 src/lib/rebrickable.ts
create mode 100644 src/lib/set-number.ts
create mode 100644 tests/setup-env.ts
create mode 100644 tests/stubs/server-only.ts
create mode 100644 tests/unit/rebrickable.test.ts
create mode 100644 tests/unit/set-number.test.ts
diff --git a/.env.example b/.env.example
index 1306964..75817ac 100644
--- a/.env.example
+++ b/.env.example
@@ -27,6 +27,8 @@ TZ=Europe/Berlin
# BIND_ADDRESS=127.0.0.1
# HOST_PORT=3000
-# Optional (ab Phase 2): Rebrickable-API-Key fuer den Lego-Import.
-# Ohne Key funktioniert alles manuell weiter.
+# Optional: Rebrickable-API-Key fuer den Lego-Import. Damit reicht auf der
+# Seite "Neues Set" die Setnummer, und Name, Jahr, Teile, Minifiguren, Thema
+# und Bild werden uebernommen. Ohne Key funktioniert alles manuell weiter.
+# Kostenlos anlegen: https://rebrickable.com/api/ (Konto -> Settings -> API)
# REBRICKABLE_API_KEY=
diff --git a/src/app/(app)/katalog/import-actions.ts b/src/app/(app)/katalog/import-actions.ts
new file mode 100644
index 0000000..c8b9aee
--- /dev/null
+++ b/src/app/(app)/katalog/import-actions.ts
@@ -0,0 +1,237 @@
+'use server';
+
+import { revalidatePath } from 'next/cache';
+import { and, eq } from 'drizzle-orm';
+import { db } from '@/db';
+import { instruction, manufacturer, set, theme } from '@/db/schema';
+import { actionData, actionError, type ActionResult } from '@/lib/action-result';
+import {
+ fetchImageBytes,
+ fetchMinifigCount,
+ fetchSet,
+ fetchTheme,
+ isRebrickableEnabled,
+ legoInstructionsUrl,
+ RebrickableError,
+} from '@/lib/rebrickable';
+import { requireUser } from '@/lib/session';
+import { uniqueSlug } from '@/lib/slug';
+import { storeImageBytes, UploadError } from '@/lib/uploads';
+
+/**
+ * Import of a Lego set from Rebrickable.
+ *
+ * Runs entirely on the server, creates the manufacturer and the theme if they
+ * are missing, and stores the picture locally so the catalog keeps working
+ * when Rebrickable changes it.
+ */
+
+const LEGO_SLUG = 'lego';
+
+function now() {
+ return new Date();
+}
+
+/** Returns the Lego manufacturer, creating it on first import. */
+function ensureLegoManufacturer(): string {
+ const existing = db
+ .select({ id: manufacturer.id })
+ .from(manufacturer)
+ .where(eq(manufacturer.slug, LEGO_SLUG))
+ .get();
+ if (existing) return existing.id;
+
+ const taken = db.select({ slug: manufacturer.slug }).from(manufacturer).all();
+ const id = crypto.randomUUID();
+
+ db.insert(manufacturer)
+ .values({
+ id,
+ name: 'Lego',
+ slug: uniqueSlug('Lego', taken.map((row) => row.slug)),
+ website: 'https://www.lego.com/de-de',
+ createdAt: now(),
+ updatedAt: now(),
+ })
+ .run();
+
+ return id;
+}
+
+/**
+ * Maps a Rebrickable theme onto a local one, creating it (and its parent) when
+ * needed. Rebrickable nests themes deeply; one level of parent is enough to
+ * keep "Technic > Cars" readable without importing the whole tree.
+ */
+async function ensureTheme(manufacturerId: string, themeId: number | null): Promise {
+ if (themeId === null) return null;
+
+ const remote = await fetchTheme(themeId);
+ if (!remote) return null;
+
+ const existing = db
+ .select({ id: theme.id })
+ .from(theme)
+ .where(and(eq(theme.manufacturerId, manufacturerId), eq(theme.name, remote.name)))
+ .get();
+ if (existing) return existing.id;
+
+ let parentThemeId: string | null = null;
+ if (remote.parentId !== null) {
+ const parent = await fetchTheme(remote.parentId);
+ if (parent) {
+ const existingParent = db
+ .select({ id: theme.id })
+ .from(theme)
+ .where(and(eq(theme.manufacturerId, manufacturerId), eq(theme.name, parent.name)))
+ .get();
+
+ if (existingParent) {
+ parentThemeId = existingParent.id;
+ } else {
+ parentThemeId = crypto.randomUUID();
+ db.insert(theme)
+ .values({
+ id: parentThemeId,
+ manufacturerId,
+ name: parent.name,
+ createdAt: now(),
+ updatedAt: now(),
+ })
+ .run();
+ }
+ }
+ }
+
+ const id = crypto.randomUUID();
+ db.insert(theme)
+ .values({
+ id,
+ manufacturerId,
+ name: remote.name,
+ parentThemeId,
+ createdAt: now(),
+ updatedAt: now(),
+ })
+ .run();
+
+ return id;
+}
+
+export type ImportResult = {
+ setId: string;
+ setNumber: string;
+ name: string;
+ imported: {
+ image: boolean;
+ theme: boolean;
+ minifigs: boolean;
+ };
+};
+
+export async function importSetFromRebrickable(
+ rawSetNumber: unknown,
+): Promise> {
+ await requireUser();
+
+ if (!isRebrickableEnabled()) {
+ return actionError(
+ 'Der Import ist nicht eingerichtet. Trag REBRICKABLE_API_KEY in der .env ein und starte den Container neu.',
+ );
+ }
+
+ const setNumber = typeof rawSetNumber === 'string' ? rawSetNumber.trim() : '';
+ if (!setNumber) return actionError('Bitte gib eine Setnummer ein.');
+ if (setNumber.length > 40) return actionError('Das sieht nicht nach einer Setnummer aus.');
+
+ let remote;
+ try {
+ remote = await fetchSet(setNumber);
+ } catch (error) {
+ if (error instanceof RebrickableError) return actionError(error.message);
+ throw error;
+ }
+
+ if (!remote) {
+ return actionError(
+ `Rebrickable kennt die Setnummer ${setNumber} nicht. Du kannst das Set von Hand anlegen.`,
+ );
+ }
+
+ const manufacturerId = ensureLegoManufacturer();
+
+ const duplicate = db
+ .select({ id: set.id })
+ .from(set)
+ .where(and(eq(set.manufacturerId, manufacturerId), eq(set.setNumber, remote.setNumber)))
+ .get();
+ if (duplicate) {
+ return actionError(`${remote.setNumber} ist schon im Katalog: ${remote.name}.`);
+ }
+
+ const themeId = await ensureTheme(manufacturerId, remote.themeId);
+ const minifigCount = await fetchMinifigCount(remote.setNumber);
+
+ const setId = crypto.randomUUID();
+
+ // Cache the picture locally; a failed download must not fail the import.
+ let imagePath: string | null = null;
+ if (remote.imageUrl) {
+ const bytes = await fetchImageBytes(remote.imageUrl);
+ if (bytes) {
+ try {
+ imagePath = (await storeImageBytes(bytes, 'set-image', setId)).filePath;
+ } catch (error) {
+ if (!(error instanceof UploadError)) throw error;
+ console.error('[import] Bild konnte nicht gespeichert werden', error);
+ }
+ }
+ }
+
+ db.insert(set)
+ .values({
+ id: setId,
+ manufacturerId,
+ themeId,
+ setNumber: remote.setNumber,
+ name: remote.name,
+ year: remote.year,
+ pieceCount: remote.pieceCount,
+ minifigCount,
+ imagePath,
+ // Keep the source URL as a fallback if the local file is ever lost.
+ imageUrl: imagePath ? null : remote.imageUrl,
+ externalIds: { rebrickable: `${remote.setNumber}-1` },
+ createdAt: now(),
+ updatedAt: now(),
+ })
+ .run();
+
+ // Rebrickable does not serve instruction files, so this points at Lego's
+ // own service page for the set number.
+ db.insert(instruction)
+ .values({
+ id: crypto.randomUUID(),
+ setId,
+ kind: 'external_url',
+ url: legoInstructionsUrl(remote.setNumber),
+ label: 'Bauanleitung bei LEGO',
+ sortOrder: 0,
+ createdAt: now(),
+ })
+ .run();
+
+ revalidatePath('/katalog');
+ revalidatePath('/verwaltung/themen');
+
+ return actionData({
+ setId,
+ setNumber: remote.setNumber,
+ name: remote.name,
+ imported: {
+ image: imagePath !== null,
+ theme: themeId !== null,
+ minifigs: minifigCount !== null,
+ },
+ });
+}
diff --git a/src/app/(app)/katalog/neu/page.tsx b/src/app/(app)/katalog/neu/page.tsx
index 07b544a..8abdeab 100644
--- a/src/app/(app)/katalog/neu/page.tsx
+++ b/src/app/(app)/katalog/neu/page.tsx
@@ -1,8 +1,10 @@
import type { Metadata } from 'next';
import Link from 'next/link';
+import { RebrickableImport } from '@/components/katalog/rebrickable-import';
import { SetForm } from '@/components/katalog/set-form';
import { Button } from '@/components/ui/button';
import { listManufacturers, listThemes } from '@/lib/queries/master-data';
+import { isRebrickableEnabled } from '@/lib/rebrickable';
import { requireUser } from '@/lib/session';
export const metadata: Metadata = { title: 'Neues Set' };
@@ -21,12 +23,19 @@ export default async function NeuesSetPage({ searchParams }: { searchParams: Sea
manufacturerId: row.manufacturerId,
}));
+ const importEnabled = isRebrickableEnabled();
+
if (manufacturers.length === 0) {
return (
-
+
Neues Set
+
+ {/* The Lego import creates its manufacturer itself, so it works even
+ on a completely empty instance. */}
+
+
- Jedes Set gehört zu einem Hersteller. Leg zuerst einen an – Lego, Playmobil, Cobi oder
+ Für ein Set von Hand brauchst du zuerst einen Hersteller – Lego, Playmobil, Cobi oder
was auch immer im Kinderzimmer steht.
@@ -45,6 +54,14 @@ export default async function NeuesSetPage({ searchParams }: { searchParams: Sea
Sammlung hinzufügen“ fest.
+
+
+
+
+ oder von Hand
+
+
+
);
diff --git a/src/components/katalog/rebrickable-import.tsx b/src/components/katalog/rebrickable-import.tsx
new file mode 100644
index 0000000..ff0ce18
--- /dev/null
+++ b/src/components/katalog/rebrickable-import.tsx
@@ -0,0 +1,95 @@
+'use client';
+
+import { useRouter } from 'next/navigation';
+import { useState, useTransition } from 'react';
+import { Download, Loader2 } from 'lucide-react';
+import { toast } from 'sonner';
+import { importSetFromRebrickable } from '@/app/(app)/katalog/import-actions';
+import { Button } from '@/components/ui/button';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { Field } from '@/components/ui/field';
+import { Input } from '@/components/ui/input';
+
+/**
+ * One-field Lego import: type the number from the box, get a finished catalog
+ * entry. Hidden entirely when no API key is configured.
+ */
+export function RebrickableImport({ enabled }: { enabled: boolean }) {
+ const router = useRouter();
+ const [setNumber, setSetNumber] = useState('');
+ const [pending, startTransition] = useTransition();
+
+ if (!enabled) {
+ return (
+
+
+ Lego-Import
+
+ Mit einem Rebrickable-API-Key füllt sich das Formular von selbst: Setnummer eingeben,
+ und Name, Jahr, Teile, Thema und Bild kommen automatisch. Dafür{' '}
+ REBRICKABLE_API_KEY in die{' '}
+ .env eintragen und den Container neu
+ starten.
+
+
+
+ );
+ }
+
+ function submit(event: React.FormEvent) {
+ event.preventDefault();
+
+ startTransition(async () => {
+ const result = await importSetFromRebrickable(setNumber);
+ if (!result.ok) {
+ toast.error(result.error);
+ return;
+ }
+
+ const missing = [
+ result.data.imported.image ? null : 'Bild',
+ result.data.imported.theme ? null : 'Thema',
+ ].filter(Boolean);
+
+ toast.success(
+ missing.length === 0
+ ? `${result.data.setNumber} „${result.data.name}“ übernommen.`
+ : `${result.data.setNumber} übernommen – ohne ${missing.join(' und ')}.`,
+ );
+
+ router.push(`/katalog/${result.data.setId}`);
+ router.refresh();
+ });
+ }
+
+ return (
+
+
+ Lego-Import
+
+ Setnummer von der Packung eintippen – Name, Jahr, Teile, Minifiguren, Thema und Bild
+ werden übernommen. Das Bild wird lokal gespeichert.
+
+
+
+
+
+
+ );
+}
diff --git a/src/env.ts b/src/env.ts
index daa678b..ffb1f45 100644
--- a/src/env.ts
+++ b/src/env.ts
@@ -31,8 +31,23 @@ const schema = z.object({
REBRICKABLE_API_KEY: z.string().min(1).optional(),
});
+/**
+ * An empty value counts as "not set".
+ *
+ * A line like `BASE_URL=` in a .env file is a common slip, and so is an empty
+ * variable injected by a container runtime. Falling back to the default beats
+ * failing to start with "Invalid URL".
+ */
+function withoutEmptyValues(source: NodeJS.ProcessEnv): Record {
+ const result: Record = {};
+ for (const [key, value] of Object.entries(source)) {
+ if (typeof value === 'string' && value.trim() !== '') result[key] = value;
+ }
+ return result;
+}
+
function parseEnv() {
- const parsed = schema.safeParse(process.env);
+ const parsed = schema.safeParse(withoutEmptyValues(process.env));
if (!parsed.success) {
const issues = parsed.error.issues
.map((issue) => ` - ${issue.path.join('.')}: ${issue.message}`)
diff --git a/src/lib/rebrickable.ts b/src/lib/rebrickable.ts
new file mode 100644
index 0000000..8862415
--- /dev/null
+++ b/src/lib/rebrickable.ts
@@ -0,0 +1,140 @@
+import 'server-only';
+import { env } from '@/env';
+import { displaySetNumber, normalizeSetNumber } from './set-number';
+
+export { displaySetNumber, legoInstructionsUrl, normalizeSetNumber } from './set-number';
+
+/**
+ * Rebrickable lookup for Lego sets.
+ *
+ * Entirely optional: without REBRICKABLE_API_KEY the import stays switched
+ * off and everything keeps working by hand. Every request runs here on the
+ * server - the browser never talks to rebrickable.com.
+ */
+
+const API_BASE = 'https://rebrickable.com/api/v3/lego';
+const REQUEST_TIMEOUT_MS = 10_000;
+
+export function isRebrickableEnabled(): boolean {
+ return Boolean(env.REBRICKABLE_API_KEY);
+}
+
+export class RebrickableError extends Error {}
+
+export type RebrickableSet = {
+ setNumber: string;
+ name: string;
+ year: number | null;
+ pieceCount: number | null;
+ themeId: number | null;
+ imageUrl: string | null;
+ externalUrl: string | null;
+};
+
+export type RebrickableTheme = {
+ id: number;
+ name: string;
+ parentId: number | null;
+};
+
+async function request(path: string): Promise {
+ const key = env.REBRICKABLE_API_KEY;
+ if (!key) throw new RebrickableError('Für den Import fehlt der Rebrickable-API-Key.');
+
+ let response: Response;
+ try {
+ response = await fetch(`${API_BASE}${path}`, {
+ headers: {
+ Authorization: `key ${key}`,
+ Accept: 'application/json',
+ },
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
+ // The catalog is not a moving target; a day of caching is plenty.
+ next: { revalidate: 86_400 },
+ });
+ } catch (error) {
+ console.error('[rebrickable] Anfrage fehlgeschlagen', path, error);
+ throw new RebrickableError('Rebrickable ist gerade nicht erreichbar.');
+ }
+
+ if (response.status === 404) return null;
+ if (response.status === 401 || response.status === 403) {
+ throw new RebrickableError('Der Rebrickable-API-Key wird abgelehnt. Bitte prüf ihn.');
+ }
+ if (response.status === 429) {
+ throw new RebrickableError('Rebrickable drosselt gerade. Bitte in einer Minute noch einmal.');
+ }
+ if (!response.ok) {
+ throw new RebrickableError(`Rebrickable antwortet mit Status ${response.status}.`);
+ }
+
+ return (await response.json()) as T;
+}
+
+type SetResponse = {
+ set_num: string;
+ name: string;
+ year: number | null;
+ theme_id: number | null;
+ num_parts: number | null;
+ set_img_url: string | null;
+ set_url: string | null;
+};
+
+export async function fetchSet(setNumber: string): Promise {
+ const data = await request(`/sets/${encodeURIComponent(normalizeSetNumber(setNumber))}/`);
+ if (!data) return null;
+
+ return {
+ setNumber: displaySetNumber(data.set_num),
+ name: data.name,
+ year: data.year ?? null,
+ pieceCount: data.num_parts ?? null,
+ themeId: data.theme_id ?? null,
+ imageUrl: data.set_img_url ?? null,
+ externalUrl: data.set_url ?? null,
+ };
+}
+
+type ThemeResponse = { id: number; name: string; parent_id: number | null };
+
+export async function fetchTheme(themeId: number): Promise {
+ const data = await request(`/themes/${themeId}/`);
+ if (!data) return null;
+ return { id: data.id, name: data.name, parentId: data.parent_id ?? null };
+}
+
+type MinifigsResponse = { results: Array<{ quantity: number | null }> };
+
+/** Number of minifigures, or null when the lookup does not work out. */
+export async function fetchMinifigCount(setNumber: string): Promise {
+ try {
+ const data = await request(
+ `/sets/${encodeURIComponent(normalizeSetNumber(setNumber))}/minifigs/`,
+ );
+ if (!data) return null;
+ return data.results.reduce((total, row) => total + (row.quantity ?? 0), 0);
+ } catch {
+ // A missing minifig list must not fail the whole import.
+ return null;
+ }
+}
+
+/**
+ * Downloads the set image so it keeps working when Rebrickable changes or
+ * removes it. Returns null instead of throwing: an import without a picture
+ * is still a good import.
+ */
+export async function fetchImageBytes(url: string): Promise {
+ try {
+ const response = await fetch(url, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
+ if (!response.ok) return null;
+
+ const buffer = Buffer.from(await response.arrayBuffer());
+ return buffer.byteLength > 0 ? buffer : null;
+ } catch (error) {
+ console.error('[rebrickable] Bild konnte nicht geladen werden', url, error);
+ return null;
+ }
+}
+
diff --git a/src/lib/set-number.ts b/src/lib/set-number.ts
new file mode 100644
index 0000000..66cb89f
--- /dev/null
+++ b/src/lib/set-number.ts
@@ -0,0 +1,32 @@
+/**
+ * Set-number handling for the Lego import.
+ *
+ * Deliberately free of server-only imports so it can be unit tested and, if
+ * ever needed, used on the client.
+ */
+
+/**
+ * Rebrickable numbers a set's variant explicitly: the box says "60398", the
+ * API knows "60398-1". People type what is printed on the box, so the suffix
+ * is added when it is missing.
+ */
+export function normalizeSetNumber(input: string): string {
+ const trimmed = input.trim().toUpperCase();
+ return /-\d+$/.test(trimmed) ? trimmed : `${trimmed}-1`;
+}
+
+/** The number as it should be stored and shown: without the "-1" variant. */
+export function displaySetNumber(setNum: string): string {
+ return setNum.trim().replace(/-1$/, '');
+}
+
+/**
+ * Lego publishes building instructions per set number. Rebrickable's API does
+ * not expose instruction files, so the import links to Lego's own service page
+ * rather than pretending to have the PDF.
+ */
+export function legoInstructionsUrl(setNumber: string): string {
+ return `https://www.lego.com/de-de/service/building-instructions/${encodeURIComponent(
+ displaySetNumber(setNumber),
+ )}`;
+}
diff --git a/src/lib/uploads.ts b/src/lib/uploads.ts
index 2012e79..6acd7b4 100644
--- a/src/lib/uploads.ts
+++ b/src/lib/uploads.ts
@@ -88,8 +88,25 @@ export type StoredImage = {
*/
export async function storeImage(file: File, kind: UploadKind, ownerId: string): Promise {
assertSize(file);
+ return storeImageBytes(Buffer.from(await file.arrayBuffer()), kind, ownerId);
+}
+
+/**
+ * Same as `storeImage`, but for bytes we already hold - for instance an image
+ * the server fetched from an import source. The size limit still applies.
+ */
+export async function storeImageBytes(
+ bytes: Buffer,
+ kind: UploadKind,
+ ownerId: string,
+): Promise {
+ if (bytes.byteLength === 0) throw new UploadError('Die Datei ist leer.');
+ if (bytes.byteLength > env.MAX_UPLOAD_BYTES) {
+ throw new UploadError(
+ `Das Bild ist größer als ${env.MAX_UPLOAD_MB} MB und wurde nicht übernommen.`,
+ );
+ }
- const bytes = Buffer.from(await file.arrayBuffer());
const mime = detectMimeType(bytes);
if (!mime || !IMAGE_MIME_TYPES.includes(mime as (typeof IMAGE_MIME_TYPES)[number])) {
throw new UploadError('Erlaubt sind nur JPEG-, PNG- und WebP-Bilder.');
diff --git a/tests/setup-env.ts b/tests/setup-env.ts
new file mode 100644
index 0000000..b19663f
--- /dev/null
+++ b/tests/setup-env.ts
@@ -0,0 +1,17 @@
+/**
+ * Baseline environment for unit tests.
+ *
+ * src/env.ts validates on import and refuses to start on a bad configuration -
+ * which is the point in production, but in tests every module that reads the
+ * environment would fail before its first assertion. These are placeholders,
+ * never anything a test asserts on.
+ *
+ * BASE_URL is overwritten unconditionally: Vite uses that very name for its
+ * public base path and sets it to "/", which is not a URL. The clash only
+ * exists under Vite-based tooling, never in the app itself.
+ */
+process.env.NODE_ENV ??= 'test';
+process.env.BASE_URL = 'http://localhost:3000';
+process.env.BETTER_AUTH_SECRET =
+ process.env.BETTER_AUTH_SECRET || 'test-secret-mindestens-32-zeichen-lang-0123';
+process.env.DATA_DIR = process.env.DATA_DIR || './data-test';
diff --git a/tests/stubs/server-only.ts b/tests/stubs/server-only.ts
new file mode 100644
index 0000000..a931fa3
--- /dev/null
+++ b/tests/stubs/server-only.ts
@@ -0,0 +1,8 @@
+/**
+ * Stub for the `server-only` marker package.
+ *
+ * The real module throws on import outside a React Server Component, which
+ * would make every server module untestable. Importing it is a build-time
+ * guard, not behaviour, so an empty module is the honest replacement here.
+ */
+export {};
diff --git a/tests/unit/rebrickable.test.ts b/tests/unit/rebrickable.test.ts
new file mode 100644
index 0000000..4d448b7
--- /dev/null
+++ b/tests/unit/rebrickable.test.ts
@@ -0,0 +1,179 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+// The module reads the key through src/env, which validates on import - so
+// the variable has to be in place before the dynamic import below.
+process.env.REBRICKABLE_API_KEY = 'test-key';
+
+const {
+ fetchImageBytes,
+ fetchMinifigCount,
+ fetchSet,
+ fetchTheme,
+ isRebrickableEnabled,
+ RebrickableError,
+} = await import('@/lib/rebrickable');
+
+type FetchArgs = Parameters;
+
+function jsonResponse(body: unknown, status = 200): Response {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { 'Content-Type': 'application/json' },
+ });
+}
+
+const originalFetch = globalThis.fetch;
+
+beforeEach(() => {
+ vi.restoreAllMocks();
+});
+
+afterEach(() => {
+ globalThis.fetch = originalFetch;
+});
+
+function stubFetch(handler: (url: string, init?: FetchArgs[1]) => Response | Promise) {
+ const spy = vi.fn(async (input: FetchArgs[0], init?: FetchArgs[1]) =>
+ handler(String(input), init),
+ );
+ globalThis.fetch = spy as unknown as typeof fetch;
+ return spy;
+}
+
+const SET_RESPONSE = {
+ set_num: '60398-1',
+ name: 'Familienhaus und Elektroauto',
+ year: 2023,
+ theme_id: 52,
+ num_parts: 462,
+ set_img_url: 'https://cdn.rebrickable.com/media/sets/60398-1.jpg',
+ set_url: 'https://rebrickable.com/sets/60398-1/',
+};
+
+describe('Verfuegbarkeit', () => {
+ it('ist mit gesetztem API-Key aktiv', () => {
+ expect(isRebrickableEnabled()).toBe(true);
+ });
+});
+
+describe('Set abrufen', () => {
+ it('uebersetzt die Antwort in unser Format', async () => {
+ stubFetch(() => jsonResponse(SET_RESPONSE));
+
+ const result = await fetchSet('60398');
+
+ expect(result).toEqual({
+ setNumber: '60398',
+ name: 'Familienhaus und Elektroauto',
+ year: 2023,
+ pieceCount: 462,
+ themeId: 52,
+ imageUrl: 'https://cdn.rebrickable.com/media/sets/60398-1.jpg',
+ externalUrl: 'https://rebrickable.com/sets/60398-1/',
+ });
+ });
+
+ it('fragt die Nummer mit Variante ab und schickt den Key im Header', async () => {
+ const spy = stubFetch(() => jsonResponse(SET_RESPONSE));
+
+ await fetchSet('60398');
+
+ const [url, init] = spy.mock.calls[0]!;
+ expect(String(url)).toContain('/sets/60398-1/');
+ // Der Key gehoert in den Header, nicht in die URL - sonst steht er in
+ // jedem Proxy-Log.
+ expect(String(url)).not.toContain('test-key');
+ expect((init?.headers as Record).Authorization).toBe('key test-key');
+ });
+
+ it('liefert null, wenn Rebrickable die Nummer nicht kennt', async () => {
+ stubFetch(() => new Response('', { status: 404 }));
+
+ await expect(fetchSet('99999')).resolves.toBeNull();
+ });
+
+ it('meldet einen abgelehnten API-Key verstaendlich', async () => {
+ stubFetch(() => new Response('', { status: 401 }));
+
+ await expect(fetchSet('60398')).rejects.toBeInstanceOf(RebrickableError);
+ await expect(fetchSet('60398')).rejects.toThrow(/API-Key/);
+ });
+
+ it('meldet eine Drosselung als eigenen Fall', async () => {
+ stubFetch(() => new Response('', { status: 429 }));
+
+ await expect(fetchSet('60398')).rejects.toThrow(/drosselt/);
+ });
+
+ it('macht aus einem Netzwerkfehler keine Ausnahme mit Innenleben', async () => {
+ stubFetch(() => {
+ throw new Error('ECONNREFUSED');
+ });
+
+ await expect(fetchSet('60398')).rejects.toThrow(/nicht erreichbar/);
+ });
+
+ it('kommt mit fehlenden Feldern klar', async () => {
+ stubFetch(() =>
+ jsonResponse({ set_num: '1234-1', name: 'Ohne alles', year: null, theme_id: null }),
+ );
+
+ const result = await fetchSet('1234');
+
+ expect(result).toMatchObject({
+ setNumber: '1234',
+ pieceCount: null,
+ themeId: null,
+ imageUrl: null,
+ });
+ });
+});
+
+describe('Thema abrufen', () => {
+ it('liefert Name und Oberthema', async () => {
+ stubFetch(() => jsonResponse({ id: 52, name: 'City', parent_id: 50 }));
+
+ await expect(fetchTheme(52)).resolves.toEqual({ id: 52, name: 'City', parentId: 50 });
+ });
+
+ it('behandelt ein Thema ohne Oberthema', async () => {
+ stubFetch(() => jsonResponse({ id: 1, name: 'Technic', parent_id: null }));
+
+ await expect(fetchTheme(1)).resolves.toEqual({ id: 1, name: 'Technic', parentId: null });
+ });
+});
+
+describe('Minifiguren zaehlen', () => {
+ it('summiert die Mengen', async () => {
+ stubFetch(() => jsonResponse({ results: [{ quantity: 2 }, { quantity: 3 }] }));
+
+ await expect(fetchMinifigCount('60398')).resolves.toBe(5);
+ });
+
+ it('laesst den Import nicht scheitern, wenn die Liste fehlt', async () => {
+ stubFetch(() => new Response('', { status: 500 }));
+
+ // null statt Ausnahme: ein Set ohne Minifiguren-Angabe ist kein Fehler.
+ await expect(fetchMinifigCount('60398')).resolves.toBeNull();
+ });
+});
+
+describe('Bild laden', () => {
+ it('gibt die Bytes zurueck', async () => {
+ stubFetch(() => new Response(new Uint8Array([1, 2, 3])));
+
+ const bytes = await fetchImageBytes('https://example.test/bild.jpg');
+
+ expect(bytes?.byteLength).toBe(3);
+ });
+
+ it('gibt null zurueck, statt den Import scheitern zu lassen', async () => {
+ stubFetch(() => new Response('', { status: 404 }));
+ await expect(fetchImageBytes('https://example.test/weg.jpg')).resolves.toBeNull();
+
+ stubFetch(() => {
+ throw new Error('timeout');
+ });
+ await expect(fetchImageBytes('https://example.test/lahm.jpg')).resolves.toBeNull();
+ });
+});
diff --git a/tests/unit/set-number.test.ts b/tests/unit/set-number.test.ts
new file mode 100644
index 0000000..20a4b0d
--- /dev/null
+++ b/tests/unit/set-number.test.ts
@@ -0,0 +1,44 @@
+import { describe, expect, it } from 'vitest';
+import { displaySetNumber, legoInstructionsUrl, normalizeSetNumber } from '@/lib/set-number';
+
+describe('Setnummern fuer den Rebrickable-Import', () => {
+ it('haengt die Variante an, wenn nur die Packungsnummer eingetippt wurde', () => {
+ expect(normalizeSetNumber('60398')).toBe('60398-1');
+ expect(normalizeSetNumber('71427')).toBe('71427-1');
+ });
+
+ it('laesst eine bereits vollstaendige Nummer unveraendert', () => {
+ expect(normalizeSetNumber('60398-1')).toBe('60398-1');
+ expect(normalizeSetNumber('10265-2')).toBe('10265-2');
+ });
+
+ it('ignoriert Leerzeichen und vereinheitlicht Grossschreibung', () => {
+ expect(normalizeSetNumber(' 60398 ')).toBe('60398-1');
+ expect(normalizeSetNumber('k8672')).toBe('K8672-1');
+ });
+
+ it('entfernt fuer die Anzeige nur die erste Variante', () => {
+ expect(displaySetNumber('60398-1')).toBe('60398');
+ // -2 ist eine echte zweite Variante und muss sichtbar bleiben.
+ expect(displaySetNumber('10265-2')).toBe('10265-2');
+ expect(displaySetNumber('60398')).toBe('60398');
+ });
+
+ it('ueberlebt den Rundweg Anzeige -> API -> Anzeige', () => {
+ for (const number of ['60398', '71427', '42172']) {
+ expect(displaySetNumber(normalizeSetNumber(number))).toBe(number);
+ }
+ });
+});
+
+describe('Link auf die LEGO-Bauanleitung', () => {
+ it('nutzt die Nummer ohne Variante', () => {
+ expect(legoInstructionsUrl('60398-1')).toBe(
+ 'https://www.lego.com/de-de/service/building-instructions/60398',
+ );
+ });
+
+ it('maskiert Sonderzeichen in der Nummer', () => {
+ expect(legoInstructionsUrl('2 30500')).toContain('2%2030500');
+ });
+});
diff --git a/vitest.config.mts b/vitest.config.mts
index c058104..2fe7f3d 100644
--- a/vitest.config.mts
+++ b/vitest.config.mts
@@ -8,10 +8,14 @@ export default defineConfig({
resolve: {
alias: {
'@': path.resolve(root, './src'),
+ // `server-only` is a build-time marker that throws when imported outside
+ // a server component. Stubbing it keeps server modules testable.
+ 'server-only': path.resolve(root, './tests/stubs/server-only.ts'),
},
},
test: {
environment: 'node',
+ setupFiles: ['./tests/setup-env.ts'],
include: ['tests/unit/**/*.test.ts'],
globals: false,
},
From baa94bf7e5a6aa30b9abcdd708417905b6d73478 Mon Sep 17 00:00:00 2001
From: unknown
Date: Mon, 7 Sep 2026 07:15:41 +0200
Subject: [PATCH 3/7] feat(scanner): ean-barcode mit der handykamera scannen
Knopf im Katalog neben "Set anlegen": Strichcode ins Bild halten, und
entweder oeffnet sich das passende Set oder das Formular "Neues Set"
mit ausgefuellter EAN. Dekodiert wird im Browser mit zxing, das nur
beim Oeffnen des Scanners nachgeladen wird - nichts verlaesst das
Geraet, und die Katalogseite bleibt leicht fuer alle, die nie scannen.
Jeder gelesene Code wird gegen seine Pruefziffer geprueft, bevor er
verwendet wird. Ohne das wuerde eine falsch erkannte Ziffer stillschweigend
ein zweites Katalog-Set fuer ein Set anlegen, das es schon gibt; bei
einem Fehlversuch scannt die Kamera einfach weiter.
Die Kamera braucht wie Passkeys einen sicheren Kontext. Fehlt HTTPS oder
wird der Zugriff abgelehnt, steht das als deutscher Hinweis im Dialog
statt einer stummen schwarzen Flaeche.
8 Unit-Tests mit echten EAN-13-, EAN-8- und UPC-A-Beispielen.
Refs Phase 2
Co-Authored-By: Claude Opus 5 (1M context)
---
package.json | 148 +++++++--------
pnpm-lock.yaml | 39 ++++
src/app/(app)/katalog/page.tsx | 16 +-
src/app/(app)/katalog/scan-actions.ts | 37 ++++
src/components/katalog/barcode-scanner.tsx | 199 +++++++++++++++++++++
src/lib/ean.ts | 41 +++++
src/lib/queries/catalog.ts | 18 ++
tests/setup-env.ts | 2 +-
tests/unit/ean.test.ts | 48 +++++
9 files changed, 468 insertions(+), 80 deletions(-)
create mode 100644 src/app/(app)/katalog/scan-actions.ts
create mode 100644 src/components/katalog/barcode-scanner.tsx
create mode 100644 src/lib/ean.ts
create mode 100644 tests/unit/ean.test.ts
diff --git a/package.json b/package.json
index 49f9650..e24d09e 100644
--- a/package.json
+++ b/package.json
@@ -1,73 +1,75 @@
-{
- "name": "bricktrack",
- "version": "0.1.0",
- "private": true,
- "description": "Self-hosted Verwaltung fuer Spielzeug-Set-Sammlungen (Lego, Playmobil, Cobi, ...)",
- "packageManager": "pnpm@10.34.5",
- "engines": {
- "node": ">=22"
- },
- "scripts": {
- "dev": "next dev",
- "build": "next build",
- "start": "next start",
- "lint": "eslint .",
- "typecheck": "tsc --noEmit",
- "db:generate": "drizzle-kit generate",
- "db:migrate": "tsx scripts/migrate.ts",
- "db:seed": "tsx scripts/seed.ts",
- "db:studio": "drizzle-kit studio",
- "test": "vitest run",
- "test:watch": "vitest",
- "test:e2e": "node scripts/reset-e2e.mjs && playwright test",
- "screenshots": "node scripts/screenshots-prepare.mjs && playwright test --config=playwright.screenshots.config.ts"
- },
- "dependencies": {
- "@better-auth/passkey": "^1.7.3",
- "better-auth": "^1.7.3",
- "better-sqlite3": "^13.0.3",
- "class-variance-authority": "^0.7.1",
- "cn": "^0.2.6",
- "date-fns": "^4.4.0",
- "drizzle-orm": "^0.45.2",
- "lucide-react": "^1.41.0",
- "next": "15.5.25",
- "next-themes": "^0.4.6",
- "pdfjs-dist": "^6.3.289",
- "radix-ui": "^1.6.7",
- "react": "19.1.0",
- "react-dom": "19.1.0",
- "server-only": "^0.0.1",
- "sharp": "^0.35.4",
- "sonner": "^2.0.8",
- "tw-animate-css": "^1.4.0",
- "zod": "^4.5.4"
- },
- "devDependencies": {
- "@eslint/eslintrc": "^3",
- "@playwright/test": "^1.63.0",
- "@tailwindcss/postcss": "^4",
- "@types/better-sqlite3": "^9.6.0",
- "@types/node": "^20",
- "@types/react": "^19",
- "@types/react-dom": "^19",
- "dotenv": "^17.4.2",
- "drizzle-kit": "^0.31.10",
- "esbuild": "^0.25.12",
- "eslint": "^9",
- "eslint-config-next": "15.5.25",
- "shadcn": "^4.21.0",
- "tailwindcss": "^4",
- "tsx": "^4.23.13",
- "typescript": "^5",
- "vitest": "^5.0.0"
- },
- "pnpm": {
- "onlyBuiltDependencies": [
- "better-sqlite3",
- "esbuild",
- "sharp",
- "unrs-resolver"
- ]
- }
-}
+{
+ "name": "bricktrack",
+ "version": "0.1.0",
+ "private": true,
+ "description": "Self-hosted Verwaltung fuer Spielzeug-Set-Sammlungen (Lego, Playmobil, Cobi, ...)",
+ "packageManager": "pnpm@10.34.5",
+ "engines": {
+ "node": ">=22"
+ },
+ "scripts": {
+ "dev": "next dev",
+ "build": "next build",
+ "start": "next start",
+ "lint": "eslint .",
+ "typecheck": "tsc --noEmit",
+ "db:generate": "drizzle-kit generate",
+ "db:migrate": "tsx scripts/migrate.ts",
+ "db:seed": "tsx scripts/seed.ts",
+ "db:studio": "drizzle-kit studio",
+ "test": "vitest run",
+ "test:watch": "vitest",
+ "test:e2e": "node scripts/reset-e2e.mjs && playwright test",
+ "screenshots": "node scripts/screenshots-prepare.mjs && playwright test --config=playwright.screenshots.config.ts"
+ },
+ "dependencies": {
+ "@better-auth/passkey": "^1.7.3",
+ "@zxing/browser": "^0.2.1",
+ "@zxing/library": "^0.23.0",
+ "better-auth": "^1.7.3",
+ "better-sqlite3": "^13.0.3",
+ "class-variance-authority": "^0.7.1",
+ "cn": "^0.2.6",
+ "date-fns": "^4.4.0",
+ "drizzle-orm": "^0.45.2",
+ "lucide-react": "^1.41.0",
+ "next": "15.5.25",
+ "next-themes": "^0.4.6",
+ "pdfjs-dist": "^6.3.289",
+ "radix-ui": "^1.6.7",
+ "react": "19.1.0",
+ "react-dom": "19.1.0",
+ "server-only": "^0.0.1",
+ "sharp": "^0.35.4",
+ "sonner": "^2.0.8",
+ "tw-animate-css": "^1.4.0",
+ "zod": "^4.5.4"
+ },
+ "devDependencies": {
+ "@eslint/eslintrc": "^3",
+ "@playwright/test": "^1.63.0",
+ "@tailwindcss/postcss": "^4",
+ "@types/better-sqlite3": "^9.6.0",
+ "@types/node": "^20",
+ "@types/react": "^19",
+ "@types/react-dom": "^19",
+ "dotenv": "^17.4.2",
+ "drizzle-kit": "^0.31.10",
+ "esbuild": "^0.25.12",
+ "eslint": "^9",
+ "eslint-config-next": "15.5.25",
+ "shadcn": "^4.21.0",
+ "tailwindcss": "^4",
+ "tsx": "^4.23.13",
+ "typescript": "^5",
+ "vitest": "^5.0.0"
+ },
+ "pnpm": {
+ "onlyBuiltDependencies": [
+ "better-sqlite3",
+ "esbuild",
+ "sharp",
+ "unrs-resolver"
+ ]
+ }
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index fa513f6..7386ebd 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -11,6 +11,12 @@ 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)
+ '@zxing/browser':
+ specifier: ^0.2.1
+ version: 0.2.1(@zxing/library@0.23.0)
+ '@zxing/library':
+ specifier: ^0.23.0
+ version: 0.23.0
better-auth:
specifier: ^1.7.3
version: 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)))
@@ -2457,6 +2463,18 @@ packages:
'@vitest/spy@5.0.0':
resolution: {integrity: sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==}
+ '@zxing/browser@0.2.1':
+ resolution: {integrity: sha512-92pVfVDUXbc15xu9vIEmhNyqIEASMEkDF92CwT6T+7sg2EHXJRktH9CQKoQSXe51UtbNsj+Fm09H/JBWHMs/Sw==}
+ peerDependencies:
+ '@zxing/library': ^0.23.0
+
+ '@zxing/library@0.23.0':
+ resolution: {integrity: sha512-6fkkoFwP8CHxl6ugnPsj74PLJgX2iRv5zczGAyt5OBzQgxFhuhF0NCEc4t4OvSr8xAv2MRLlI0Iu9ZGDZQ2urA==}
+ engines: {node: '>= 24.0.0'}
+
+ '@zxing/text-encoding@0.9.0':
+ resolution: {integrity: sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==}
+
accepts@2.0.0:
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
engines: {node: '>= 0.6'}
@@ -4745,6 +4763,10 @@ packages:
peerDependencies:
typescript: '>=4.8.4'
+ ts-custom-error@3.3.1:
+ resolution: {integrity: sha512-5OX1tzOjxWEgsr/YEUWSuPrQ00deKLh6D7OTWcvNHm12/7QPyRh8SYpyWvA4IZv8H/+GQWQEh/kwo95Q9OVW1A==}
+ engines: {node: '>=14.0.0'}
+
ts-morph@26.0.0:
resolution: {integrity: sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug==}
@@ -7104,6 +7126,21 @@ snapshots:
'@vitest/spy@5.0.0': {}
+ '@zxing/browser@0.2.1(@zxing/library@0.23.0)':
+ dependencies:
+ '@zxing/library': 0.23.0
+ optionalDependencies:
+ '@zxing/text-encoding': 0.9.0
+
+ '@zxing/library@0.23.0':
+ dependencies:
+ ts-custom-error: 3.3.1
+ optionalDependencies:
+ '@zxing/text-encoding': 0.9.0
+
+ '@zxing/text-encoding@0.9.0':
+ optional: true
+
accepts@2.0.0:
dependencies:
mime-types: 3.0.2
@@ -9555,6 +9592,8 @@ snapshots:
dependencies:
typescript: 5.9.3
+ ts-custom-error@3.3.1: {}
+
ts-morph@26.0.0:
dependencies:
'@ts-morph/common': 0.27.0
diff --git a/src/app/(app)/katalog/page.tsx b/src/app/(app)/katalog/page.tsx
index 1098964..b3f7f4d 100644
--- a/src/app/(app)/katalog/page.tsx
+++ b/src/app/(app)/katalog/page.tsx
@@ -1,6 +1,7 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { Plus } from 'lucide-react';
+import { BarcodeScannerButton } from '@/components/katalog/barcode-scanner';
import { CatalogFilters } from '@/components/katalog/catalog-filters';
import { CatalogGrid } from '@/components/katalog/catalog-grid';
import { Pagination } from '@/components/katalog/pagination';
@@ -60,12 +61,15 @@ export default async function KatalogPage({ searchParams }: { searchParams: Sear
: 'Alle bekannten Sets – unabhängig davon, ob sie schon da sind.'}
-
-
-
- Set anlegen
-
-
+
+
+
+
+
+ Set anlegen
+
+
+
diff --git a/src/app/(app)/katalog/scan-actions.ts b/src/app/(app)/katalog/scan-actions.ts
new file mode 100644
index 0000000..b07163e
--- /dev/null
+++ b/src/app/(app)/katalog/scan-actions.ts
@@ -0,0 +1,37 @@
+'use server';
+
+import { actionData, actionError, type ActionResult } from '@/lib/action-result';
+import { isValidEan } from '@/lib/ean';
+import { findSetByEan } from '@/lib/queries/catalog';
+import { requireUser } from '@/lib/session';
+
+export type ScanLookup =
+ | { found: true; setId: string; setNumber: string; name: string }
+ | { found: false; ean: string };
+
+/**
+ * Resolves a scanned barcode.
+ *
+ * Either the set is already in the catalog - then we jump straight to it - or
+ * it is not, and the caller opens the "new set" form with the number filled in.
+ */
+export async function lookupScannedEan(rawEan: unknown): Promise> {
+ await requireUser();
+
+ const ean = typeof rawEan === 'string' ? rawEan.replace(/\D/g, '') : '';
+ if (!isValidEan(ean)) {
+ return actionError('Der Strichcode wurde nicht sauber erkannt. Bitte noch einmal scannen.');
+ }
+
+ const match = findSetByEan(ean);
+ if (match) {
+ return actionData({
+ found: true as const,
+ setId: match.id,
+ setNumber: match.setNumber,
+ name: match.name,
+ });
+ }
+
+ return actionData({ found: false as const, ean });
+}
diff --git a/src/components/katalog/barcode-scanner.tsx b/src/components/katalog/barcode-scanner.tsx
new file mode 100644
index 0000000..f5fa78f
--- /dev/null
+++ b/src/components/katalog/barcode-scanner.tsx
@@ -0,0 +1,199 @@
+'use client';
+
+import { useRouter } from 'next/navigation';
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { Loader2, ScanLine } from 'lucide-react';
+import { toast } from 'sonner';
+import { lookupScannedEan } from '@/app/(app)/katalog/scan-actions';
+import { formatEan, isValidEan } from '@/lib/ean';
+import { Button } from '@/components/ui/button';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+
+type Controls = { stop: () => void };
+
+/**
+ * Scans the barcode on a box with the phone camera.
+ *
+ * Decoding runs in the browser with zxing, which is bundled with the app and
+ * loaded only when the scanner is opened - nothing leaves the device, and the
+ * catalog pages stay light for everyone who never scans.
+ */
+export function BarcodeScannerButton() {
+ const router = useRouter();
+ const videoRef = useRef(null);
+ const controlsRef = useRef(null);
+ const handledRef = useRef(false);
+
+ const [open, setOpen] = useState(false);
+ const [status, setStatus] = useState<'starting' | 'scanning' | 'checking' | 'error'>('starting');
+ const [message, setMessage] = useState(null);
+
+ const stop = useCallback(() => {
+ controlsRef.current?.stop();
+ controlsRef.current = null;
+ }, []);
+
+ const onDetected = useCallback(
+ (raw: string) => {
+ if (handledRef.current) return;
+ if (!isValidEan(raw)) return; // keep scanning on a misread
+
+ handledRef.current = true;
+ stop();
+ setStatus('checking');
+
+ void lookupScannedEan(raw).then((result) => {
+ if (!result.ok) {
+ toast.error(result.error);
+ setOpen(false);
+ return;
+ }
+
+ setOpen(false);
+
+ if (result.data.found) {
+ toast.success(`${result.data.setNumber} · ${result.data.name}`);
+ router.push(`/katalog/${result.data.setId}`);
+ } else {
+ toast.info(`${formatEan(result.data.ean)} ist noch nicht im Katalog.`);
+ router.push(`/katalog/neu?ean=${encodeURIComponent(result.data.ean)}`);
+ }
+ router.refresh();
+ });
+ },
+ [router, stop],
+ );
+
+ useEffect(() => {
+ if (!open) return;
+
+ handledRef.current = false;
+ setStatus('starting');
+ setMessage(null);
+
+ // The camera needs a secure context - same rule as for passkeys.
+ if (!window.isSecureContext) {
+ setStatus('error');
+ setMessage(
+ 'Die Kamera funktioniert nur über HTTPS. Ruf Bricktrack über deine Domain auf, nicht über die IP-Adresse.',
+ );
+ return;
+ }
+
+ let cancelled = false;
+
+ void (async () => {
+ try {
+ const { BrowserMultiFormatReader } = await import('@zxing/browser');
+ const reader = new BrowserMultiFormatReader();
+ const video = videoRef.current;
+ if (!video || cancelled) return;
+
+ const controls = await reader.decodeFromConstraints(
+ // The rear camera is the one pointing at the box.
+ { video: { facingMode: 'environment' } },
+ video,
+ (result) => {
+ if (result) onDetected(result.getText());
+ },
+ );
+
+ if (cancelled) {
+ controls.stop();
+ return;
+ }
+
+ controlsRef.current = controls;
+ setStatus('scanning');
+ } catch (error) {
+ if (cancelled) return;
+ console.error('[scan] Kamera konnte nicht gestartet werden', error);
+ setStatus('error');
+
+ const name = (error as { name?: string })?.name;
+ setMessage(
+ name === 'NotAllowedError'
+ ? 'Der Zugriff auf die Kamera wurde abgelehnt. Erlaube ihn in den Browser-Einstellungen und versuch es noch einmal.'
+ : name === 'NotFoundError'
+ ? 'Dieses Gerät hat keine nutzbare Kamera.'
+ : 'Die Kamera lässt sich nicht starten.',
+ );
+ }
+ })();
+
+ return () => {
+ cancelled = true;
+ stop();
+ };
+ }, [open, onDetected, stop]);
+
+ return (
+ <>
+ setOpen(true)}
+ >
+
+
+
+ {
+ if (!next) stop();
+ setOpen(next);
+ }}
+ >
+
+
+ Barcode scannen
+
+ Halte den Strichcode auf der Packung ins Bild. Kennt der Katalog das Set schon,
+ landest du direkt darauf – sonst im Formular mit ausgefüllter EAN.
+
+
+
+ {status === 'error' ? (
+
+ {message}
+
+ ) : (
+
+
+ {/* A guide line makes people hold the box the right way round. */}
+
+
+ {status !== 'scanning' ? (
+
+
+ {status === 'checking' ? 'Im Katalog nachsehen …' : 'Kamera startet …'}
+
+ ) : null}
+
+ )}
+
+
+ setOpen(false)}>
+ Abbrechen
+
+
+
+
+ >
+ );
+}
diff --git a/src/lib/ean.ts b/src/lib/ean.ts
new file mode 100644
index 0000000..4dac099
--- /dev/null
+++ b/src/lib/ean.ts
@@ -0,0 +1,41 @@
+/**
+ * Barcode helpers.
+ *
+ * Kept free of server-only imports so both the scanner in the browser and the
+ * lookup on the server use the very same rules.
+ */
+
+/** Digits only - scanners and humans disagree about spaces and dashes. */
+export function normalizeEan(input: string): string {
+ return input.replace(/\D/g, '');
+}
+
+/**
+ * Checks an EAN-8, EAN-13 or UPC-A against its check digit.
+ *
+ * Worth doing: a camera occasionally reads a digit wrong, and without this a
+ * misread would silently create a second catalog entry for a set that is
+ * already there.
+ */
+export function isValidEan(input: string): boolean {
+ const digits = normalizeEan(input);
+ if (![8, 12, 13].includes(digits.length)) return false;
+
+ // From the right: every second digit counts three times.
+ const checkDigit = Number(digits[digits.length - 1]);
+ const body = digits.slice(0, -1);
+
+ let sum = 0;
+ for (let index = body.length - 1, weight = 3; index >= 0; index -= 1, weight = weight === 3 ? 1 : 3) {
+ sum += Number(body[index]) * weight;
+ }
+
+ return (10 - (sum % 10)) % 10 === checkDigit;
+}
+
+/** "4005556603985" -> "4 005556 603985" for readable display. */
+export function formatEan(input: string): string {
+ const digits = normalizeEan(input);
+ if (digits.length !== 13) return digits;
+ return `${digits.slice(0, 1)} ${digits.slice(1, 7)} ${digits.slice(7)}`;
+}
diff --git a/src/lib/queries/catalog.ts b/src/lib/queries/catalog.ts
index 9bb2fd2..96b45ff 100644
--- a/src/lib/queries/catalog.ts
+++ b/src/lib/queries/catalog.ts
@@ -160,6 +160,24 @@ export function getSetById(id: string) {
return row ?? null;
}
+/**
+ * Lookup by barcode. EANs are stored as typed, so the comparison strips
+ * everything but digits on both sides - a scanner never produces the spaces
+ * and dashes people write by hand.
+ */
+export function findSetByEan(ean: string) {
+ const digits = ean.replace(/\D/g, '');
+ if (digits.length === 0) return null;
+
+ return (
+ db
+ .select({ id: set.id, setNumber: set.setNumber, name: set.name })
+ .from(set)
+ .where(sql`replace(replace(coalesce(${set.ean}, ''), '-', ''), ' ', '') = ${digits}`)
+ .get() ?? null
+ );
+}
+
export function getSetByNumber(manufacturerId: string, setNumber: string) {
return (
db
diff --git a/tests/setup-env.ts b/tests/setup-env.ts
index b19663f..702ce79 100644
--- a/tests/setup-env.ts
+++ b/tests/setup-env.ts
@@ -10,7 +10,7 @@
* public base path and sets it to "/", which is not a URL. The clash only
* exists under Vite-based tooling, never in the app itself.
*/
-process.env.NODE_ENV ??= 'test';
+// NODE_ENV is typed read-only; vitest already sets it to 'test'.
process.env.BASE_URL = 'http://localhost:3000';
process.env.BETTER_AUTH_SECRET =
process.env.BETTER_AUTH_SECRET || 'test-secret-mindestens-32-zeichen-lang-0123';
diff --git a/tests/unit/ean.test.ts b/tests/unit/ean.test.ts
new file mode 100644
index 0000000..44ba727
--- /dev/null
+++ b/tests/unit/ean.test.ts
@@ -0,0 +1,48 @@
+import { describe, expect, it } from 'vitest';
+import { formatEan, isValidEan, normalizeEan } from '@/lib/ean';
+
+describe('EAN normalisieren', () => {
+ it('behaelt nur Ziffern', () => {
+ expect(normalizeEan('4 005556 603985')).toBe('4005556603985');
+ expect(normalizeEan('4005556-603985')).toBe('4005556603985');
+ });
+});
+
+describe('EAN pruefen', () => {
+ it('akzeptiert gueltige EAN-13', () => {
+ // Playmobil 70392 und ein Lego-Beispiel.
+ expect(isValidEan('4008789703927')).toBe(true);
+ expect(isValidEan('5702017152677')).toBe(true);
+ });
+
+ it('akzeptiert gueltige EAN-8 und UPC-A', () => {
+ expect(isValidEan('96385074')).toBe(true);
+ expect(isValidEan('036000291452')).toBe(true);
+ });
+
+ it('lehnt eine falsche Pruefziffer ab', () => {
+ // Letzte Ziffer verdreht - genau der Fall, den eine Kamera produziert.
+ expect(isValidEan('4008789703928')).toBe(false);
+ expect(isValidEan('5702017152678')).toBe(false);
+ });
+
+ it('lehnt falsche Laengen ab', () => {
+ expect(isValidEan('')).toBe(false);
+ expect(isValidEan('123')).toBe(false);
+ expect(isValidEan('40087897039271')).toBe(false);
+ });
+
+ it('ignoriert Trennzeichen bei der Pruefung', () => {
+ expect(isValidEan('4 008789 703927')).toBe(true);
+ });
+});
+
+describe('EAN anzeigen', () => {
+ it('gruppiert eine EAN-13 lesbar', () => {
+ expect(formatEan('4008789703927')).toBe('4 008789 703927');
+ });
+
+ it('laesst andere Laengen unveraendert', () => {
+ expect(formatEan('96385074')).toBe('96385074');
+ });
+});
From 5c5c5213bb6de33226a9f5ae12e1d5795d99aa39 Mon Sep 17 00:00:00 2001
From: unknown
Date: Mon, 7 Sep 2026 07:18:50 +0200
Subject: [PATCH 4/7] feat(fehlteile): fehlende teile je sammlungseintrag
pflegen
Jeder Sammlungseintrag hat jetzt eine Fehlteil-Liste: Teilenummer,
Farbe, Anzahl und Notiz, abhakbar wenn das Teil nachgekauft ist. Ein
Eintrag ohne Nummer geht auch - dann reicht die Notiz, denn beim
Aufbauen kennt man die Nummer selten.
Die Vollstaendigkeit pflegt sich dabei selbst: Der erste offene Eintrag
setzt den Sammlungseintrag auf "Teile fehlen", der letzte erledigte
zurueck auf "vollstaendig". Damit stimmt der Filter in der Sammlung,
ohne dass man daran denken muss.
Offene Fehlteile stehen als Badge an jedem Eintrag - in der Sammlung
und auf der Set-Detailseite.
Refs Phase 2
Co-Authored-By: Claude Opus 5 (1M context)
---
src/app/(app)/katalog/[id]/page.tsx | 3 +
src/app/(app)/sammlung/actions.ts | 126 +++++++++++-
src/app/(app)/sammlung/page.tsx | 5 +
.../sammlung/collection-items-section.tsx | 32 ++-
.../sammlung/missing-parts-dialog.tsx | 186 ++++++++++++++++++
src/lib/queries/collection.ts | 52 ++++-
src/lib/validation.ts | 16 ++
7 files changed, 414 insertions(+), 6 deletions(-)
create mode 100644 src/components/sammlung/missing-parts-dialog.tsx
diff --git a/src/app/(app)/katalog/[id]/page.tsx b/src/app/(app)/katalog/[id]/page.tsx
index 769ffb0..ac2add6 100644
--- a/src/app/(app)/katalog/[id]/page.tsx
+++ b/src/app/(app)/katalog/[id]/page.tsx
@@ -18,6 +18,7 @@ import {
listInstructionsForSet,
listPhotosForSet,
} from '@/lib/queries/catalog';
+import { listMissingParts } from '@/lib/queries/collection';
import { listLocations } from '@/lib/queries/master-data';
import { listWishlistsForSet, listWishlistTargets } from '@/lib/queries/wishlists';
import { requireUser } from '@/lib/session';
@@ -45,6 +46,8 @@ export default async function SetDetailPage({ params }: Props) {
const collectionItems = listCollectionItemsForSet(set.id).map((item) => ({
...item,
locationName: item.locationId ? (locationNames.get(item.locationId) ?? null) : null,
+ setName: `${set.setNumber} · ${set.name}`,
+ missingParts: listMissingParts(item.id),
}));
const facts: Array<[string, string]> = [
diff --git a/src/app/(app)/sammlung/actions.ts b/src/app/(app)/sammlung/actions.ts
index 3c1f5c5..a0b1ca3 100644
--- a/src/app/(app)/sammlung/actions.ts
+++ b/src/app/(app)/sammlung/actions.ts
@@ -1,9 +1,9 @@
'use server';
import { revalidatePath } from 'next/cache';
-import { eq } from 'drizzle-orm';
+import { and, eq } from 'drizzle-orm';
import { db } from '@/db';
-import { collectionItem, set } from '@/db/schema';
+import { collectionItem, missingPart, set } from '@/db/schema';
import {
actionData,
actionError,
@@ -13,7 +13,7 @@ import {
} from '@/lib/action-result';
import { reconcileFulfilledWishlistItems } from '@/lib/queries/wishlists';
import { requireUser } from '@/lib/session';
-import { collectionItemSchema } from '@/lib/validation';
+import { collectionItemSchema, missingPartSchema } from '@/lib/validation';
import type { Condition } from '@/db/types';
function now() {
@@ -103,3 +103,123 @@ export async function deleteCollectionItem(id: string): Promise {
revalidateCollection(existing.setId);
return actionOk();
}
+
+// ---------------------------------------------------------------------------
+// Missing parts
+// ---------------------------------------------------------------------------
+
+/**
+ * Keeps `completeness` honest without ever fighting the user.
+ *
+ * The moment a part is reported missing the item is not complete any more, and
+ * once the last open entry is resolved it is complete again. A deliberate
+ * "unknown" is left alone in the second direction only.
+ */
+function syncCompleteness(collectionItemId: string): void {
+ const open = db
+ .select({ id: missingPart.id })
+ .from(missingPart)
+ .where(and(eq(missingPart.collectionItemId, collectionItemId), eq(missingPart.resolved, false)))
+ .all();
+
+ const item = db
+ .select({ completeness: collectionItem.completeness })
+ .from(collectionItem)
+ .where(eq(collectionItem.id, collectionItemId))
+ .get();
+ if (!item) return;
+
+ if (open.length > 0 && item.completeness !== 'missing_parts') {
+ db.update(collectionItem)
+ .set({ completeness: 'missing_parts', updatedAt: now() })
+ .where(eq(collectionItem.id, collectionItemId))
+ .run();
+ return;
+ }
+
+ if (open.length === 0 && item.completeness === 'missing_parts') {
+ db.update(collectionItem)
+ .set({ completeness: 'complete', updatedAt: now() })
+ .where(eq(collectionItem.id, collectionItemId))
+ .run();
+ }
+}
+
+function loadItemSetId(collectionItemId: string): string | null {
+ const row = db
+ .select({ setId: collectionItem.setId })
+ .from(collectionItem)
+ .where(eq(collectionItem.id, collectionItemId))
+ .get();
+ return row?.setId ?? null;
+}
+
+export async function addMissingPart(
+ collectionItemId: string,
+ input: unknown,
+): Promise {
+ await requireUser();
+
+ const parsed = parseForm(missingPartSchema, input);
+ if (!parsed.ok) return parsed;
+
+ const setId = loadItemSetId(collectionItemId);
+ if (!setId) return actionError('Diesen Sammlungseintrag gibt es nicht mehr.');
+
+ db.insert(missingPart)
+ .values({
+ id: crypto.randomUUID(),
+ collectionItemId,
+ partNumber: parsed.data.partNumber,
+ colorName: parsed.data.colorName,
+ quantity: parsed.data.quantity,
+ resolved: false,
+ notes: parsed.data.notes,
+ createdAt: now(),
+ })
+ .run();
+
+ syncCompleteness(collectionItemId);
+ revalidateCollection(setId);
+ return actionOk();
+}
+
+export async function setMissingPartResolved(
+ missingPartId: string,
+ resolved: boolean,
+): Promise {
+ await requireUser();
+
+ const row = db
+ .select({ collectionItemId: missingPart.collectionItemId })
+ .from(missingPart)
+ .where(eq(missingPart.id, missingPartId))
+ .get();
+ if (!row) return actionOk();
+
+ db.update(missingPart)
+ .set({ resolved })
+ .where(eq(missingPart.id, missingPartId))
+ .run();
+
+ syncCompleteness(row.collectionItemId);
+ revalidateCollection(loadItemSetId(row.collectionItemId) ?? undefined);
+ return actionOk();
+}
+
+export async function deleteMissingPart(missingPartId: string): Promise {
+ await requireUser();
+
+ const row = db
+ .select({ collectionItemId: missingPart.collectionItemId })
+ .from(missingPart)
+ .where(eq(missingPart.id, missingPartId))
+ .get();
+ if (!row) return actionOk();
+
+ db.delete(missingPart).where(eq(missingPart.id, missingPartId)).run();
+
+ syncCompleteness(row.collectionItemId);
+ revalidateCollection(loadItemSetId(row.collectionItemId) ?? undefined);
+ return actionOk();
+}
diff --git a/src/app/(app)/sammlung/page.tsx b/src/app/(app)/sammlung/page.tsx
index fddc188..fe47006 100644
--- a/src/app/(app)/sammlung/page.tsx
+++ b/src/app/(app)/sammlung/page.tsx
@@ -10,6 +10,7 @@ import { completenessLabel, statusLabel } from '@/lib/labels';
import { formatEuro } from '@/lib/money';
import {
countMissingSets,
+ countOpenMissingParts,
getCollectionStats,
listCollectionItems,
type CollectionFilters as Filters,
@@ -40,6 +41,7 @@ export default async function SammlungPage({ searchParams }: { searchParams: Sea
};
const items = listCollectionItems(filters);
+ const openParts = countOpenMissingParts(items.map((item) => item.id));
const stats = getCollectionStats();
const missing = countMissingSets();
@@ -132,6 +134,9 @@ export default async function SammlungPage({ searchParams }: { searchParams: Sea
{item.completeness !== 'unknown' ? (
{completenessLabel(item.completeness)}
) : null}
+ {openParts.get(item.id) ? (
+ {openParts.get(item.id)} Fehlteile
+ ) : null}
{item.acquiredAt ? (
seit {formatDate(item.acquiredAt)}
diff --git a/src/components/sammlung/collection-items-section.tsx b/src/components/sammlung/collection-items-section.tsx
index 5a40f69..1e9c970 100644
--- a/src/components/sammlung/collection-items-section.tsx
+++ b/src/components/sammlung/collection-items-section.tsx
@@ -2,9 +2,11 @@
import { useRouter } from 'next/navigation';
import { useState, useTransition } from 'react';
-import { Loader2, Pencil, Trash2 } from 'lucide-react';
+import { Loader2, Pencil, PuzzleIcon, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { deleteCollectionItem, updateCollectionItem } from '@/app/(app)/sammlung/actions';
+import type { MissingPartRow } from '@/lib/queries/collection';
+import { MissingPartsDialog } from './missing-parts-dialog';
import type { CollectionStatus, Completeness, Condition } from '@/db/types';
import { formatDate, toDateInputValue } from '@/lib/format';
import {
@@ -54,6 +56,8 @@ export type CollectionItemRow = {
giftedBy: string | null;
lentOutTo: string | null;
notes: string | null;
+ setName: string;
+ missingParts: MissingPartRow[];
};
export function CollectionItemsSection({
@@ -67,6 +71,7 @@ export function CollectionItemsSection({
const [pending, startTransition] = useTransition();
const [editing, setEditing] = useState(null);
const [deleting, setDeleting] = useState(null);
+ const [parts, setParts] = useState(null);
function submit(event: React.FormEvent) {
event.preventDefault();
@@ -121,6 +126,11 @@ export function CollectionItemsSection({
{statusLabel(item.status)}
{completenessLabel(item.completeness)}
+ {item.missingParts.some((part) => !part.resolved) ? (
+
+ {item.missingParts.filter((part) => !part.resolved).length} Fehlteile
+
+ ) : null}
{item.locationName ? (
{item.locationName}
) : null}
@@ -140,6 +150,16 @@ export function CollectionItemsSection({
{item.notes ? {item.notes}
: null}
+ setParts(item)}
+ disabled={pending}
+ >
+
+
+ {parts ? (
+ !open && setParts(null)}
+ />
+ ) : null}
+
!open && setDeleting(null)}>
diff --git a/src/components/sammlung/missing-parts-dialog.tsx b/src/components/sammlung/missing-parts-dialog.tsx
new file mode 100644
index 0000000..e5c4dc2
--- /dev/null
+++ b/src/components/sammlung/missing-parts-dialog.tsx
@@ -0,0 +1,186 @@
+'use client';
+
+import { useRouter } from 'next/navigation';
+import { useTransition } from 'react';
+import { Loader2, Plus, Trash2 } from 'lucide-react';
+import { toast } from 'sonner';
+import {
+ addMissingPart,
+ deleteMissingPart,
+ setMissingPartResolved,
+} from '@/app/(app)/sammlung/actions';
+import type { MissingPartRow } from '@/lib/queries/collection';
+import { cn } from 'cn';
+import { Button } from '@/components/ui/button';
+import { Checkbox } from '@/components/ui/checkbox';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import { Field } from '@/components/ui/field';
+import { Input } from '@/components/ui/input';
+
+/**
+ * Missing parts of one owned copy.
+ *
+ * Adding an entry marks the copy as incomplete, resolving the last one marks
+ * it complete again - so the collection filter stays truthful without anyone
+ * having to remember to update it.
+ */
+export function MissingPartsDialog({
+ collectionItemId,
+ setName,
+ parts,
+ open,
+ onOpenChange,
+}: {
+ collectionItemId: string;
+ setName: string;
+ parts: MissingPartRow[];
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}) {
+ const router = useRouter();
+ const [pending, startTransition] = useTransition();
+
+ function add(event: React.FormEvent) {
+ event.preventDefault();
+ const form = event.currentTarget;
+ const input = Object.fromEntries(new FormData(form).entries());
+
+ startTransition(async () => {
+ const result = await addMissingPart(collectionItemId, input);
+ if (!result.ok) {
+ toast.error(result.error);
+ return;
+ }
+ form.reset();
+ toast.success('Fehlteil vermerkt.');
+ router.refresh();
+ });
+ }
+
+ function toggle(part: MissingPartRow) {
+ startTransition(async () => {
+ const result = await setMissingPartResolved(part.id, !part.resolved);
+ if (!result.ok) {
+ toast.error(result.error);
+ return;
+ }
+ router.refresh();
+ });
+ }
+
+ function remove(part: MissingPartRow) {
+ startTransition(async () => {
+ const result = await deleteMissingPart(part.id);
+ if (!result.ok) {
+ toast.error(result.error);
+ return;
+ }
+ toast.success('Eintrag gelöscht.');
+ router.refresh();
+ });
+ }
+
+ const open_ = parts.filter((part) => !part.resolved).length;
+
+ return (
+
+
+
+ Fehlteile
+
+ {setName} –{' '}
+ {open_ === 0
+ ? 'aktuell fehlt nichts.'
+ : open_ === 1
+ ? 'ein Teil fehlt noch.'
+ : `${open_} Teile fehlen noch.`}
+
+
+
+ {parts.length > 0 ? (
+
+ {parts.map((part) => (
+
+ toggle(part)}
+ disabled={pending}
+ aria-label={part.resolved ? 'Wieder als fehlend markieren' : 'Als erledigt markieren'}
+ />
+
+
+ {part.quantity}× {part.partNumber ?? 'Teil'}
+ {part.colorName ? ` · ${part.colorName}` : ''}
+
+ {part.notes ? (
+
{part.notes}
+ ) : null}
+
+ remove(part)}
+ disabled={pending}
+ >
+
+
+
+ ))}
+
+ ) : (
+
+ Noch nichts vermerkt. Trag hier ein, was beim Aufbauen fehlt – dann weißt du beim
+ Nachbestellen, worum es geht.
+
+ )}
+
+
+
+
+ onOpenChange(false)}>
+ Fertig
+
+
+
+
+ );
+}
diff --git a/src/lib/queries/collection.ts b/src/lib/queries/collection.ts
index b5acfa9..f0c162d 100644
--- a/src/lib/queries/collection.ts
+++ b/src/lib/queries/collection.ts
@@ -1,7 +1,7 @@
import 'server-only';
-import { and, asc, countDistinct, eq, sql, type SQL } from 'drizzle-orm';
+import { and, asc, countDistinct, eq, inArray, sql, type SQL } from 'drizzle-orm';
import { db } from '@/db';
-import { collectionItem, location, manufacturer, set, theme } from '@/db/schema';
+import { collectionItem, location, manufacturer, missingPart, set, theme } from '@/db/schema';
import { SOLD_STATUS } from '@/lib/ownership';
import type { CollectionStatus, Completeness, Condition } from '@/db/types';
@@ -158,3 +158,51 @@ export function countMissingSets(): number {
return row?.total ?? 0;
}
+
+export type MissingPartRow = {
+ id: string;
+ partNumber: string | null;
+ colorName: string | null;
+ quantity: number;
+ resolved: boolean;
+ notes: string | null;
+};
+
+export function listMissingParts(collectionItemId: string): MissingPartRow[] {
+ return db
+ .select({
+ id: missingPart.id,
+ partNumber: missingPart.partNumber,
+ colorName: missingPart.colorName,
+ quantity: missingPart.quantity,
+ resolved: missingPart.resolved,
+ notes: missingPart.notes,
+ })
+ .from(missingPart)
+ .where(eq(missingPart.collectionItemId, collectionItemId))
+ // Open entries first: those are the ones you act on.
+ .orderBy(asc(missingPart.resolved), asc(missingPart.createdAt))
+ .all();
+}
+
+/** Open missing parts per collection item, for the badges in the lists. */
+export function countOpenMissingParts(collectionItemIds: string[]): Map {
+ if (collectionItemIds.length === 0) return new Map();
+
+ const rows = db
+ .select({
+ collectionItemId: missingPart.collectionItemId,
+ open: sql`count(*)`.mapWith(Number),
+ })
+ .from(missingPart)
+ .where(
+ and(
+ inArray(missingPart.collectionItemId, collectionItemIds),
+ eq(missingPart.resolved, false),
+ ),
+ )
+ .groupBy(missingPart.collectionItemId)
+ .all();
+
+ return new Map(rows.map((row) => [row.collectionItemId, row.open]));
+}
diff --git a/src/lib/validation.ts b/src/lib/validation.ts
index 82306c7..a2a1b2a 100644
--- a/src/lib/validation.ts
+++ b/src/lib/validation.ts
@@ -147,6 +147,22 @@ export const collectionItemSchema = z.object({
notes: optionalText(2000),
});
+/**
+ * A missing part needs at least something identifying: a part number or a
+ * note. An entry with neither says nothing and would only clutter the list.
+ */
+export const missingPartSchema = z
+ .object({
+ partNumber: optionalText(40),
+ colorName: optionalText(40),
+ quantity: z.coerce.number().int().min(1).max(999).default(1),
+ notes: optionalText(300),
+ })
+ .refine(
+ (value) => value.partNumber !== null || value.notes !== null,
+ 'Bitte gib mindestens die Teilenummer oder eine Notiz an.',
+ );
+
// ---------------------------------------------------------------------------
// Wishlists
// ---------------------------------------------------------------------------
From 45aa8f13cfa8b55873c4b86905e34d8511eaa8ae Mon Sep 17 00:00:00 2001
From: unknown
Date: Mon, 7 Sep 2026 07:23:45 +0200
Subject: [PATCH 5/7] feat(daten): export als zip, json und csv sowie import
eines archivs
Unter Verwaltung -> Daten laedt der Admin alles als ZIP (Daten plus
hochgeladene Dateien), nur die Daten als JSON oder die Sammlung als CSV
- letzteres mit Semikolon und BOM, damit deutsches Excel es ohne
Rueckfragen oeffnet. Die CSV steht auch Mitgliedern offen, das
vollstaendige Archiv nur Administratoren.
Bewusst nicht im Export: Konten, Passwoerter und Passkeys. Ein Export
voller Passwort-Hashes im Download-Ordner waere ein Risiko und kein
Backup - das bleibt ./data. Ebenfalls draussen: Reservierungen und
Teilen-Links, die zur jeweiligen Instanz gehoeren.
Der Import ersetzt alle Anwendungsdaten in einer Transaktion, statt zu
vermischen: Zusammenfuehren zweier Sammlungen braeuchte Konfliktregeln
fuer jede Tabelle und waere im Ergebnis nicht vorhersagbar. Deshalb ist
er Administratoren vorbehalten und verlangt ein getipptes ERSETZEN.
Wunschlisten gehoeren danach dem importierenden Admin und bekommen
frische, zunaechst nicht geteilte Links. Pfade aus dem Archiv werden
gegen Ausbrueche aus dem Dateiverzeichnis geprueft.
10 Tests decken den Rundweg ab: Export, Loeschen, Import, Ersetzen statt
Vermischen, und dass ein fremdes Archiv abgelehnt wird, ohne etwas zu
loeschen.
Refs Phase 2
Co-Authored-By: Claude Opus 5 (1M context)
---
.gitignore | 2 +
package.json | 1 +
pnpm-lock.yaml | 8 +
src/app/(app)/verwaltung/daten/actions.ts | 45 +++
src/app/(app)/verwaltung/daten/page.tsx | 10 +
src/app/api/export/[kind]/route.ts | 106 +++++++
src/components/verwaltung/data-transfer.tsx | 138 ++++++++
src/components/verwaltung/verwaltung-nav.tsx | 1 +
src/lib/export.ts | 304 ++++++++++++++++++
tests/unit/export.test.ts | 313 +++++++++++++++++++
10 files changed, 928 insertions(+)
create mode 100644 src/app/(app)/verwaltung/daten/actions.ts
create mode 100644 src/app/(app)/verwaltung/daten/page.tsx
create mode 100644 src/app/api/export/[kind]/route.ts
create mode 100644 src/components/verwaltung/data-transfer.tsx
create mode 100644 src/lib/export.ts
create mode 100644 tests/unit/export.test.ts
diff --git a/.gitignore b/.gitignore
index 23c007c..7e8447c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,6 +23,8 @@ next-env.d.ts
/data-shots
/data-docker
/backups
+/data-test
+/data-test-export
# production
*.tsbuildinfo
diff --git a/package.json b/package.json
index e24d09e..903181a 100644
--- a/package.json
+++ b/package.json
@@ -32,6 +32,7 @@
"cn": "^0.2.6",
"date-fns": "^4.4.0",
"drizzle-orm": "^0.45.2",
+ "fflate": "^0.8.3",
"lucide-react": "^1.41.0",
"next": "15.5.25",
"next-themes": "^0.4.6",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 7386ebd..8fef224 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -35,6 +35,9 @@ importers:
drizzle-orm:
specifier: ^0.45.2
version: 0.45.2(@types/better-sqlite3@9.6.0)(better-sqlite3@13.0.3)(kysely@0.29.5)
+ fflate:
+ specifier: ^0.8.3
+ version: 0.8.3
lucide-react:
specifier: ^1.41.0
version: 1.41.0(react@19.1.0)
@@ -3348,6 +3351,9 @@ packages:
picomatch:
optional: true
+ fflate@0.8.3:
+ resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==}
+
figures@6.1.0:
resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==}
engines: {node: '>=18'}
@@ -8131,6 +8137,8 @@ snapshots:
optionalDependencies:
picomatch: 4.0.7
+ fflate@0.8.3: {}
+
figures@6.1.0:
dependencies:
is-unicode-supported: 2.1.0
diff --git a/src/app/(app)/verwaltung/daten/actions.ts b/src/app/(app)/verwaltung/daten/actions.ts
new file mode 100644
index 0000000..7699fb5
--- /dev/null
+++ b/src/app/(app)/verwaltung/daten/actions.ts
@@ -0,0 +1,45 @@
+'use server';
+
+import { revalidatePath } from 'next/cache';
+import { actionData, actionError, type ActionResult } from '@/lib/action-result';
+import { importArchive, ImportError, type ImportSummary } from '@/lib/export';
+import { requireAdmin } from '@/lib/session';
+
+const MAX_ARCHIVE_BYTES = 512 * 1024 * 1024;
+
+/** Typed by hand before anything is deleted. */
+const CONFIRMATION = 'ERSETZEN';
+
+export async function importArchiveAction(
+ formData: FormData,
+): Promise> {
+ const admin = await requireAdmin();
+
+ if (formData.get('confirmation') !== CONFIRMATION) {
+ return actionError(`Bitte tipp ${CONFIRMATION} ein, um den Austausch zu bestätigen.`);
+ }
+
+ const file = formData.get('archive');
+ if (!(file instanceof File) || file.size === 0) {
+ return actionError('Bitte wähle ein Bricktrack-Archiv (.zip) aus.');
+ }
+ if (file.size > MAX_ARCHIVE_BYTES) {
+ return actionError('Das Archiv ist größer als 512 MB und wird nicht eingelesen.');
+ }
+
+ try {
+ const summary = importArchive(
+ new Uint8Array(await file.arrayBuffer()),
+ admin.id,
+ );
+
+ revalidatePath('/', 'layout');
+ return actionData(summary);
+ } catch (error) {
+ if (error instanceof ImportError) return actionError(error.message);
+ console.error('[import] Archiv konnte nicht eingelesen werden', error);
+ return actionError(
+ 'Das Archiv konnte nicht eingelesen werden. Die vorhandenen Daten wurden nicht verändert.',
+ );
+ }
+}
diff --git a/src/app/(app)/verwaltung/daten/page.tsx b/src/app/(app)/verwaltung/daten/page.tsx
new file mode 100644
index 0000000..ace9e61
--- /dev/null
+++ b/src/app/(app)/verwaltung/daten/page.tsx
@@ -0,0 +1,10 @@
+import type { Metadata } from 'next';
+import { DataTransfer } from '@/components/verwaltung/data-transfer';
+import { requireAdmin } from '@/lib/session';
+
+export const metadata: Metadata = { title: 'Daten' };
+
+export default async function DatenPage() {
+ await requireAdmin();
+ return ;
+}
diff --git a/src/app/api/export/[kind]/route.ts b/src/app/api/export/[kind]/route.ts
new file mode 100644
index 0000000..8207855
--- /dev/null
+++ b/src/app/api/export/[kind]/route.ts
@@ -0,0 +1,106 @@
+import { NextResponse } from 'next/server';
+import {
+ buildCollectionCsv,
+ buildExportPayload,
+ buildExportZip,
+ exportFileName,
+} from '@/lib/export';
+import { formatDate } from '@/lib/format';
+import { conditionLabel, completenessLabel, statusLabel } from '@/lib/labels';
+import { centsToInput } from '@/lib/money';
+import { listCollectionItems } from '@/lib/queries/collection';
+import { getCurrentUser } from '@/lib/session';
+
+export const dynamic = 'force-dynamic';
+
+const CSV_COLUMNS = [
+ { key: 'setNumber', label: 'Setnummer' },
+ { key: 'setName', label: 'Name' },
+ { key: 'manufacturer', label: 'Hersteller' },
+ { key: 'theme', label: 'Thema' },
+ { key: 'year', label: 'Jahr' },
+ { key: 'pieceCount', label: 'Teile' },
+ { key: 'status', label: 'Status' },
+ { key: 'completeness', label: 'Vollständigkeit' },
+ { key: 'condition', label: 'Zustand' },
+ { key: 'location', label: 'Standort' },
+ { key: 'acquiredAt', label: 'Im Besitz seit' },
+ { key: 'acquiredPrice', label: 'Kaufpreis (EUR)' },
+ { key: 'retailPrice', label: 'UVP (EUR)' },
+ { key: 'acquiredFrom', label: 'Gekauft bei' },
+ { key: 'giftedBy', label: 'Geschenkt von' },
+ { key: 'notes', label: 'Notizen' },
+];
+
+/**
+ * Downloads.
+ *
+ * The full archive is for admins - it contains the whole collection. The CSV
+ * of the collection is available to every signed-in member.
+ */
+export async function GET(
+ _request: Request,
+ context: { params: Promise<{ kind: string }> },
+) {
+ const user = await getCurrentUser();
+ if (!user) return new NextResponse('Nicht angemeldet', { status: 401 });
+
+ const { kind } = await context.params;
+
+ if (kind === 'csv') {
+ const rows = listCollectionItems({}).map((item) => ({
+ setNumber: item.setNumber,
+ setName: item.setName,
+ manufacturer: item.manufacturerName,
+ theme: item.themeName,
+ year: item.year,
+ pieceCount: item.pieceCount,
+ status: statusLabel(item.status),
+ completeness: completenessLabel(item.completeness),
+ condition: conditionLabel(item.condition),
+ location: item.locationName,
+ acquiredAt: item.acquiredAt ? formatDate(item.acquiredAt) : '',
+ acquiredPrice: centsToInput(item.acquiredPriceCents),
+ retailPrice: centsToInput(item.retailPriceCents),
+ acquiredFrom: item.acquiredFrom,
+ giftedBy: item.giftedBy,
+ notes: item.notes,
+ }));
+
+ return new NextResponse(buildCollectionCsv(rows, CSV_COLUMNS), {
+ headers: {
+ 'Content-Type': 'text/csv; charset=utf-8',
+ 'Content-Disposition': `attachment; filename="${exportFileName('csv')}"`,
+ 'Cache-Control': 'no-store',
+ },
+ });
+ }
+
+ if (user.role !== 'admin') {
+ return new NextResponse('Nur Administratoren dürfen die Daten exportieren', { status: 403 });
+ }
+
+ if (kind === 'json') {
+ return new NextResponse(JSON.stringify(buildExportPayload(), null, 2), {
+ headers: {
+ 'Content-Type': 'application/json; charset=utf-8',
+ 'Content-Disposition': `attachment; filename="${exportFileName('json')}"`,
+ 'Cache-Control': 'no-store',
+ },
+ });
+ }
+
+ if (kind === 'zip') {
+ const archive = buildExportZip();
+ return new NextResponse(archive as unknown as BodyInit, {
+ headers: {
+ 'Content-Type': 'application/zip',
+ 'Content-Length': String(archive.byteLength),
+ 'Content-Disposition': `attachment; filename="${exportFileName('zip')}"`,
+ 'Cache-Control': 'no-store',
+ },
+ });
+ }
+
+ return new NextResponse('Unbekanntes Format', { status: 404 });
+}
diff --git a/src/components/verwaltung/data-transfer.tsx b/src/components/verwaltung/data-transfer.tsx
new file mode 100644
index 0000000..47537d5
--- /dev/null
+++ b/src/components/verwaltung/data-transfer.tsx
@@ -0,0 +1,138 @@
+'use client';
+
+import { useRouter } from 'next/navigation';
+import { useState, useTransition } from 'react';
+import { AlertTriangle, Download, FileSpreadsheet, FileText, Loader2, Upload } from 'lucide-react';
+import { toast } from 'sonner';
+import { importArchiveAction } from '@/app/(app)/verwaltung/daten/actions';
+import { Button } from '@/components/ui/button';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { Field } from '@/components/ui/field';
+import { Input } from '@/components/ui/input';
+
+const CONFIRMATION = 'ERSETZEN';
+
+export function DataTransfer() {
+ const router = useRouter();
+ const [pending, startTransition] = useTransition();
+ const [confirmation, setConfirmation] = useState('');
+
+ function submit(event: React.FormEvent) {
+ event.preventDefault();
+ const form = event.currentTarget;
+
+ startTransition(async () => {
+ const result = await importArchiveAction(new FormData(form));
+ if (!result.ok) {
+ toast.error(result.error);
+ return;
+ }
+
+ toast.success(
+ `Eingelesen: ${result.data.sets} Sets, ${result.data.collectionItems} Sammlungseinträge, ${result.data.wishlists} Wunschlisten, ${result.data.files} Dateien.`,
+ );
+ form.reset();
+ setConfirmation('');
+ router.refresh();
+ });
+ }
+
+ return (
+
+
+
+ Exportieren
+
+ Zum Umziehen, zum Nachsehen außerhalb der App oder als zweites Netz. Das eigentliche
+ Backup bleibt das Verzeichnis ./data.
+
+
+
+
+
+
+ Alles als ZIP
+
+
+
+
+
+ Nur Daten (JSON)
+
+
+
+
+
+ Sammlung als CSV
+
+
+
+
+
+
+
+ Was im Export steckt
+
+
+
+ Dabei: Hersteller, Themen, Sets,
+ Anleitungen, Standorte, Sammlungseinträge, Fehlteile, Fotos und Wunschlisten – im ZIP
+ zusätzlich alle hochgeladenen Dateien.
+
+
+ Nicht dabei: Konten, Passwörter
+ und Passkeys. Ein Export voller Passwort-Hashes im Download-Ordner wäre ein Risiko,
+ kein Backup. Ebenfalls draußen: Reservierungen und Teilen-Links – die gehören zu der
+ Instanz, auf der sie entstanden sind.
+
+
+
+
+
+
+
+
+ Importieren
+
+
+ Ein Import ersetzt alle vorhandenen Daten – Katalog, Sammlung und
+ Wunschlisten. Zusammenführen ist nicht möglich. Wunschlisten gehören danach dir und
+ sind zunächst nicht geteilt.
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/verwaltung/verwaltung-nav.tsx b/src/components/verwaltung/verwaltung-nav.tsx
index 017ac23..ecc9a6d 100644
--- a/src/components/verwaltung/verwaltung-nav.tsx
+++ b/src/components/verwaltung/verwaltung-nav.tsx
@@ -9,6 +9,7 @@ const tabs = [
{ href: '/verwaltung/themen', label: 'Themen', adminOnly: false },
{ href: '/verwaltung/standorte', label: 'Standorte', adminOnly: false },
{ href: '/verwaltung/einladungen', label: 'Einladungen', adminOnly: true },
+ { href: '/verwaltung/daten', label: 'Daten', adminOnly: true },
] as const;
export function VerwaltungNav({ isAdmin }: { isAdmin: boolean }) {
diff --git a/src/lib/export.ts b/src/lib/export.ts
new file mode 100644
index 0000000..5ae0265
--- /dev/null
+++ b/src/lib/export.ts
@@ -0,0 +1,304 @@
+import 'server-only';
+import fs from 'node:fs';
+import path from 'node:path';
+import { zipSync, unzipSync, strToU8, strFromU8 } from 'fflate';
+import { db } from '@/db';
+import * as schema from '@/db/schema';
+import { env } from '@/env';
+import { formatDate } from './format';
+
+/**
+ * Export and import of the whole collection.
+ *
+ * What this is: a portable copy of the data - to move to another machine, to
+ * look at outside the app, or as a second safety net.
+ *
+ * What this is not: a replacement for backing up ./data. Accounts, passwords
+ * and passkeys are deliberately left out, because a JSON file full of password
+ * hashes in someone's download folder is a liability, not a backup.
+ */
+
+export const EXPORT_FORMAT_VERSION = 1;
+
+export type ExportPayload = {
+ formatVersion: number;
+ exportedAt: string;
+ app: string;
+ data: {
+ manufacturers: unknown[];
+ themes: unknown[];
+ sets: unknown[];
+ tags: unknown[];
+ setTags: unknown[];
+ instructions: unknown[];
+ locations: unknown[];
+ collectionItems: unknown[];
+ missingParts: unknown[];
+ photos: unknown[];
+ wishlists: unknown[];
+ wishlistItems: unknown[];
+ };
+};
+
+/** Everything except accounts and sessions. */
+export function buildExportPayload(): ExportPayload {
+ return {
+ formatVersion: EXPORT_FORMAT_VERSION,
+ exportedAt: new Date().toISOString(),
+ app: 'bricktrack',
+ data: {
+ manufacturers: db.select().from(schema.manufacturer).all(),
+ themes: db.select().from(schema.theme).all(),
+ sets: db.select().from(schema.set).all(),
+ tags: db.select().from(schema.tag).all(),
+ setTags: db.select().from(schema.setTag).all(),
+ instructions: db.select().from(schema.instruction).all(),
+ locations: db.select().from(schema.location).all(),
+ collectionItems: db.select().from(schema.collectionItem).all(),
+ missingParts: db.select().from(schema.missingPart).all(),
+ photos: db.select().from(schema.photo).all(),
+ // Reservations are personal data of people without an account and have
+ // no meaning outside the running instance - so they stay behind.
+ wishlists: db
+ .select()
+ .from(schema.wishlist)
+ .all()
+ .map(({ shareToken, ...rest }) => ({ ...rest, shareToken: null })),
+ wishlistItems: db
+ .select()
+ .from(schema.wishlistItem)
+ .all()
+ .map(({ reservationTokenHash, reservedByName, reservedAt, ...rest }) => rest),
+ },
+ };
+}
+
+/** Collects every uploaded file, keyed by its path relative to FILES_DIR. */
+function collectFiles(): Record {
+ const files: Record = {};
+ const root = env.FILES_DIR;
+ if (!fs.existsSync(root)) return files;
+
+ const walk = (dir: string) => {
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
+ const absolute = path.join(dir, entry.name);
+ if (entry.isDirectory()) {
+ walk(absolute);
+ continue;
+ }
+ const relative = path.relative(root, absolute).split(path.sep).join('/');
+ files[`files/${relative}`] = new Uint8Array(fs.readFileSync(absolute));
+ }
+ };
+
+ walk(root);
+ return files;
+}
+
+/** One archive with the data and every uploaded file. */
+export function buildExportZip(): Uint8Array {
+ const payload = buildExportPayload();
+
+ return zipSync(
+ {
+ 'bricktrack.json': strToU8(JSON.stringify(payload, null, 2)),
+ ...collectFiles(),
+ },
+ // Level 6 is the sweet spot: JPEGs and PDFs barely compress anyway.
+ { level: 6 },
+ );
+}
+
+export function exportFileName(extension: string): string {
+ const today = formatDate(new Date()).split('.').reverse().join('-');
+ return `bricktrack-${today}.${extension}`;
+}
+
+// ---------------------------------------------------------------------------
+// CSV
+// ---------------------------------------------------------------------------
+
+function csvCell(value: unknown): string {
+ if (value === null || value === undefined) return '';
+ const text = String(value);
+ return /[";\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
+}
+
+/**
+ * The collection as CSV, semicolon separated with a BOM - that is what German
+ * Excel opens without asking questions.
+ */
+export function buildCollectionCsv(
+ rows: Array>,
+ columns: Array<{ key: string; label: string }>,
+): string {
+ const lines = [columns.map((column) => csvCell(column.label)).join(';')];
+
+ for (const row of rows) {
+ lines.push(columns.map((column) => csvCell(row[column.key])).join(';'));
+ }
+
+ return `${lines.join('\r\n')}\r\n`;
+}
+
+// ---------------------------------------------------------------------------
+// Import
+// ---------------------------------------------------------------------------
+
+export class ImportError extends Error {}
+
+export type ImportSummary = {
+ sets: number;
+ collectionItems: number;
+ wishlists: number;
+ files: number;
+};
+
+type ArchiveContents = {
+ payload: ExportPayload;
+ files: Record;
+};
+
+function readArchive(bytes: Uint8Array): ArchiveContents {
+ let entries: Record;
+ try {
+ entries = unzipSync(bytes);
+ } catch {
+ throw new ImportError('Die Datei ist kein gültiges Bricktrack-Archiv (ZIP erwartet).');
+ }
+
+ const json = entries['bricktrack.json'];
+ if (!json) {
+ throw new ImportError('Im Archiv fehlt bricktrack.json. Stammt es wirklich aus Bricktrack?');
+ }
+
+ let payload: ExportPayload;
+ try {
+ payload = JSON.parse(strFromU8(json)) as ExportPayload;
+ } catch {
+ throw new ImportError('bricktrack.json lässt sich nicht lesen.');
+ }
+
+ if (payload.app !== 'bricktrack' || typeof payload.data !== 'object') {
+ throw new ImportError('Das Archiv stammt nicht aus Bricktrack.');
+ }
+ if (payload.formatVersion > EXPORT_FORMAT_VERSION) {
+ throw new ImportError(
+ `Das Archiv wurde mit einer neueren Version erstellt (Format ${payload.formatVersion}). Bitte zuerst Bricktrack aktualisieren.`,
+ );
+ }
+
+ const files: Record = {};
+ for (const [name, content] of Object.entries(entries)) {
+ if (name.startsWith('files/') && !name.endsWith('/')) {
+ files[name.slice('files/'.length)] = content;
+ }
+ }
+
+ return { payload, files };
+}
+
+/** Guards against a path escaping FILES_DIR through the archive. */
+function safeTarget(relative: string): string | null {
+ const absolute = path.resolve(env.FILES_DIR, relative);
+ const root = path.resolve(env.FILES_DIR);
+ return absolute.startsWith(root + path.sep) ? absolute : null;
+}
+
+/**
+ * Replaces all application data with the contents of the archive.
+ *
+ * Deliberately a replacement and not a merge: merging two collections would
+ * need conflict rules for every table, and the result would be impossible to
+ * predict. Wishlists are handed to the importing admin, since the original
+ * owner's account does not travel with the archive.
+ */
+export function importArchive(bytes: Uint8Array, ownerUserId: string): ImportSummary {
+ const { payload, files } = readArchive(bytes);
+ const data = payload.data;
+
+ db.transaction((tx) => {
+ // Order matters only for readability - the foreign keys cascade anyway.
+ tx.delete(schema.wishlistItem).run();
+ tx.delete(schema.wishlist).run();
+ tx.delete(schema.missingPart).run();
+ tx.delete(schema.photo).run();
+ tx.delete(schema.instruction).run();
+ tx.delete(schema.collectionItem).run();
+ tx.delete(schema.setTag).run();
+ tx.delete(schema.set).run();
+ tx.delete(schema.tag).run();
+ tx.delete(schema.theme).run();
+ tx.delete(schema.manufacturer).run();
+ tx.delete(schema.location).run();
+
+ const insertAll = (table: never, rows: unknown[]) => {
+ if (!Array.isArray(rows) || rows.length === 0) return;
+ // Insert in batches: SQLite caps the number of bound variables.
+ for (let index = 0; index < rows.length; index += 100) {
+ tx.insert(table)
+ .values(rows.slice(index, index + 100) as never)
+ .run();
+ }
+ };
+
+ insertAll(schema.manufacturer as never, revive(data.manufacturers));
+ insertAll(schema.location as never, revive(data.locations));
+ insertAll(schema.theme as never, revive(data.themes));
+ insertAll(schema.set as never, revive(data.sets));
+ insertAll(schema.tag as never, revive(data.tags));
+ insertAll(schema.setTag as never, revive(data.setTags));
+ insertAll(schema.instruction as never, revive(data.instructions));
+ insertAll(schema.collectionItem as never, revive(data.collectionItems));
+ insertAll(schema.missingPart as never, revive(data.missingParts));
+ insertAll(schema.photo as never, revive(data.photos));
+
+ const wishlists = revive(data.wishlists).map((row) => ({
+ ...(row as Record),
+ ownerUserId,
+ // Fresh tokens: links from the exporting instance must not resurrect.
+ shareToken: crypto.randomUUID().replace(/-/g, ''),
+ isPublic: false,
+ }));
+ insertAll(schema.wishlist as never, wishlists);
+ insertAll(schema.wishlistItem as never, revive(data.wishlistItems));
+ });
+
+ let restored = 0;
+ for (const [relative, content] of Object.entries(files)) {
+ const target = safeTarget(relative);
+ if (!target) continue;
+ fs.mkdirSync(path.dirname(target), { recursive: true });
+ fs.writeFileSync(target, content);
+ restored += 1;
+ }
+
+ return {
+ sets: data.sets?.length ?? 0,
+ collectionItems: data.collectionItems?.length ?? 0,
+ wishlists: data.wishlists?.length ?? 0,
+ files: restored,
+ };
+}
+
+/**
+ * JSON has no date type, so timestamps come back as strings. Drizzle expects
+ * Date objects for its timestamp columns.
+ */
+function revive(rows: unknown[]): unknown[] {
+ if (!Array.isArray(rows)) return [];
+
+ return rows.map((row) => {
+ if (typeof row !== 'object' || row === null) return row;
+
+ const result: Record = {};
+ for (const [key, value] of Object.entries(row as Record)) {
+ const looksLikeDate =
+ typeof value === 'string' &&
+ /(At|Date)$/.test(key) &&
+ /^\d{4}-\d{2}-\d{2}T/.test(value);
+ result[key] = looksLikeDate ? new Date(value) : value;
+ }
+ return result;
+ });
+}
diff --git a/tests/unit/export.test.ts b/tests/unit/export.test.ts
new file mode 100644
index 0000000..fd4df7c
--- /dev/null
+++ b/tests/unit/export.test.ts
@@ -0,0 +1,313 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import Database from 'better-sqlite3';
+import { drizzle } from 'drizzle-orm/better-sqlite3';
+import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
+import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
+
+// A throwaway database for this file alone, so the round trip can delete
+// everything without touching the development data.
+const DATA_DIR = path.resolve(process.cwd(), 'data-test-export');
+process.env.DATA_DIR = DATA_DIR;
+
+fs.rmSync(DATA_DIR, { recursive: true, force: true });
+fs.mkdirSync(path.join(DATA_DIR, 'db'), { recursive: true });
+fs.mkdirSync(path.join(DATA_DIR, 'files', 'images', 'sets'), { recursive: true });
+
+{
+ const sqlite = new Database(path.join(DATA_DIR, 'db', 'bricktrack.db'));
+ migrate(drizzle(sqlite), { migrationsFolder: path.resolve(process.cwd(), 'drizzle') });
+ sqlite.close();
+}
+
+const { db } = await import('@/db');
+const schema = await import('@/db/schema');
+const { buildExportPayload, buildExportZip, importArchive, ImportError, buildCollectionCsv } =
+ await import('@/lib/export');
+
+const now = new Date('2026-09-07T10:00:00.000Z');
+const USER_ID = 'user-1';
+const OTHER_USER_ID = 'user-2';
+
+function seed() {
+ db.insert(schema.user)
+ .values([
+ {
+ id: USER_ID,
+ name: 'Florian',
+ email: 'florian@example.test',
+ emailVerified: false,
+ role: 'admin',
+ createdAt: now,
+ updatedAt: now,
+ },
+ {
+ id: OTHER_USER_ID,
+ name: 'Mama',
+ email: 'mama@example.test',
+ emailVerified: false,
+ role: 'member',
+ createdAt: now,
+ updatedAt: now,
+ },
+ ])
+ .run();
+
+ db.insert(schema.manufacturer)
+ .values({ id: 'm-1', name: 'Lego', slug: 'lego', createdAt: now, updatedAt: now })
+ .run();
+
+ db.insert(schema.theme)
+ .values({ id: 't-1', manufacturerId: 'm-1', name: 'City', createdAt: now, updatedAt: now })
+ .run();
+
+ db.insert(schema.set)
+ .values({
+ id: 's-1',
+ manufacturerId: 'm-1',
+ themeId: 't-1',
+ setNumber: '60398',
+ name: 'Familienhaus und Elektroauto',
+ year: 2023,
+ pieceCount: 462,
+ retailPriceCents: 4999,
+ imagePath: 'images/sets/s-1/bild.webp',
+ createdAt: now,
+ updatedAt: now,
+ })
+ .run();
+
+ db.insert(schema.collectionItem)
+ .values({
+ id: 'c-1',
+ setId: 's-1',
+ status: 'built',
+ completeness: 'missing_parts',
+ acquiredPriceCents: 4500,
+ createdAt: now,
+ updatedAt: now,
+ })
+ .run();
+
+ db.insert(schema.missingPart)
+ .values({
+ id: 'p-1',
+ collectionItemId: 'c-1',
+ partNumber: '3001',
+ colorName: 'Rot',
+ quantity: 2,
+ resolved: false,
+ createdAt: now,
+ })
+ .run();
+
+ db.insert(schema.wishlist)
+ .values({
+ id: 'w-1',
+ ownerUserId: OTHER_USER_ID,
+ title: 'Weihnachten 2026',
+ occasion: 'christmas',
+ shareToken: 'geheimer-token-der-nicht-wandern-darf',
+ isPublic: true,
+ surpriseMode: false,
+ createdAt: now,
+ updatedAt: now,
+ })
+ .run();
+
+ db.insert(schema.wishlistItem)
+ .values({
+ id: 'wi-1',
+ wishlistId: 'w-1',
+ setId: 's-1',
+ priority: 1,
+ sortOrder: 0,
+ reservedByName: 'Tante Anne',
+ reservedAt: now,
+ reservationTokenHash: 'a'.repeat(64),
+ createdAt: now,
+ })
+ .run();
+
+ fs.mkdirSync(path.join(DATA_DIR, 'files', 'images', 'sets', 's-1'), { recursive: true });
+ fs.writeFileSync(
+ path.join(DATA_DIR, 'files', 'images', 'sets', 's-1', 'bild.webp'),
+ Buffer.from([1, 2, 3, 4, 5]),
+ );
+}
+
+function clearAll() {
+ db.delete(schema.wishlistItem).run();
+ db.delete(schema.wishlist).run();
+ db.delete(schema.missingPart).run();
+ db.delete(schema.collectionItem).run();
+ db.delete(schema.set).run();
+ db.delete(schema.theme).run();
+ db.delete(schema.manufacturer).run();
+ db.delete(schema.user).run();
+}
+
+beforeAll(() => {
+ clearAll();
+});
+
+beforeEach(() => {
+ clearAll();
+ seed();
+});
+
+describe('Export', () => {
+ it('nimmt Katalog, Sammlung und Wunschlisten mit', () => {
+ const payload = buildExportPayload();
+
+ expect(payload.app).toBe('bricktrack');
+ expect(payload.data.manufacturers).toHaveLength(1);
+ expect(payload.data.sets).toHaveLength(1);
+ expect(payload.data.collectionItems).toHaveLength(1);
+ expect(payload.data.missingParts).toHaveLength(1);
+ expect(payload.data.wishlists).toHaveLength(1);
+ expect(payload.data.wishlistItems).toHaveLength(1);
+ });
+
+ it('nimmt weder Teilen-Token noch Reservierungen mit', () => {
+ const payload = buildExportPayload();
+ const serialized = JSON.stringify(payload);
+
+ expect(serialized).not.toContain('geheimer-token-der-nicht-wandern-darf');
+ expect(serialized).not.toContain('Tante Anne');
+ expect(serialized).not.toContain('a'.repeat(64));
+ });
+
+ it('nimmt keine Konten mit', () => {
+ const serialized = JSON.stringify(buildExportPayload());
+
+ expect(serialized).not.toContain('florian@example.test');
+ expect(serialized).not.toContain('mama@example.test');
+ });
+
+ it('packt die hochgeladenen Dateien ins Archiv', () => {
+ const archive = buildExportZip();
+
+ expect(archive.byteLength).toBeGreaterThan(0);
+ // Der Dateiname steht im ZIP-Verzeichnis im Klartext.
+ expect(Buffer.from(archive).toString('latin1')).toContain('files/images/sets/s-1/bild.webp');
+ });
+});
+
+describe('Import', () => {
+ it('stellt einen vollstaendigen Rundweg wieder her', () => {
+ const archive = buildExportZip();
+ clearAll();
+
+ // Der importierende Admin muss existieren - die Konten reisen nicht mit.
+ db.insert(schema.user)
+ .values({
+ id: USER_ID,
+ name: 'Florian',
+ email: 'florian@example.test',
+ emailVerified: false,
+ role: 'admin',
+ createdAt: now,
+ updatedAt: now,
+ })
+ .run();
+
+ const summary = importArchive(archive, USER_ID);
+
+ expect(summary.sets).toBe(1);
+ expect(summary.collectionItems).toBe(1);
+ expect(summary.wishlists).toBe(1);
+ expect(summary.files).toBeGreaterThan(0);
+
+ const sets = db.select().from(schema.set).all();
+ expect(sets).toHaveLength(1);
+ expect(sets[0]).toMatchObject({
+ setNumber: '60398',
+ name: 'Familienhaus und Elektroauto',
+ pieceCount: 462,
+ retailPriceCents: 4999,
+ });
+ // Zeitstempel muessen als Date zurueckkommen, nicht als String.
+ expect(sets[0]!.createdAt).toBeInstanceOf(Date);
+
+ expect(db.select().from(schema.missingPart).all()).toHaveLength(1);
+ });
+
+ it('uebergibt die Wunschlisten dem importierenden Admin und teilt sie nicht', () => {
+ const archive = buildExportZip();
+ clearAll();
+ db.insert(schema.user)
+ .values({
+ id: USER_ID,
+ name: 'Florian',
+ email: 'florian@example.test',
+ emailVerified: false,
+ role: 'admin',
+ createdAt: now,
+ updatedAt: now,
+ })
+ .run();
+
+ importArchive(archive, USER_ID);
+
+ const [list] = db.select().from(schema.wishlist).all();
+ expect(list?.ownerUserId).toBe(USER_ID);
+ expect(list?.isPublic).toBe(false);
+ expect(list?.shareToken).not.toBe('geheimer-token-der-nicht-wandern-darf');
+ expect(list?.shareToken.length).toBeGreaterThan(16);
+ });
+
+ it('ersetzt vorhandene Daten, statt sie zu vermischen', () => {
+ const archive = buildExportZip();
+
+ // Zweites Set, das im Archiv nicht vorkommt.
+ db.insert(schema.set)
+ .values({
+ id: 's-2',
+ manufacturerId: 'm-1',
+ setNumber: '99999',
+ name: 'Wird ersetzt',
+ createdAt: now,
+ updatedAt: now,
+ })
+ .run();
+
+ importArchive(archive, USER_ID);
+
+ const numbers = db
+ .select({ setNumber: schema.set.setNumber })
+ .from(schema.set)
+ .all()
+ .map((row) => row.setNumber);
+ expect(numbers).toEqual(['60398']);
+ });
+
+ it('lehnt ein fremdes Archiv ab, ohne etwas zu loeschen', () => {
+ const before = db.select().from(schema.set).all().length;
+
+ expect(() => importArchive(new Uint8Array([1, 2, 3]), USER_ID)).toThrow(ImportError);
+ expect(db.select().from(schema.set).all()).toHaveLength(before);
+ });
+});
+
+describe('CSV', () => {
+ it('trennt mit Semikolon und beginnt mit einer BOM', () => {
+ const csv = buildCollectionCsv([{ a: 'x', b: 1 }], [
+ { key: 'a', label: 'Spalte A' },
+ { key: 'b', label: 'Spalte B' },
+ ]);
+
+ expect(csv.startsWith('')).toBe(true);
+ expect(csv).toContain('Spalte A;Spalte B');
+ expect(csv).toContain('x;1');
+ });
+
+ it('maskiert Semikolon, Anfuehrungszeichen und Zeilenumbrueche', () => {
+ const csv = buildCollectionCsv(
+ [{ note: 'a;b "c"\nd' }],
+ [{ key: 'note', label: 'Notiz' }],
+ );
+
+ expect(csv).toContain('"a;b ""c""\nd"');
+ });
+});
From 39271f8af71b0aa9d2a600420e4f6907835f5403 Mon Sep 17 00:00:00 2001
From: unknown
Date: Mon, 7 Sep 2026 07:27:12 +0200
Subject: [PATCH 6/7] feat(pwa): installierbar mit manifest, icons und
offline-katalog
Manifest, Icons (auch maskable und Apple-Touch) und ein Service Worker.
Auf dem Handy laesst sich Bricktrack damit auf den Startbildschirm legen
und startet ohne Browserleiste; die Verknuepfungen springen direkt zu
"Fehlende Sets" und zur Sammlung.
Der Service Worker liefert Seiten nach dem Muster "erst Netz, dann
Cache": mit Verbindung ist alles aktuell, ohne Verbindung bleibt lesbar,
was schon einmal geladen war - genau der Katalog, den man im Keller oder
im Laden braucht. Statische Build-Dateien und Set-Bilder kommen aus dem
Cache, weil ihre Namen ohnehin unveraenderlich sind.
Bewusst nie aus dem Cache: /api/auth, /api/export und der Healthcheck,
und nichts ausser GET - eine offline "reservierte" Wunschliste waere
eine Luege. Beim Abmelden raeumt die App den Cache, damit auf einem
geteilten Geraet keine lesbaren Seiten zurueckbleiben.
Icons entstehen reproduzierbar aus einer SVG-Quelle
(node scripts/generate-icons.mjs).
Refs Phase 2
Co-Authored-By: Claude Opus 5 (1M context)
---
eslint.config.mjs | 11 ++
public/favicon.png | Bin 0 -> 332 bytes
public/icons/apple-touch-icon.png | Bin 0 -> 2781 bytes
public/icons/icon-192.png | Bin 0 -> 2384 bytes
public/icons/icon-512.png | Bin 0 -> 7934 bytes
public/icons/icon-maskable-512.png | Bin 0 -> 3911 bytes
public/manifest.webmanifest | 45 +++++++++
public/sw.js | 122 +++++++++++++++++++++++
scripts/generate-icons.mjs | 67 +++++++++++++
src/app/layout.tsx | 12 +++
src/app/offline/page.tsx | 28 ++++++
src/components/layout/service-worker.tsx | 38 +++++++
src/components/layout/user-menu.tsx | 3 +
src/middleware.ts | 4 +-
14 files changed, 329 insertions(+), 1 deletion(-)
create mode 100644 public/favicon.png
create mode 100644 public/icons/apple-touch-icon.png
create mode 100644 public/icons/icon-192.png
create mode 100644 public/icons/icon-512.png
create mode 100644 public/icons/icon-maskable-512.png
create mode 100644 public/manifest.webmanifest
create mode 100644 public/sw.js
create mode 100644 scripts/generate-icons.mjs
create mode 100644 src/app/offline/page.tsx
create mode 100644 src/components/layout/service-worker.tsx
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 719cea2..df2f176 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -11,6 +11,17 @@ const compat = new FlatCompat({
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
+ {
+ rules: {
+ // Destructuring a key out in order to drop it is how we omit fields
+ // (share tokens, reservations) from an export - the binding is
+ // deliberately unused.
+ "@typescript-eslint/no-unused-vars": [
+ "warn",
+ { ignoreRestSiblings: true, argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
+ ],
+ },
+ },
{
ignores: [
"node_modules/**",
diff --git a/public/favicon.png b/public/favicon.png
new file mode 100644
index 0000000000000000000000000000000000000000..a7ff39ab327d843576c8b7154d52e658e3e8ad0f
GIT binary patch
literal 332
zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=fdz&H|6fVg?3oVGw3ym^DWNDEQCQ
z#W5t}@Z0ccz9s{KH~RhlUlZ8gGu&}{TD6G1(WOz}u3>J=B(14SILl1SCVO(+ed9Cz
z(i@J2E&?pp=RX@Ay88WZ+lBQlZCTG+j4fL9GCn%22vfPrB-4`N=GT39`AN}|$sHTt
zPIWE1+37CP7X8LcslO&$M+@RqQ
z9##29zopr00BCQ3IG5A
literal 0
HcmV?d00001
diff --git a/public/icons/apple-touch-icon.png b/public/icons/apple-touch-icon.png
new file mode 100644
index 0000000000000000000000000000000000000000..cc0eb0aa637b6097bfc135396cef6b2cb2be7376
GIT binary patch
literal 2781
zcmcIm`#Tei7k7!I$R&o?ok(bJluSr7W{5W=xmQ9VVajE5i`Ok=ZW$V(tfS&9--G&85v~ef535KYagz_j%4apL5PH=RD8(Jm;MBzZ{@qq6(sXe0*Xs8;H|B
zw*FQT;eBg1B5be^hwj;2i{RrsEd5*g`Ou|ue0-ugFvvxhsG0S$OTjKvG6@)ntP$z{
z%jyGP9C3dtiGGZY^3ANuuT?qk7;|eQl(qIQD6eE>iw4l3~Q?Pef^a7k6ouI*zTl5>=-$HBC)du$~@;d3685y>k15+tS>0fW@apR+VJR*}YEdWck2eJ;O8k$`
z0!N^k4yGtJOU#tI`!&Ms$uPZDcq*o&0~CU}CBCUMcmOJ=tFNzbPqkaTg%ZhTrS=x#
zV9Fh7UvY7ul9EyyzPWa(r?V5ZCHE?H
zK{TR1~#sFn`fROKubr8yKe*JoOhtufObN2>!
zi7?imtrXFXE7&jHgU6dtICt*cS-udYMw*;-O?G&2_H7Cee-qcWN&LQqhQ!?WrC`H
z2CVTf3pPVJF0kS4Q5Ex2ID+dL#1aBMma32rL`nuJb??uyYGt-9sHl?@6B8q0azfjo
z^v|iKnE8kn)~CrybY@dSgILAGmfI6r@o{nU_5nFrS=EY_)tgOiK%?N#31jp{u+TN2fKwAQDWiov=PzWk
zlyUdZUG^^f+uVvLjiTP_+85sTwK(JR_;~L9M==K7bqj#jU@y5gImsB9{)Mtiyvu1@
zth@>SZ<&=u!GopfxZ%-Ju9uhB^Y@x1C$s8<=Lw+hjwYd)@M~hW@tsM-#d*cfL33ba
zjwcb@l>3jc%u^G*IF5{Ys96m$h5ne$!e>x#O!WgE<5fB0>aA0UxtMcp`9
zWMt%?E0<2Emxj!b&pzLB$ZHFl`db;(y;f`(us8cEl&WVw(y-=sE=dh8{3@pwawST|vjoa^0c+
zQ_QHugL;G3=NdJlQ;+N9Yuvisj48as+;K%Vd6?
z#)*uC;T5r&V~SCuiByrSv42;TFoFKFE`8PK7Eog
zw$?9Oi$P2^dKq4xAA!i!x3n0zU~&|*htq8{wpuRg)_uld{YxJ
zUm=dG;BGQP%$?2{UbEF;*i6pM{PPY#%*~bTX>LBfC0Bw*FMoRO$q{%VBdm8N*XtNJ
zm#9wJXHFax3PmI2cXv1n!BysZyH99(ux_6^i>;}zK2Oq+i5aD(
z9&~&Es0f3(V5P1qnl|(Rp8Ql*UP6-OLW@U0>YEy
z3j8dNJq}|uXKMejNI41SV9sZ}0
zU8VKB$1bV&GYv7l`&>(!AsS7#loGOzI#Idl&zwb@(#`fGP9D2e%du8Q`LW);(p>|S
ztFeP;qBnprIb(;OqF`Me9UYVkM+GJb?GbU11pqEEs>yI&XDUu
zYX}%w&61}6j@=b(G*_(c^s8jma)2>hY2Z5<1-i4r+j$5-klgXAvv(+RqBGmu2kWd#
zJKd283<#)g@b1F9nSbH)a8xU}a%5@hpKR)#iJct8fLbAYQAfQWHUHr&cmij>ia|Y)
zx*AP`r1`KWqFYX#h;l9Yh&z>Q;SDgavMgFx+>wq(cv!DeyH1=no3l=ewM^QrQ*ML?
zK-r6s=sk-ZDvFy~LbyeCwHX7x4_()l;^^#b$}BPaAUBA9!k{m6JB75$bO^?4+}z&N
iDsHLH*&$cQnQEUU92&(ewR(l@Dg+0BHpKCjAFCwP(2i
literal 0
HcmV?d00001
diff --git a/public/icons/icon-192.png b/public/icons/icon-192.png
new file mode 100644
index 0000000000000000000000000000000000000000..4f65f83490ea6463a46cebdecda73ed75ad5b6d2
GIT binary patch
literal 2384
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
S`Kc-RMFPk+&ZIgkAI4v3{QE2b
literal 0
HcmV?d00001
diff --git a/public/icons/icon-512.png b/public/icons/icon-512.png
new file mode 100644
index 0000000000000000000000000000000000000000..69dcaa8d65f2b75df2787d4e7a775b5c831eec35
GIT binary patch
literal 7934
zcmb_h2Ut^Ew%!R+14INv7rcmqfTDmPEvO)f4N+;KUQkp@0F@4jA|Mw9RGLz~is(f^
z=|~Gix)i1NB1K9d^Z+4whk*CZyqS43Z{{T*-`VW5&RTo#v)2EwwGv`*L6?g|hywt?
zbw*Fy5CAwdgabAf=(6b9x&~d?UGz+E1AyS&{DXlP@d5zs`EW-2#1+qOQXeAX>WT#`
zxo5wHbyv~FhgDi4XMUFx47r?}YpS2qE=+By0GhqJP0jw^n$2yx-Ewik@-&_83*4vi34rfx4D?zd9gbC@FkLbw%by
z?V%3a6D$U{w)|gGY+a24tG9~B}EKfIKTRBypdY=cq2#jS}
zh~>QHYviVtoSYoR-u20$%JJ8%##+CfW
zVJdkqu-|8U&^vo>QM}uc2MIZ9{EH6?izJJGmUf8x(}H
z(+`yDVA73xM7}!mc30edoga?Vo|QyvgFuh{Ulz;QvP=4S8r?sVmiCIHdG60+{Y`+r
zkCAx)6;hwgnAPlu7)pD4yAjttPb}MKSF{MIedr*$TH2wgE`>a|Q;<^3%dA!&1OuzH5u?qvQ9F6{vLVq##XMnfryg!7RCr%p0!oyb54uvKTc>ym3r=B;+479{V%ldVfd
z4slaWb>|(_C}kbGR3Jrp9I)q(b`r(i&m>282P^(*u_XCMvBzWLdX(76g6ln4vK_6Q
zDe%y3;{nPKoxQN+*NKh}4r!{rRv#GN8R(_20;CF4HO~E*%3n(0w_HU_@PU25r<$eb
zT~)8jFU1G|z3B>qJ+W&x`T=&zihlD15WkcZa-iaJad%mLBdhqF0xU^?q6LN}UqS;)
zP&k`qQiT^zc@>7os`Gxxo@S*ZJHG;#OUTHjcm56)Rx`WR1i+!msBaotQXk9v0$IQK4wcw||$P@NJyE~FfcCF`iMgEy7@W*CgH
z-v|{n^3@bdlaHowvPT~WxPY`fqWktB9Ikt9*JMSK0S@2gmanQ0W~_XAK{q|_QN)4QIrU9y9)1DCM8i3^e1~m
zxj^ATKy-3)YMby~qQel-|BHki5$bo|hbM_DH}Ap3W9(t2e3>nAVNDzKAZ8Z4c?^E#
z-0C=uC`P?s$&5EwJC?G-%$7;RRs9avq6JnnXC~H#(z#8p0I3Hv(#I*m`{&$4*+HNx
zqFHly{zl)mBd}xxekR)LYi`>4^XD6ekg86bQ;iR7J?rf(H_EDifott;g7>1I!^vVO
z9>8G=XbWaY8ulX|7~H^?Mu*eIx(#9XYuQqqRit*;=N
z&mPwAt&~bvlM@1w6|+_i6Ld`k3t%td+UfsY`4RMXBvK1pU~TAo+^_?O&J&r4h^^
zWqu@TW@BKNe~YmkABe0o#+aGCIJ7cXri5Y#`m3&l(HOOjvc6wnTGAU|M`!5J*|PzC
zFP6csvnBKa5r1O+rrboYP9_r@ofwW00aCd|huzn30f8{QVHp>>h+hUIWvKvzNjSMS
zbT{56lnJC90>lAdGW{5W35+Ux%=BUl$#8;?EC<+k3HXmxz?*F@fn;ih1rE1Skq`D{
zOM!^bcY#_&Ww9ZQj2E
zqiD+lRypYT6tz-jm(G(w%h1~v=J|DVU`ZAMKyOwEUv2_u9lt^|%!As#h`~pd0RK8Z
zjCn9VaC4Amvg1%|XJ@P&CQo+i$B!EKncn9bt0T`$u~_Ueb@liJO+QsNpJmyYBgc-p
z`=<}TsLTy@5a>9Xanl?l_zb-&ahMNejg5^tE={&|mHMq`@gBG!@VdqB&K<>`A`fid
zt+qcF91i)+AHYe0*3S*f1MyDXInu%+B8uYnCgux|g_W-(_zy**SD&Hz!1XFF<=zOV
zzE4VOUh}VQOl)1n9J{h4Rw2}xg^iE+0Ku)K~{|-8s3VSLbsHy`E-b<-e+gG
ze5fz9D008HFxK!4J%VJ$*A^|cW3mHSkwPxaUt3z9$(iNo(IOyA8GWSufyB*>f(279
za@=hnCd)2_q078PZKgnCYIat+nd6Vl%*^IfCbUgeLItDIm@Hy2QrM2!gG!m3&g*gB
zG7@6pGOE!r|6^vR(ag+j1i5+&HaPD--D%?z^@WH;%i?OpI6w>v`|g%?RLlHm{U}2s
zcX-^RWY%mZE_Y-#%xUX<^omRVIC0f^u77ntnEDcsl@KUd^1fW{nZCqo)`FTOXwE<)
zP8oYFdDmR?DP*nKd@!7l)^vGAuk8*eC+End*UXlpnionAoNqqAH~DySbG!IfQcr%9
zf&Q074Ijq(^h|ASZI7v{-jb5MZfCbI%_wbN_Zzfsy>1JSzH8!=f0UG1(DYQHBR>N<
zh5Vykxz2s6s}d8?0``%<>WV2n+O>PO@G$OmJf!?JX`pOCUjK@*aSZjz9hw1nI@Uim
zF-amtp`SL2acF%D3JT(U^#EJ*%eFj3$bzRsRPRnkZp&ZanR1?{cN{Xu0a!jjMv{RP
z5FkLi!^4w-HK>OUkOL3L!(<_mKZQ1j454I5o*@K@Yzsm1&=Y>*kh}mv3T(as7I_Y^
zsS)-Fj)zQGgIC~RxXppzYxfWA|Lr3G+CF0^|8tQ^)aT{@?pl8CBq-vq58`hZ^53Vm
zc@TehA!3^c$~djq|9C+x^8e&j{kg(qV8X@>m`orq;e)=M){xT~00SV8A4M{2!L(om
z5DkSVBqG2JpnC*x8m0va%i}MB&A8>X<^xbP{wuec0i7(T^*99ae8`Wkbx5Ge4SUCIZ*Go*&MlDd`{IwUjx_2v+?dH2XDGhcpX~RUjbgy(S)Y+7x}5KycI7$6ZGoeugM&kR
zON*^b)aQFltXa{~D2C~RES*r47dmfWlX>{MAp>FeJJvpxG_|ryI$>nsI#B5+#ehL$
zr@7(Eu9v5RIdkrZWG2?j`;K0Omi~5bqH|`(IUrqYwlI1kCyiPQ1*{U^;o*ttX~jV|
z=jt%gl;T1CEg%lPIH&41^3W!R7@BTKiO<~b3U%ONP~4)^I^WRlY_$+IzRWp
zpQR3o2?)q@I*|3+C|7dhoIjnRjWEn+b}jT8zc+b8>dHPKEGT$HJhhfgcFFTunOR6J
z*SKiGFlheHD6Q*T%GvC@Q|$?5;&0Q^WPE6=+*Nh8kj)f+Ld*!J}WFN%yGOiq3hX2&F7FSUndfY
zT@x*-+1d6@Zh{c{k9Qo*Ff}hK2y&JC{-P>3>Ij2@-Ch+I9>%3hT8kFthjyct_u(ER12%8C(N*lVz@RMadFPU)_UXa$02cETi;IhPp(weh3@>%?SSRJ%H)*WImmA6J
zhKbZh%bNbO+4)O_a~>OWEOWuqajd9W^pbYjh-eC5m`*jMbZB1MWno*
zEhE43n|n3os(&gf%Kp>0i$&Gk1#^`;-=61#Jt}l}mB6mUWaSy|~VLOz*Jq`;K(hm_7
z6s-ND?T>9%b?>n75Y!8~g~i5fBao`9D(5ThbWHw*ZF2Wn{HhBQ87C)V}!)Z3yxHPb&VCum2>0zeDQ(jHck$
zV*fseZ}TP|Zp|k6&ytEi7x@p8iofL21GwuS{;&DOPY(Z?jr{*+7h?bUf?$vS_52HU
z@=w10Ur7Bw;P9Ur0F)SP=Lmm6>iDC=!omsE?2A2@x3&Xfw1gQb#$h^qr*O8_my4`
zSFt(<3|_?)d%C*+Xx__|Zathfew5RSK_Rz%Rr899daPxdeKM}8k`Cga+)#_FGSpL5
zO^vG{zUQN@uJqx8rwkyj8opc{CUX4O&QbEOX?&XYatz_evz>W2TlTLwH{B~h4wJ6-
zba!u|^VpujT{sFW3kxSvNZn9L^D600oHL|pvv!2cIa!y8qpdpLxFNOaOnRB(8JEn{
z3a-17hJ`XLijFy8vFNhAqx*65x``Zw;|LfybDSSR5M&H2o-5cl(#@a(T~}o}=^?Xq
zC!@@^mM>){>x4q0UVBk^;%3=C?+aDljEB5bQTS@P9bn&GEW@k0P%P54>BnH
zh0RA+15ALX-CiIrJsaKuda=;xT|SrzwFmvzAW7+!D?&m-C=45TdvT&RZs00AhH`g9
z2DJk`CF`p!H-#~aTe8aALEQ#+zbN`Z1zO~TIEZ+VT51F;1o73fY+&CUJ3idPlf^n+
z2ZrCrL0CT|3p_A?`^a*F$h8f+N9h}olb3fah&~t3496R)c0k?ZbaO`pmdUpr
z4z*_n=(sm-@8qaK=#P59KtCZy7Q7UFpQQy1>J$Lls}|ne+S)4ev(M8N9Uc9qZZwY^
zN2@#u(|TvQLBBn!kD)v+v;7G5hxWwk!924AAv=CF+?i|eo~a6~|JVDFnc|({BwlGS
zCrc1Bkq1(h>+~|?=Rb}?j*)Mw2&8mi6k6R?$2lkbcN90^ApLl7k#6O3IT?w8TBnO?
z^oFRkz6$!py6hgn*S&Hj;~GM8&@_}41h!r-Y?tH1TjfF>ubLM6ip6c%!xb)WT+K}c
z)Jb#OuQ`x<^tqhk!)n=mfN!#mo^*A8QJ@c8Ha8X+h|KwGM!oB-5L=!_@d1vZiMlIJ
zFVDRHm<$Xaw$Nn`UM{fUr!`BUP+)Swf4pq!O&0hZ3LCJufr)oOUA&Z|Z5ZgIL@j@X
z5&&*qWU;Uq($zP%2>QK&yc!U3)BF{8LF`0bW@@+o^+c4
z(ajcVK$M7M_DOb1{AR<{ZD?Tp`$uIcd(k{ZaT<#A?h1q9Z!Wp1jO7{6=LBLWFHQ@9
zSfhlfVkbE{x${H9>%S*3ftF!U-=kQ`aEyxFM_J#1@#%daw)--Dh^B_VQoDdUbfNQ>
zF4POQq~yOe#FMy&;{3X~neftIdBf80>PUeosPCX0h^6Etuz+Vrjf{;~5~6-_8Y=lM
zDR;A{+;1U0j4GWACaHsh{bKL92tcOS08Ur(9%+pnv%#t+0vgwgTpWutp4J%gcQ+oGz5{mQM(NtcMx{?XQM8$*
zb!LFq_S@gDQTBAWy}P?x0F?|yyxw#aJsaxH%0)%y-^%SqpZ=u77a5lKX$@PjO&e25bX#P~M
zEE|4T$vnNq6DbnB5`tD6ELlnv1zN5pyTYy^Re!g_u$%Cp$d7iy4CJl2FIH?w>(19|
z=u;XEcB}=m`3~piS3x;mz5(Y0LP%!ePPkURLb6yFLyZd90G2)d{m4|
z%7?ty_K1^jm&Yc1dm4tvkVYaGBo?R9^TQnq}m|fzZawJq3?AXI=2_`d1
zdo}3-ns@VRcybB+>_G_>u?kMY4kVS%TS|K?apQR2R0Vg*;pt}0A(p{ir2Nv=
z1@E2m7XVLP6~An(RN3N*$~t=xrOfEy?hT!NQ`t9dz7$ywXxhb
z-9n{%76Or_$$nzAMjAYQaz8H=1@=O
z^uG;;?5xP7oY)cTK7Kz@-b0Mjs!X|5{Gv|R9%K7@atH1U)f$jGq
zAQhGdDUKx_uRNV}3LlbZD0UTt&&ngj?C>&{#mWNed#r|n?F*@T5OB5eR2oT_2ZWSL
zdfHP|>N2atQ$DYA7swrn_;Ifk=gUGfjSq1>#-glZ*lE~vU0G4FSehzN?WCLv)fDhm
zq^=zfxcI;dEh-W+ObQL)9qLHszLqr?f?ymBdtA&7}n5-s&H^d=Yn>|N$dOn1+Pu)iU0rr
literal 0
HcmV?d00001
diff --git a/public/icons/icon-maskable-512.png b/public/icons/icon-maskable-512.png
new file mode 100644
index 0000000000000000000000000000000000000000..68eedb8d3bc063c165118f500c958e9580ca74ad
GIT binary patch
literal 3911
zcmds4Yfw{37VaQw)B>Zp!z?Qgb#!!40t1MG4-hcI4oHa$!m2zNhnHXi_BNc6k3ViNkZ*Q;c?|+c5_cAp#UB;C>M#JXT*49e3TI{Cx6jI4cGCfBhn;HVO
ze(kN@Uu}F+5j5cXH?u7+KWUS+nSi~MX3XJmj0Y@t-3Txde$nI7KDr!qG#BtTZLDXt
zHa2Dyyt8?C*Y>UX)q`K!LBOCXA{YT_M-2a!-y`pWAw9zzR
zWmi&CV%&3NEtxFGK<#)@%&Y+CddT<2JN=&znQK!Qf!E(kV=7#(ojTLh-HUCrR@2#8
zyX-<~@C~#?N8okV$V-~yq)hv^rnB=h0+QsRfBTB<%L%cGLT)UiG3Q)zo5NTv1p>C|
zyyR-l#+7C#j^XA%%D>|r@P49DAQZ%D1fG$R8lvnQphNdaPj~cchWvEB3_@RyKhR
znPmlsv7wo~vXMIyfZ@fKXoD#;sGJ>!?a~#ljfjAp^T`Gugo8T_Ax${laC-KP{7)Kp
zG+^8QR5*a?k>42)98W2XJr)Q^ArgQoz?eK4mde>mEK7?(JMp*lj0s^%Lwz#@0w-kK
zr%JeE+gxu*av7h<>lbd?#^Kp3I+G!=;YL$;SW_
zQ>|VzrW=WO><9+2M!aI;^eJS{cb3LoRs_HO4^0>V{
z;S#Id`${-Qq)-Qu#qjX3im5M9Dep)mJ_t+Uic{hv<>mHs$G`NXRdDn2e!_6uo#6s?_Zk6g;yN+xPK<^X0wAszPmtOx*^|3T0-(7b?ECx7SFoP0iKyPfUUt25i_K
z-xJ&q^~(>9;q7%eySTWxw6v7;)fw-7>$&+)8Bp9fuXQ?$EnBwy5{&syX0f)W^mfH=
zgvni~FhLQBhzbu6Pa;`ZT5_PimQ@gStr`venoJYMU@(TMkC3c>Pp8wBuU<8JzUDAu
zV%+onkV4p=kBp40?&`YYu0LgCW3z2Nmye?791svt-Q0XCU6+|Gq^_RKm_CSEygT+5
zt6X*XXNQ!8{ffSJQv$dsT57y-p}A`7<=`8ulD*mz@4oc}l|`Pk?_+cH*DqW!M<~^0
zWo1f`EpBZ+i;^3+$;e`JL+9(Px;JkQ)bIToXHHj1s5s?l1L8|~KwzMG+q@tu6{R$%
z!C!fZVDpG}dQ@EWOYfxAD-I?4eQABM371HjSeyab%nz1eOH}gq==)nLL@Rj;Ek`W&
zLO}YC>$Fb^7mRO*G$J>f3KTYp8;WXlIuTws4qP9ox(;SWxcNST9M&Ktg8&soHbbC+
zs_R=6flGLLRi!d_v9$Re+CaE6)YlZvo2>KqrFHh>#f{wp2H=BL2{h4Xi}
zB@9m}Y|b#Oc!!#3h=!A4Sx}8Nspa7AqTgEM@Ijc;s@%mfF)=8m&!oKxnChzICRFz+
z1IlVaOEfC1DN>M?PvSV-eKzay;n{oF1^WsHFuG*fS>o~c-@$oq-lLgL=
ztEN(^)sqK<;fI=dPF!_XRu=Aq&R{gcv&7^%fofu6qI~)Cp4){SE|;sEgHtVV^^&En
z^MXP`e#D5GliS?vKR2PFv8f52N4~J;N{&%^-5ptC_+fZ_RDb|<-r(_E?ndld`x&yj
zT=(kL%~@j{cys*Vp3<5`
zhz4QVPFZzoUQ|?+@w=Trhz>bz-uxXd*z4AhpO6UdFyt3rFo
zX)4FnnAt^W%E|xFW#J>OZn`;u
n#rgh9t{}+-3dc0BRHA3JbMxZ&x1Ikp<`8&!`g+{l7Mk`Ssk^;}
literal 0
HcmV?d00001
diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest
new file mode 100644
index 0000000..062a079
--- /dev/null
+++ b/public/manifest.webmanifest
@@ -0,0 +1,45 @@
+{
+ "name": "Bricktrack",
+ "short_name": "Bricktrack",
+ "description": "Sammlung und Wunschlisten für Spielzeug-Sets",
+ "lang": "de",
+ "dir": "ltr",
+ "start_url": "/",
+ "scope": "/",
+ "display": "standalone",
+ "orientation": "portrait",
+ "background_color": "#ffffff",
+ "theme_color": "#0a0a0a",
+ "icons": [
+ {
+ "src": "/icons/icon-192.png",
+ "sizes": "192x192",
+ "type": "image/png",
+ "purpose": "any"
+ },
+ {
+ "src": "/icons/icon-512.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "any"
+ },
+ {
+ "src": "/icons/icon-maskable-512.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "maskable"
+ }
+ ],
+ "shortcuts": [
+ {
+ "name": "Fehlende Sets",
+ "short_name": "Fehlt noch",
+ "url": "/katalog?besitz=fehlt"
+ },
+ {
+ "name": "Sammlung",
+ "short_name": "Sammlung",
+ "url": "/sammlung"
+ }
+ ]
+}
diff --git a/public/sw.js b/public/sw.js
new file mode 100644
index 0000000..b38cb19
--- /dev/null
+++ b/public/sw.js
@@ -0,0 +1,122 @@
+/**
+ * Bricktrack service worker.
+ *
+ * Goal: the catalog stays readable in the cellar, in the shop, and wherever
+ * else the phone loses its connection. Anything that changes data still needs
+ * the network - a wish reserved offline would be a lie.
+ *
+ * Caches are versioned; bumping VERSION drops the old ones on activation.
+ */
+const VERSION = 'v1';
+const PAGE_CACHE = `bricktrack-pages-${VERSION}`;
+const ASSET_CACHE = `bricktrack-assets-${VERSION}`;
+const IMAGE_CACHE = `bricktrack-images-${VERSION}`;
+const OFFLINE_URL = '/offline';
+
+self.addEventListener('install', (event) => {
+ event.waitUntil(
+ caches
+ .open(PAGE_CACHE)
+ .then((cache) => cache.addAll([OFFLINE_URL, '/manifest.webmanifest']))
+ .then(() => self.skipWaiting()),
+ );
+});
+
+self.addEventListener('activate', (event) => {
+ const keep = new Set([PAGE_CACHE, ASSET_CACHE, IMAGE_CACHE]);
+
+ event.waitUntil(
+ caches
+ .keys()
+ .then((keys) => Promise.all(keys.filter((key) => !keep.has(key)).map((key) => caches.delete(key))))
+ .then(() => self.clients.claim()),
+ );
+});
+
+/**
+ * Signing out must not leave readable pages behind on a shared device, so the
+ * app tells the worker to drop everything it holds.
+ */
+self.addEventListener('message', (event) => {
+ if (event.data === 'bricktrack:clear-cache') {
+ event.waitUntil(caches.keys().then((keys) => Promise.all(keys.map((key) => caches.delete(key)))));
+ }
+});
+
+/** Long-lived, content-addressed build output. */
+function isStaticAsset(url) {
+ return url.pathname.startsWith('/_next/static/') || url.pathname.startsWith('/icons/');
+}
+
+/** Set pictures; their file names contain a UUID and never change. */
+function isCacheableImage(url) {
+ return url.pathname.startsWith('/api/files/images/');
+}
+
+/** Everything that must never be answered from a cache. */
+function isNeverCached(url) {
+ return (
+ url.pathname.startsWith('/api/auth/') ||
+ url.pathname.startsWith('/api/export/') ||
+ url.pathname.startsWith('/api/health')
+ );
+}
+
+async function cacheFirst(request, cacheName) {
+ const cache = await caches.open(cacheName);
+ const hit = await cache.match(request);
+ if (hit) return hit;
+
+ const response = await fetch(request);
+ if (response.ok) cache.put(request, response.clone());
+ return response;
+}
+
+/**
+ * Network first, cache as a fallback: the catalog is always current when there
+ * is a connection, and still readable when there is not.
+ */
+async function networkFirst(request, cacheName) {
+ const cache = await caches.open(cacheName);
+
+ try {
+ const response = await fetch(request);
+ if (response.ok) cache.put(request, response.clone());
+ return response;
+ } catch (error) {
+ const hit = await cache.match(request);
+ if (hit) return hit;
+
+ if (request.mode === 'navigate') {
+ const offline = await cache.match(OFFLINE_URL);
+ if (offline) return offline;
+ }
+ throw error;
+ }
+}
+
+self.addEventListener('fetch', (event) => {
+ const { request } = event;
+
+ // Only plain GETs are safe to serve from a cache.
+ if (request.method !== 'GET') return;
+
+ const url = new URL(request.url);
+ if (url.origin !== self.location.origin) return;
+ if (isNeverCached(url)) return;
+
+ if (isStaticAsset(url)) {
+ event.respondWith(cacheFirst(request, ASSET_CACHE));
+ return;
+ }
+
+ if (isCacheableImage(url)) {
+ event.respondWith(cacheFirst(request, IMAGE_CACHE));
+ return;
+ }
+
+ // Page loads and the RSC payloads behind client-side navigation.
+ if (request.mode === 'navigate' || url.searchParams.has('_rsc')) {
+ event.respondWith(networkFirst(request, PAGE_CACHE));
+ }
+});
diff --git a/scripts/generate-icons.mjs b/scripts/generate-icons.mjs
new file mode 100644
index 0000000..3846f19
--- /dev/null
+++ b/scripts/generate-icons.mjs
@@ -0,0 +1,67 @@
+/**
+ * Renders the app icons from one SVG source.
+ *
+ * Run with `node scripts/generate-icons.mjs` after changing the mark; the
+ * results are committed so neither the build nor the container needs to draw
+ * anything at runtime.
+ */
+import fs from 'node:fs';
+import path from 'node:path';
+import sharp from 'sharp';
+
+const OUT_DIR = path.resolve(process.cwd(), 'public/icons');
+
+// Four studs on a rounded plate - readable down to 32px, which a detailed
+// brick drawing would not be.
+const mark = (background, foreground) => `
+
+
+
+
+
+
+
+
+ `;
+
+/**
+ * A maskable icon has to survive being cropped to a circle, so the mark sits
+ * on a full-bleed background with generous padding.
+ */
+const maskable = (background, foreground) => `
+
+
+
+
+
+
+
+
+ `;
+
+const DARK = '#0a0a0a';
+const LIGHT = '#fafafa';
+
+const targets = [
+ { file: 'icon-192.png', size: 192, svg: mark(DARK, LIGHT) },
+ { file: 'icon-512.png', size: 512, svg: mark(DARK, LIGHT) },
+ { file: 'icon-maskable-512.png', size: 512, svg: maskable(DARK, LIGHT) },
+ { file: 'apple-touch-icon.png', size: 180, svg: mark(DARK, LIGHT) },
+];
+
+fs.mkdirSync(OUT_DIR, { recursive: true });
+
+for (const target of targets) {
+ await sharp(Buffer.from(target.svg))
+ .resize(target.size, target.size)
+ .png({ compressionLevel: 9 })
+ .toFile(path.join(OUT_DIR, target.file));
+ console.log(`[icons] ${target.file} (${target.size}px)`);
+}
+
+// The favicon keeps the same mark so the browser tab matches the home screen.
+await sharp(Buffer.from(mark(DARK, LIGHT)))
+ .resize(32, 32)
+ .png({ compressionLevel: 9 })
+ .toFile(path.resolve(process.cwd(), 'public/favicon.png'));
+console.log('[icons] favicon.png (32px)');
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 2e04487..e8c1ba4 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -1,4 +1,5 @@
import type { Metadata, Viewport } from 'next';
+import { ServiceWorkerRegistration } from '@/components/layout/service-worker';
import { Toaster } from '@/components/ui/sonner';
import './globals.css';
@@ -9,6 +10,16 @@ export const metadata: Metadata = {
},
description: 'Sammlung und Wunschlisten für Spielzeug-Sets',
applicationName: 'Bricktrack',
+ manifest: '/manifest.webmanifest',
+ icons: {
+ icon: [{ url: '/favicon.png', type: 'image/png', sizes: '32x32' }],
+ apple: [{ url: '/icons/apple-touch-icon.png', sizes: '180x180' }],
+ },
+ appleWebApp: {
+ capable: true,
+ title: 'Bricktrack',
+ statusBarStyle: 'default',
+ },
// A private, self-hosted instance never belongs in a search index - and the
// public wishlist links are meant to be shared, not found.
robots: { index: false, follow: false },
@@ -30,6 +41,7 @@ export default function RootLayout({ children }: Readonly<{ children: React.Reac
{children}
+