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.'}
-
+
+
+
+
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 (
+ <>
+
+
+
+ >
+ );
+}
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');
+ });
+});