Initial: Leckerbuch PWA v0.1.0 — Lebensmittel-Favoriten mit Laktose-Tracker

- Svelte 5 + SvelteKit + Tailwind 4 (VDE Katalog Template-Stil, AWL Dark Theme)
- Fastify + Drizzle + MariaDB Backend (DB: leckerbuch auf 192.168.155.11)
- Dual-Barcode-Scanner (ZXing + zbar-wasm parallel, aus HandyBarcodeScanner portiert)
- Open Food Facts API-Proxy mit automatischer Laktose-Erkennung (Allergen-Flags + Schätztabelle)
- Lactase-Dosisrechner (konfigurierbarer FCC/g-Faktor)
- Produkt-CRUD, Laden-Verwaltung, Tags, Einstellungen
- Docker-Setup (Dockerfile + docker-compose.yml)
- Mobile-first PWA mit Bottom-Navigation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Eduard Wisch 2026-04-12 09:11:11 +02:00
parent 1af9c0aea4
commit 24e05680f9
33 changed files with 6808 additions and 0 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
node_modules/\ndist/\nbuild/\n.svelte-kit/\n*.log
node_modules/
.svelte-kit/

27
Dockerfile Normal file
View file

@ -0,0 +1,27 @@
FROM node:22-alpine AS web-build
WORKDIR /build/web
COPY web/package.json web/package-lock.json* ./
RUN npm install
COPY web/ .
RUN npm run build
FROM node:22-alpine AS api-build
WORKDIR /build/api
COPY api/package.json api/package-lock.json* ./
RUN npm install
COPY api/ .
RUN npm run build
FROM node:22-alpine
WORKDIR /app
COPY --from=api-build /build/api/package.json /build/api/package-lock.json* ./
RUN npm install --omit=dev
COPY --from=api-build /build/api/dist ./dist
COPY --from=web-build /build/web/build ./public
ENV NODE_ENV=production
ENV PORT=3300
EXPOSE 3300
CMD ["node", "dist/index.js"]

14
api/drizzle.config.ts Normal file
View file

@ -0,0 +1,14 @@
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './src/db/schema.ts',
out: './drizzle',
dialect: 'mysql',
dbCredentials: {
host: process.env.DB_HOST || '192.168.155.11',
port: Number(process.env.DB_PORT || 3306),
user: process.env.DB_USER || 'claude',
password: process.env.DB_PASS || '8715',
database: process.env.DB_NAME || 'leckerbuch',
},
});

2722
api/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

27
api/package.json Normal file
View file

@ -0,0 +1,27 @@
{
"name": "leckerbuch-api",
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push"
},
"dependencies": {
"@fastify/cors": "^11.2.0",
"@fastify/static": "^9.1.0",
"drizzle-orm": "^0.45.1",
"fastify": "^5.8.2",
"mysql2": "^3.20.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/node": "^22.15.3",
"drizzle-kit": "^0.31.2",
"tsx": "^4.19.4",
"typescript": "^5.9.3"
}
}

16
api/src/config.ts Normal file
View file

@ -0,0 +1,16 @@
import { drizzle } from 'drizzle-orm/mysql2';
import mysql from 'mysql2/promise';
import * as schema from './db/schema.js';
const pool = mysql.createPool({
host: process.env.DB_HOST || '192.168.155.11',
port: Number(process.env.DB_PORT || 3306),
user: process.env.DB_USER || 'claude',
password: process.env.DB_PASS || '8715',
database: process.env.DB_NAME || 'leckerbuch',
waitForConnections: true,
connectionLimit: 5,
});
export const db = drizzle(pool, { schema, mode: 'default' });
export const PORT = Number(process.env.PORT || 3300);

55
api/src/db/schema.ts Normal file
View file

@ -0,0 +1,55 @@
import { mysqlTable, int, varchar, text, decimal, boolean, timestamp, json } from 'drizzle-orm/mysql-core';
export const products = mysqlTable('products', {
id: int('id').primaryKey().autoincrement(),
barcode: varchar('barcode', { length: 64 }).notNull(),
name: varchar('name', { length: 255 }).notNull(),
brand: varchar('brand', { length: 255 }).default(''),
image_url: text('image_url'),
nutri_score: varchar('nutri_score', { length: 1 }).default(''),
ingredients: text('ingredients'),
categories: varchar('categories', { length: 500 }).default(''),
allergens: varchar('allergens', { length: 500 }).default(''),
has_milk: boolean('has_milk').default(false),
is_lactose_free: boolean('is_lactose_free').default(false),
lactose_per_100g: decimal('lactose_per_100g', { precision: 5, scale: 2 }),
lactose_source: varchar('lactose_source', { length: 20 }),
notes: text('notes'),
rating: int('rating').default(0),
off_cache: json('off_cache'),
created_at: timestamp('created_at').defaultNow(),
updated_at: timestamp('updated_at').defaultNow().onUpdateNow(),
});
export const stores = mysqlTable('stores', {
id: int('id').primaryKey().autoincrement(),
name: varchar('name', { length: 255 }).notNull(),
location: varchar('location', { length: 255 }).default(''),
created_at: timestamp('created_at').defaultNow(),
});
export const productStores = mysqlTable('product_stores', {
id: int('id').primaryKey().autoincrement(),
product_id: int('product_id').notNull(),
store_id: int('store_id').notNull(),
price: decimal('price', { precision: 8, scale: 2 }),
last_seen: timestamp('last_seen').defaultNow(),
});
export const tags = mysqlTable('tags', {
id: int('id').primaryKey().autoincrement(),
name: varchar('name', { length: 100 }).notNull(),
color: varchar('color', { length: 7 }).default('#4390dc'),
});
export const productTags = mysqlTable('product_tags', {
id: int('id').primaryKey().autoincrement(),
product_id: int('product_id').notNull(),
tag_id: int('tag_id').notNull(),
});
export const settings = mysqlTable('settings', {
id: int('id').primaryKey().autoincrement(),
key: varchar('key', { length: 100 }).notNull(),
value: varchar('value', { length: 500 }).notNull(),
});

View file

@ -0,0 +1,38 @@
import type { FastifyPluginAsync } from 'fastify';
import { db } from '../config.js';
import { settings } from '../db/schema.js';
import { eq } from 'drizzle-orm';
const DEFAULTS: Record<string, string> = {
lactase_factor: '1000',
default_portion_g: '100',
};
async function getSetting(key: string): Promise<string> {
const [row] = await db.select().from(settings).where(eq(settings.key, key));
return row?.value ?? DEFAULTS[key] ?? '';
}
async function setSetting(key: string, value: string) {
const [existing] = await db.select().from(settings).where(eq(settings.key, key));
if (existing) {
await db.update(settings).set({ value }).where(eq(settings.key, key));
} else {
await db.insert(settings).values({ key, value });
}
}
export const settingsRoutes: FastifyPluginAsync = async (app) => {
app.get('/', async () => ({
lactase_factor: Number(await getSetting('lactase_factor')),
default_portion_g: Number(await getSetting('default_portion_g')),
}));
app.put('/', async (req) => {
const body = req.body as Record<string, any>;
for (const [key, value] of Object.entries(body)) {
if (key in DEFAULTS) await setSetting(key, String(value));
}
return { ok: true };
});
};

47
api/src/index.ts Normal file
View file

@ -0,0 +1,47 @@
import { existsSync } from 'fs';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
import Fastify from 'fastify';
import cors from '@fastify/cors';
import { PORT } from './config.js';
import { produkteRoutes } from './produkte/routes.js';
import { ladenRoutes } from './laden/routes.js';
import { offRoutes } from './openfoodfacts/routes.js';
import { tagsRoutes } from './produkte/tags.js';
import { settingsRoutes } from './einstellungen/routes.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const app = Fastify({ logger: true });
await app.register(cors, { origin: true });
await app.register(produkteRoutes, { prefix: '/api/produkte' });
await app.register(ladenRoutes, { prefix: '/api/laden' });
await app.register(offRoutes, { prefix: '/api/off' });
await app.register(tagsRoutes, { prefix: '/api/tags' });
await app.register(settingsRoutes, { prefix: '/api/einstellungen' });
app.get('/api/health', async () => ({ status: 'ok', version: '0.1.0' }));
const publicDir = resolve(__dirname, '..', 'public');
if (existsSync(publicDir)) {
const fastifyStatic = await import('@fastify/static');
await app.register(fastifyStatic.default, {
root: publicDir,
wildcard: false,
});
app.setNotFoundHandler(async (req, reply) => {
if (req.url.startsWith('/api/')) {
reply.status(404).send({ error: 'Not found' });
} else {
return reply.sendFile('index.html');
}
});
}
try {
await app.listen({ port: PORT, host: '0.0.0.0' });
console.log(`Leckerbuch API läuft auf :${PORT}`);
} catch (err) {
app.log.error(err);
process.exit(1);
}

26
api/src/laden/routes.ts Normal file
View file

@ -0,0 +1,26 @@
import type { FastifyPluginAsync } from 'fastify';
import { db } from '../config.js';
import { stores, productStores } from '../db/schema.js';
import { eq, sql } from 'drizzle-orm';
export const ladenRoutes: FastifyPluginAsync = async (app) => {
app.get('/', async () => {
const rows = await db.select({
id: stores.id,
name: stores.name,
location: stores.location,
created_at: stores.created_at,
product_count: sql<number>`(SELECT COUNT(*) FROM product_stores WHERE store_id = ${stores.id})`,
}).from(stores);
return rows;
});
app.post('/', async (req) => {
const body = req.body as { name: string; location?: string };
const [result] = await db.insert(stores).values({
name: body.name,
location: body.location || '',
}).$returningId();
return { id: result.id };
});
};

View file

@ -0,0 +1,126 @@
import type { FastifyPluginAsync } from 'fastify';
const LACTOSE_ESTIMATES: Record<string, number> = {
'milch': 4.8,
'vollmilch': 4.8,
'fettarme milch': 4.9,
'h-milch': 4.8,
'buttermilch': 3.5,
'joghurt': 4.0,
'naturjoghurt': 4.0,
'fruchtjoghurt': 3.5,
'skyr': 3.5,
'quark': 3.0,
'magerquark': 3.5,
'sahne': 3.2,
'schlagsahne': 3.2,
'schmand': 3.0,
'creme fraiche': 2.5,
'frischkäse': 2.5,
'mascarpone': 2.5,
'ricotta': 3.5,
'mozzarella': 1.5,
'feta': 1.0,
'camembert': 0.5,
'brie': 0.5,
'gouda': 0.1,
'emmentaler': 0.1,
'cheddar': 0.1,
'parmesan': 0.0,
'gruyère': 0.0,
'butter': 0.1,
'eiscreme': 4.0,
'eis': 3.5,
'milchschokolade': 2.5,
'pudding': 3.5,
'milchreis': 3.5,
'kakao': 4.0,
};
function detectLactose(offProduct: any): {
has_milk: boolean;
is_lactose_free: boolean;
lactose_per_100g: number | null;
lactose_source: 'off' | 'estimate' | null;
} {
const allergens: string[] = offProduct.allergens_tags || [];
const labels: string[] = offProduct.labels_tags || [];
const categories: string = (offProduct.categories || '').toLowerCase();
const productName: string = (offProduct.product_name || '').toLowerCase();
const hasMilk = allergens.some((a: string) =>
a.includes('milk') || a.includes('milch') || a.includes('lait') || a.includes('dairy')
);
const isLactoseFree = labels.some((l: string) =>
l.includes('no-lactose') || l.includes('lactose-free') || l.includes('laktosefrei') || l.includes('sans-lactose')
);
if (isLactoseFree) {
return { has_milk: hasMilk, is_lactose_free: true, lactose_per_100g: 0, lactose_source: 'off' };
}
if (!hasMilk) {
return { has_milk: false, is_lactose_free: false, lactose_per_100g: null, lactose_source: null };
}
const searchStr = `${productName} ${categories}`;
let bestMatch: { key: string; value: number } | null = null;
for (const [key, value] of Object.entries(LACTOSE_ESTIMATES)) {
if (searchStr.includes(key)) {
if (!bestMatch || key.length > bestMatch.key.length) {
bestMatch = { key, value };
}
}
}
if (bestMatch) {
return { has_milk: true, is_lactose_free: false, lactose_per_100g: bestMatch.value, lactose_source: 'estimate' };
}
return { has_milk: true, is_lactose_free: false, lactose_per_100g: null, lactose_source: null };
}
export const offRoutes: FastifyPluginAsync = async (app) => {
app.get('/:barcode', async (req) => {
const { barcode } = req.params as { barcode: string };
const res = await fetch(`https://world.openfoodfacts.org/api/v2/product/${barcode}.json`, {
headers: { 'User-Agent': 'Leckerbuch/0.1 (PWA; contact: data@data-it-solution.de)' },
});
if (!res.ok) {
return { found: false, barcode, product: null };
}
const data = await res.json();
if (data.status !== 1 || !data.product) {
return { found: false, barcode, product: null };
}
const p = data.product;
const lactose = detectLactose(p);
return {
found: true,
barcode,
product: {
name: p.product_name_de || p.product_name || '',
brand: p.brands || '',
image_url: p.image_front_small_url || p.image_url || '',
nutri_score: p.nutriscore_grade || '',
ingredients: p.ingredients_text_de || p.ingredients_text || '',
categories: p.categories || '',
allergens: (p.allergens_tags || []).join(', '),
...lactose,
},
raw: {
nutri_score_data: p.nutriscore_data,
nutriments: p.nutriments,
labels_tags: p.labels_tags,
allergens_tags: p.allergens_tags,
},
};
});
};

125
api/src/produkte/routes.ts Normal file
View file

@ -0,0 +1,125 @@
import type { FastifyPluginAsync } from 'fastify';
import { db } from '../config.js';
import { products, productStores, productTags, tags, stores } from '../db/schema.js';
import { eq, like, or, desc } from 'drizzle-orm';
export const produkteRoutes: FastifyPluginAsync = async (app) => {
app.get('/', async (req) => {
const { q } = req.query as { q?: string };
const where = q
? or(like(products.name, `%${q}%`), like(products.brand, `%${q}%`), like(products.barcode, `%${q}%`))
: undefined;
const rows = await db.select().from(products).where(where).orderBy(desc(products.updated_at)).limit(200);
const result = await Promise.all(rows.map(async (p) => {
const ps = await db.select({
id: productStores.id, product_id: productStores.product_id,
store_id: productStores.store_id, price: productStores.price,
last_seen: productStores.last_seen,
store_name: stores.name, store_location: stores.location,
}).from(productStores)
.innerJoin(stores, eq(productStores.store_id, stores.id))
.where(eq(productStores.product_id, p.id));
const pt = await db.select({ id: tags.id, name: tags.name, color: tags.color })
.from(productTags)
.innerJoin(tags, eq(productTags.tag_id, tags.id))
.where(eq(productTags.product_id, p.id));
return { ...p, stores: ps, tags: pt };
}));
return result;
});
app.get('/:id', async (req) => {
const { id } = req.params as { id: string };
const [product] = await db.select().from(products).where(eq(products.id, Number(id)));
if (!product) throw { statusCode: 404, message: 'Nicht gefunden' };
const ps = await db.select({
id: productStores.id, product_id: productStores.product_id,
store_id: productStores.store_id, price: productStores.price,
last_seen: productStores.last_seen,
store_name: stores.name, store_location: stores.location,
}).from(productStores)
.innerJoin(stores, eq(productStores.store_id, stores.id))
.where(eq(productStores.product_id, product.id));
const pt = await db.select({ id: tags.id, name: tags.name, color: tags.color })
.from(productTags)
.innerJoin(tags, eq(productTags.tag_id, tags.id))
.where(eq(productTags.product_id, product.id));
return { ...product, stores: ps, tags: pt };
});
app.post('/', async (req) => {
const body = req.body as any;
const [result] = await db.insert(products).values({
barcode: body.barcode,
name: body.name,
brand: body.brand || '',
image_url: body.image_url || null,
nutri_score: body.nutri_score || '',
ingredients: body.ingredients || null,
categories: body.categories || '',
allergens: body.allergens || '',
has_milk: body.has_milk ?? false,
is_lactose_free: body.is_lactose_free ?? false,
lactose_per_100g: body.lactose_per_100g ?? null,
lactose_source: body.lactose_source ?? null,
notes: body.notes || null,
rating: body.rating ?? 0,
}).$returningId();
const productId = result.id;
if (body.store?.name) {
let [store] = await db.select().from(stores).where(eq(stores.name, body.store.name));
if (!store) {
const [s] = await db.insert(stores).values({
name: body.store.name,
location: body.store.location || '',
}).$returningId();
[store] = await db.select().from(stores).where(eq(stores.id, s.id));
}
await db.insert(productStores).values({
product_id: productId,
store_id: store.id,
price: body.store.price ?? null,
});
}
if (body.tags?.length) {
for (const tagName of body.tags) {
let [tag] = await db.select().from(tags).where(eq(tags.name, tagName));
if (!tag) {
const [t] = await db.insert(tags).values({ name: tagName }).$returningId();
[tag] = await db.select().from(tags).where(eq(tags.id, t.id));
}
await db.insert(productTags).values({ product_id: productId, tag_id: tag.id });
}
}
return { id: productId };
});
app.put('/:id', async (req) => {
const { id } = req.params as { id: string };
const body = req.body as any;
await db.update(products).set(body).where(eq(products.id, Number(id)));
return { ok: true };
});
app.delete('/:id', async (req) => {
const { id } = req.params as { id: string };
const pid = Number(id);
await db.delete(productTags).where(eq(productTags.product_id, pid));
await db.delete(productStores).where(eq(productStores.product_id, pid));
await db.delete(products).where(eq(products.id, pid));
return { ok: true };
});
};

18
api/src/produkte/tags.ts Normal file
View file

@ -0,0 +1,18 @@
import type { FastifyPluginAsync } from 'fastify';
import { db } from '../config.js';
import { tags } from '../db/schema.js';
export const tagsRoutes: FastifyPluginAsync = async (app) => {
app.get('/', async () => {
return db.select().from(tags);
});
app.post('/', async (req) => {
const body = req.body as { name: string; color?: string };
const [result] = await db.insert(tags).values({
name: body.name,
color: body.color || '#4390dc',
}).$returningId();
return { id: result.id };
});
};

14
api/tsconfig.json Normal file
View file

@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"esModuleInterop": true,
"strict": true,
"outDir": "dist",
"rootDir": "src",
"skipLibCheck": true,
"declaration": true
},
"include": ["src"]
}

15
docker-compose.yml Normal file
View file

@ -0,0 +1,15 @@
services:
leckerbuch:
build: .
container_name: leckerbuch
restart: unless-stopped
ports:
- "3300:3300"
environment:
DB_HOST: 192.168.155.11
DB_PORT: 3306
DB_USER: claude
DB_PASS: 8715
DB_NAME: leckerbuch
PORT: 3300
NODE_ENV: production

2258
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

30
web/package.json Normal file
View file

@ -0,0 +1,30 @@
{
"name": "leckerbuch-web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite dev --host 0.0.0.0",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.50.2",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/vite": "^4.2.2",
"svelte": "^5.51.0",
"svelte-check": "^4.4.2",
"tailwindcss": "^4.2.2",
"typescript": "^5.9.3",
"vite": "^7.3.1"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.52.0",
"@undecaf/zbar-wasm": "^0.11.0",
"@zxing/library": "^0.21.3",
"lucide-svelte": "^0.577.0"
}
}

152
web/src/app.css Normal file
View file

@ -0,0 +1,152 @@
@import 'tailwindcss';
:root {
--bg-primary: #1d1e20;
--bg-secondary: #2b2c2e;
--bg-panel: #3b3c3e;
--bg-card: #38393d;
--bg-input: #464646;
--bg-hover: #2b2d2f;
--bg-menu: #3d3e40;
--text-primary: #dcdcdc;
--text-secondary: #aaaaaa;
--text-muted: #777777;
--accent: #4390dc;
--accent-hover: #5ba3e8;
--accent-light: rgba(67, 144, 220, 0.15);
--border: #555555;
--border-light: #666666;
--success: #65b84d;
--warning: #f2b81e;
--danger: #fc545b;
--info: #4390dc;
--radius: 6px;
--radius-lg: 10px;
--lactose-warn: #e8a045;
--lactose-free: #65b84d;
--lactose-high: #fc545b;
}
body {
background: var(--bg-primary);
color: var(--text-primary);
font-family: 'Inter', system-ui, -apple-system, sans-serif;
font-size: 0.92em;
line-height: 1.4;
margin: 0;
-webkit-tap-highlight-color: transparent;
overscroll-behavior: none;
}
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: var(--bg-secondary); }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: var(--accent); }
.panel {
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 16px;
}
.panel-header {
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-secondary);
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border);
}
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 12px 16px;
transition: border-color 0.15s, box-shadow 0.15s;
}
.card:hover, .card:active {
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-light);
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
height: 36px;
padding: 0 14px;
border: none;
border-radius: var(--radius);
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s, opacity 0.15s;
color: white;
}
.btn-primary { background: var(--accent); }
.btn-primary:hover { background: var(--accent-hover); }
.btn-success { background: var(--success); }
.btn-danger { background: var(--danger); }
.btn-outline {
background: transparent;
border: 1px solid var(--border);
color: var(--text-primary);
}
.btn-outline:hover { border-color: var(--accent); color: var(--accent); }
.btn-lg { height: 44px; padding: 0 20px; font-size: 0.95rem; border-radius: var(--radius-lg); }
.btn-block { width: 100%; }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
input, select, textarea {
background: var(--bg-input);
color: var(--text-primary);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 8px 12px;
font-size: 0.9rem;
width: 100%;
outline: none;
transition: border-color 0.15s;
}
input:focus, select:focus, textarea:focus {
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-light);
}
label {
display: block;
font-size: 0.8rem;
font-weight: 500;
color: var(--text-secondary);
margin-bottom: 4px;
}
.badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 500;
}
.badge-accent { background: var(--accent-light); color: var(--accent); }
.badge-success { background: rgba(101, 184, 77, 0.15); color: var(--success); }
.badge-warning { background: rgba(242, 184, 30, 0.15); color: var(--warning); }
.badge-danger { background: rgba(252, 84, 91, 0.15); color: var(--danger); }
.lactose-free { color: var(--lactose-free); }
.lactose-warn { color: var(--lactose-warn); }
.lactose-high { color: var(--lactose-high); }
@media (min-width: 768px) {
.mobile-only { display: none !important; }
}
@media (max-width: 767px) {
.desktop-only { display: none !important; }
}

20
web/src/app.html Normal file
View file

@ -0,0 +1,20 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
<meta name="theme-color" content="#1d1e20" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<link rel="manifest" href="%sveltekit.assets%/manifest.json" />
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<title>Leckerbuch</title>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

38
web/src/lib/api.ts Normal file
View file

@ -0,0 +1,38 @@
const BASE = '/api';
async function request<T>(path: string, opts: RequestInit = {}): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
...opts,
headers: { 'Content-Type': 'application/json', ...opts.headers },
});
if (!res.ok) {
const err = await res.text();
throw new Error(`API ${res.status}: ${err}`);
}
return res.json();
}
export const api = {
products: {
list: (q?: string) => request<any[]>(`/produkte${q ? `?q=${encodeURIComponent(q)}` : ''}`),
get: (id: number) => request<any>(`/produkte/${id}`),
create: (data: any) => request<any>('/produkte', { method: 'POST', body: JSON.stringify(data) }),
update: (id: number, data: any) => request<any>(`/produkte/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
delete: (id: number) => request<void>(`/produkte/${id}`, { method: 'DELETE' }),
},
stores: {
list: () => request<any[]>('/laden'),
create: (data: any) => request<any>('/laden', { method: 'POST', body: JSON.stringify(data) }),
},
tags: {
list: () => request<any[]>('/tags'),
create: (data: any) => request<any>('/tags', { method: 'POST', body: JSON.stringify(data) }),
},
off: {
lookup: (barcode: string) => request<any>(`/off/${barcode}`),
},
settings: {
get: () => request<any>('/einstellungen'),
update: (data: any) => request<any>('/einstellungen', { method: 'PUT', body: JSON.stringify(data) }),
},
};

View file

@ -0,0 +1,208 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { ScanLine, X, Flashlight } from 'lucide-svelte';
let { onDetected }: { onDetected: (code: string, format: string) => void } = $props();
let videoEl: HTMLVideoElement;
let canvasEl: HTMLCanvasElement;
let stream: MediaStream | null = null;
let scanning = $state(false);
let error = $state('');
let pendingCode: string | null = null;
let pendingCount = 0;
const REQUIRED_CONFIRMATIONS = 2;
let lastAccepted = '';
let lastAcceptedTime = 0;
let zxingReader: any = null;
let zbarReady = false;
let preprocBusy = false;
let preprocTimer: ReturnType<typeof setInterval> | null = null;
function validateEAN13(code: string): boolean {
if (!/^\d{13}$/.test(code)) return false;
let sum = 0;
for (let i = 0; i < 12; i++) {
sum += parseInt(code[i]) * (i % 2 === 0 ? 1 : 3);
}
return (10 - (sum % 10)) % 10 === parseInt(code[12]);
}
function handleDetection(code: string, format: string) {
const now = Date.now();
if (code === lastAccepted && now - lastAcceptedTime < 3000) return;
let trusted = false;
if (format.includes('EAN') && format.includes('13')) {
if (validateEAN13(code)) trusted = true;
else return;
}
if (code === pendingCode) {
pendingCount++;
} else {
pendingCode = code;
pendingCount = 1;
}
if (!trusted && pendingCount < REQUIRED_CONFIRMATIONS) return;
lastAccepted = code;
lastAcceptedTime = now;
pendingCode = null;
pendingCount = 0;
if (navigator.vibrate) navigator.vibrate(100);
onDetected(code, format);
}
async function initZxing() {
try {
const { BrowserMultiFormatReader } = await import('@zxing/library');
zxingReader = new BrowserMultiFormatReader();
zxingReader.decodeFromVideoElement(videoEl, (result: any, err: any) => {
if (result) {
handleDetection(result.getText(), result.getBarcodeFormat()?.toString() || 'UNKNOWN');
}
});
} catch (e) {
console.warn('ZXing init failed:', e);
}
}
async function initZbar() {
try {
const zbarWasm = await import('@undecaf/zbar-wasm');
zbarReady = true;
const canvas = canvasEl;
preprocTimer = setInterval(() => {
if (preprocBusy || !scanning || !videoEl || !videoEl.videoWidth || videoEl.readyState < 2) return;
preprocBusy = true;
const scale = videoEl.videoWidth > 1280 ? 0.5 : 0.75;
const w = Math.round(videoEl.videoWidth * scale);
const h = Math.round(videoEl.videoHeight * scale);
if (canvas.width !== w) canvas.width = w;
if (canvas.height !== h) canvas.height = h;
const ctx = canvas.getContext('2d', { willReadFrequently: true })!;
ctx.drawImage(videoEl, 0, 0, w, h);
const imageData = ctx.getImageData(0, 0, w, h);
zbarWasm.scanImageData(imageData).then((symbols: any[]) => {
if (symbols?.length > 0) {
const sym = symbols[0];
const code = sym.decode();
const typeMap: Record<string, string> = {
'ZBAR_CODE128': 'CODE_128', 'ZBAR_EAN13': 'EAN_13',
'ZBAR_EAN8': 'EAN_8', 'ZBAR_UPCA': 'UPC_A',
'ZBAR_UPCE': 'UPC_E', 'ZBAR_QRCODE': 'QR_CODE',
'ZBAR_CODE39': 'CODE_39',
};
handleDetection(code, typeMap[sym.typeName] || sym.typeName);
}
}).finally(() => { preprocBusy = false; });
}, 220);
} catch (e) {
console.warn('zbar-wasm init failed:', e);
}
}
async function startScanning() {
error = '';
try {
stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: 'environment',
width: { min: 1280, ideal: 1920 },
height: { min: 720, ideal: 1080 },
// @ts-ignore
aspectRatio: { ideal: 1.7778 },
},
audio: false,
});
videoEl.srcObject = stream;
await videoEl.play();
scanning = true;
initZxing();
initZbar();
} catch (e: any) {
error = `Kamera-Zugriff fehlgeschlagen: ${e.message}`;
}
}
function stopScanning() {
scanning = false;
if (zxingReader) { zxingReader.reset(); zxingReader = null; }
if (preprocTimer) { clearInterval(preprocTimer); preprocTimer = null; }
if (stream) { stream.getTracks().forEach(t => t.stop()); stream = null; }
}
onMount(() => { startScanning(); });
onDestroy(() => { stopScanning(); });
</script>
<div class="scanner-container">
{#if error}
<div class="flex flex-col items-center justify-center h-64 gap-4 text-center p-6">
<p class="text-[var(--danger)]">{error}</p>
<button class="btn btn-primary" onclick={startScanning}>Erneut versuchen</button>
</div>
{:else}
<div class="relative w-full bg-black rounded-xl overflow-hidden" style="aspect-ratio: 4/3;">
<!-- svelte-ignore element_invalid_self_closing_tag -->
<video bind:this={videoEl} class="w-full h-full object-cover" playsinline muted />
<canvas bind:this={canvasEl} class="hidden" />
{#if scanning}
<div class="scan-overlay">
<div class="scan-window">
<div class="scan-corner tl"></div>
<div class="scan-corner tr"></div>
<div class="scan-corner bl"></div>
<div class="scan-corner br"></div>
<div class="scan-line"></div>
</div>
</div>
{/if}
<div class="absolute bottom-3 left-0 right-0 flex justify-center gap-3">
<button class="btn btn-danger" onclick={stopScanning}>
<X size={16} /> Abbrechen
</button>
</div>
</div>
{/if}
</div>
<style>
.scan-overlay {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
}
.scan-window {
position: relative;
width: 75%; height: 35%;
border: 2px solid rgba(67, 144, 220, 0.5);
border-radius: 8px;
}
.scan-corner {
position: absolute; width: 20px; height: 20px;
border-color: var(--accent); border-style: solid; border-width: 0;
}
.scan-corner.tl { top: -2px; left: -2px; border-top-width: 3px; border-left-width: 3px; border-radius: 4px 0 0 0; }
.scan-corner.tr { top: -2px; right: -2px; border-top-width: 3px; border-right-width: 3px; border-radius: 0 4px 0 0; }
.scan-corner.bl { bottom: -2px; left: -2px; border-bottom-width: 3px; border-left-width: 3px; border-radius: 0 0 0 4px; }
.scan-corner.br { bottom: -2px; right: -2px; border-bottom-width: 3px; border-right-width: 3px; border-radius: 0 0 4px 0; }
.scan-line {
position: absolute; left: 5%; right: 5%; height: 2px;
background: var(--accent);
animation: scanMove 2s ease-in-out infinite;
box-shadow: 0 0 8px var(--accent);
}
@keyframes scanMove { 0%, 100% { top: 15%; } 50% { top: 85%; } }
</style>

78
web/src/lib/types.ts Normal file
View file

@ -0,0 +1,78 @@
export interface Product {
id: number;
barcode: string;
name: string;
brand: string;
image_url: string;
nutri_score: string;
ingredients: string;
categories: string;
allergens: string;
has_milk: boolean;
is_lactose_free: boolean;
lactose_per_100g: number | null;
lactose_source: 'off' | 'estimate' | 'manual' | null;
notes: string;
rating: number;
created_at: string;
updated_at: string;
stores?: ProductStore[];
tags?: Tag[];
}
export interface ProductStore {
id: number;
product_id: number;
store_id: number;
store_name?: string;
store_location?: string;
price: number | null;
last_seen: string;
}
export interface Store {
id: number;
name: string;
location: string;
product_count?: number;
}
export interface Tag {
id: number;
name: string;
color: string;
}
export interface Category {
id: number;
name: string;
parent_id: number | null;
children?: Category[];
}
export interface OFFProduct {
code: string;
product_name: string;
brands: string;
image_url: string;
nutriscore_grade: string;
ingredients_text_de: string;
ingredients_text: string;
categories: string;
allergens_tags: string[];
labels_tags: string[];
nutriments: Record<string, number>;
}
export interface LactoseInfo {
has_milk: boolean;
is_lactose_free: boolean;
estimated_lactose_per_100g: number | null;
source: 'off' | 'estimate' | null;
lactase_units: number | null;
}
export interface Settings {
lactase_factor: number;
default_portion_g: number;
}

View file

@ -0,0 +1,44 @@
<script lang="ts">
import '../app.css';
import { page } from '$app/state';
import { ScanBarcode, List, Store, Settings } from 'lucide-svelte';
let { children } = $props();
const navItems = [
{ href: '/', label: 'Produkte', icon: List },
{ href: '/scan', label: 'Scannen', icon: ScanBarcode },
{ href: '/laden', label: 'Läden', icon: Store },
{ href: '/einstellungen', label: 'Settings', icon: Settings },
];
</script>
<div class="flex flex-col min-h-screen min-h-[100dvh]">
<!-- Header -->
<header class="flex items-center h-12 px-4 bg-[var(--bg-secondary)] border-b border-[var(--border)] shrink-0">
<a href="/" class="text-sm font-bold tracking-wide text-[var(--accent)]">
🍎 LECKERBUCH
</a>
</header>
<!-- Content -->
<main class="flex-1 overflow-y-auto pb-16">
{@render children()}
</main>
<!-- Bottom Navigation (mobile) -->
<nav class="fixed bottom-0 left-0 right-0 h-14 bg-[var(--bg-secondary)] border-t border-[var(--border)] flex items-stretch z-50">
{#each navItems as item}
<a
href={item.href}
class="flex-1 flex flex-col items-center justify-center gap-0.5 text-[0.65rem] transition-colors
{page.url.pathname === item.href || (item.href !== '/' && page.url.pathname.startsWith(item.href))
? 'text-[var(--accent)]'
: 'text-[var(--text-muted)] active:text-[var(--text-primary)]'}"
>
<svelte:component this={item.icon} size={20} />
{item.label}
</a>
{/each}
</nav>
</div>

109
web/src/routes/+page.svelte Normal file
View file

@ -0,0 +1,109 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/api';
import { Search, Plus, Milk, Star } from 'lucide-svelte';
import type { Product } from '$lib/types';
let products = $state<Product[]>([]);
let search = $state('');
let loading = $state(true);
async function loadProducts() {
loading = true;
try {
products = await api.products.list(search || undefined);
} catch (e) {
console.error('Laden fehlgeschlagen:', e);
}
loading = false;
}
onMount(loadProducts);
let filteredProducts = $derived(products);
function lactoseLabel(p: Product): { text: string; class: string } {
if (p.is_lactose_free) return { text: 'Laktosefrei', class: 'badge-success' };
if (!p.has_milk) return { text: 'Ohne Milch', class: 'badge-success' };
if (p.lactose_per_100g !== null && p.lactose_per_100g > 1) return { text: `${p.lactose_per_100g}g/100g`, class: 'badge-danger' };
if (p.lactose_per_100g !== null) return { text: `${p.lactose_per_100g}g/100g`, class: 'badge-warning' };
return { text: 'Milch enthalten', class: 'badge-warning' };
}
function doSearch() {
loadProducts();
}
</script>
<div class="p-4 space-y-4">
<div class="flex items-center gap-2">
<div class="relative flex-1">
<Search size={16} class="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--text-muted)]" />
<input
type="search"
placeholder="Produkt suchen..."
bind:value={search}
onkeydown={(e) => e.key === 'Enter' && doSearch()}
class="pl-9"
/>
</div>
<a href="/scan" class="btn btn-primary">
<Plus size={16} />
</a>
</div>
{#if loading}
<div class="text-center py-12 text-[var(--text-muted)]">Lade...</div>
{:else if filteredProducts.length === 0}
<div class="text-center py-12 space-y-4">
<p class="text-[var(--text-muted)]">Noch keine Produkte gespeichert</p>
<a href="/scan" class="btn btn-primary btn-lg">
<Plus size={18} /> Erstes Produkt scannen
</a>
</div>
{:else}
<div class="space-y-2">
{#each filteredProducts as product}
<a href="/produkt/{product.id}" class="card flex gap-3 no-underline">
{#if product.image_url}
<img src={product.image_url} alt="" class="w-14 h-14 rounded object-cover shrink-0 bg-[var(--bg-input)]" />
{:else}
<div class="w-14 h-14 rounded bg-[var(--bg-input)] flex items-center justify-center shrink-0 text-xl">🍽️</div>
{/if}
<div class="flex-1 min-w-0">
<div class="font-medium text-sm truncate">{product.name || product.barcode}</div>
<div class="text-xs text-[var(--text-muted)] truncate">{product.brand || ''}</div>
<div class="flex items-center gap-2 mt-1 flex-wrap">
{#if product.has_milk || product.is_lactose_free}
{@const lbl = lactoseLabel(product)}
<span class="badge {lbl.class}">
<Milk size={10} /> {lbl.text}
</span>
{/if}
{#if product.rating}
<span class="badge badge-accent">
<Star size={10} /> {product.rating}/5
</span>
{/if}
{#if product.stores && product.stores.length > 0}
<span class="text-xs text-[var(--text-muted)]">
{product.stores.map(s => s.store_name).join(', ')}
</span>
{/if}
</div>
</div>
{#if product.stores?.[0]?.price}
<div class="text-sm font-medium text-[var(--accent)] shrink-0">
{product.stores[0].price.toFixed(2)}
</div>
{/if}
</a>
{/each}
</div>
{/if}
</div>

View file

@ -0,0 +1,75 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/api';
import { Save, Milk, Info } from 'lucide-svelte';
let lactaseFactor = $state(1000);
let defaultPortion = $state(100);
let saved = $state(false);
let loading = $state(true);
onMount(async () => {
try {
const s = await api.settings.get();
lactaseFactor = s.lactase_factor ?? 1000;
defaultPortion = s.default_portion_g ?? 100;
} catch {}
loading = false;
});
async function save() {
await api.settings.update({ lactase_factor: lactaseFactor, default_portion_g: defaultPortion });
saved = true;
setTimeout(() => saved = false, 2000);
}
</script>
<div class="p-4 space-y-6">
<h2 class="text-sm font-semibold text-[var(--text-secondary)] uppercase tracking-wide">Einstellungen</h2>
{#if loading}
<p class="text-[var(--text-muted)]">Lade...</p>
{:else}
<div class="panel space-y-4">
<div class="panel-header flex items-center gap-2">
<Milk size={14} /> Laktose-Einstellungen
</div>
<div>
<label>Lactase-Faktor (FCC-Einheiten pro Gramm Laktose)</label>
<input type="number" min="100" max="10000" step="100" bind:value={lactaseFactor} />
<p class="text-xs text-[var(--text-muted)] mt-1">
Richtwerte: 500-1000 bei leichter, 1500-3000 bei mittlerer, 3000-9000 bei starker Intoleranz
</p>
</div>
<div>
<label>Standard-Portionsgröße (g)</label>
<input type="number" min="10" max="1000" step="10" bind:value={defaultPortion} />
<p class="text-xs text-[var(--text-muted)] mt-1">
Wird für die Lactase-Dosis-Berechnung verwendet
</p>
</div>
<div class="p-3 rounded bg-[var(--accent-light)] text-sm flex gap-2">
<Info size={16} class="text-[var(--accent)] shrink-0 mt-0.5" />
<div>
<strong>Formel:</strong> Benötigte Einheiten = (Laktose g/100g × Portion g / 100) × Faktor<br>
<span class="text-[var(--text-muted)]">Beispiel: 4,8g Laktose × 200ml Milch / 100 × {lactaseFactor} = {Math.round(4.8 * 200 / 100 * lactaseFactor)} FCC</span>
</div>
</div>
<button class="btn btn-success btn-block" onclick={save}>
<Save size={16} /> {saved ? 'Gespeichert ✓' : 'Speichern'}
</button>
</div>
<div class="panel">
<div class="panel-header">Info</div>
<p class="text-xs text-[var(--text-muted)]">
Leckerbuch v0.1.0 — Lebensmittel-Favoriten mit Laktose-Tracker.<br>
Daten via Open Food Facts (openfoodfacts.org), Lizenz: ODbL.
</p>
</div>
{/if}
</div>

View file

@ -0,0 +1,67 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/api';
import { Store, Plus } from 'lucide-svelte';
let stores = $state<any[]>([]);
let showAdd = $state(false);
let newName = $state('');
let newLocation = $state('');
onMount(async () => {
stores = await api.stores.list();
});
async function addStore() {
if (!newName.trim()) return;
await api.stores.create({ name: newName.trim(), location: newLocation.trim() });
stores = await api.stores.list();
newName = '';
newLocation = '';
showAdd = false;
}
</script>
<div class="p-4 space-y-4">
<div class="flex items-center justify-between">
<h2 class="text-sm font-semibold text-[var(--text-secondary)] uppercase tracking-wide">Läden</h2>
<button class="btn btn-primary" onclick={() => showAdd = !showAdd}>
<Plus size={16} /> Neu
</button>
</div>
{#if showAdd}
<div class="panel space-y-3">
<div>
<label>Name</label>
<input bind:value={newName} placeholder="z.B. REWE, dm, Alnatura" />
</div>
<div>
<label>Standort / Filiale</label>
<input bind:value={newLocation} placeholder="z.B. Bahnhofstr. 12" />
</div>
<button class="btn btn-success btn-block" onclick={addStore} disabled={!newName.trim()}>Speichern</button>
</div>
{/if}
{#if stores.length === 0}
<p class="text-center text-[var(--text-muted)] py-8">Noch keine Läden angelegt</p>
{:else}
<div class="space-y-2">
{#each stores as store}
<div class="card flex items-center gap-3">
<Store size={18} class="text-[var(--accent)] shrink-0" />
<div>
<div class="font-medium text-sm">{store.name}</div>
{#if store.location}
<div class="text-xs text-[var(--text-muted)]">{store.location}</div>
{/if}
</div>
{#if store.product_count}
<span class="badge badge-accent ml-auto">{store.product_count}</span>
{/if}
</div>
{/each}
</div>
{/if}
</div>

View file

@ -0,0 +1,162 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { api } from '$lib/api';
import type { Product, Settings } from '$lib/types';
import { ArrowLeft, Trash2, Edit, Milk, Store, Star, Tag } from 'lucide-svelte';
let product = $state<Product | null>(null);
let settings = $state<Settings>({ lactase_factor: 1000, default_portion_g: 100 });
let loading = $state(true);
let confirmDelete = $state(false);
onMount(async () => {
const id = Number(page.params.id);
try {
const [p, s] = await Promise.all([api.products.get(id), api.settings.get()]);
product = p;
settings = s;
} catch (e) { console.error(e); }
loading = false;
});
function lactaseDose(p: Product): string | null {
if (!p.has_milk || p.is_lactose_free || !p.lactose_per_100g) return null;
const portionG = settings.default_portion_g;
const lactoseInPortion = (p.lactose_per_100g / 100) * portionG;
const units = Math.round(lactoseInPortion * settings.lactase_factor);
if (units <= 0) return null;
return `~${units} FCC (${portionG}g Portion)`;
}
async function deleteProduct() {
if (!product) return;
await api.products.delete(product.id);
goto('/');
}
</script>
<div class="p-4 space-y-4">
<a href="/" class="inline-flex items-center gap-1 text-sm text-[var(--text-muted)] hover:text-[var(--accent)]">
<ArrowLeft size={16} /> Zurück
</a>
{#if loading}
<div class="text-center py-12 text-[var(--text-muted)]">Lade...</div>
{:else if product}
<div class="flex gap-4 items-start">
{#if product.image_url}
<img src={product.image_url} alt="" class="w-24 h-24 rounded-lg object-cover bg-[var(--bg-input)]" />
{:else}
<div class="w-24 h-24 rounded-lg bg-[var(--bg-input)] flex items-center justify-center text-3xl">🍽️</div>
{/if}
<div class="flex-1">
<h1 class="text-lg font-semibold">{product.name}</h1>
<p class="text-sm text-[var(--text-muted)]">{product.brand || ''}</p>
<p class="text-xs text-[var(--text-muted)] mt-1">EAN: {product.barcode}</p>
{#if product.nutri_score}
<span class="badge badge-accent mt-1 uppercase">Nutri-Score {product.nutri_score}</span>
{/if}
{#if product.rating}
<span class="badge badge-accent mt-1"><Star size={10} /> {product.rating}/5</span>
{/if}
</div>
</div>
<!-- Laktose -->
{#if product.has_milk || product.is_lactose_free}
<div class="panel">
<div class="panel-header flex items-center gap-2"><Milk size={14} /> Laktose</div>
<div class="space-y-2 text-sm">
{#if product.is_lactose_free}
<p class="lactose-free font-medium">Laktosefrei</p>
{:else if product.lactose_per_100g !== null}
<p>
<span class="{product.lactose_per_100g > 1 ? 'lactose-high' : 'lactose-warn'} font-medium">
{product.lactose_per_100g} g/100g
</span>
{#if product.lactose_source === 'estimate'}
<span class="text-xs text-[var(--text-muted)]">(geschätzt)</span>
{/if}
</p>
{@const dose = lactaseDose(product)}
{#if dose}
<p class="text-[var(--accent)]">💊 Empfohlene Lactase: {dose}</p>
{/if}
{:else}
<p class="lactose-warn">Milch enthalten (Laktosegehalt unbekannt)</p>
{/if}
</div>
</div>
{/if}
<!-- Läden -->
{#if product.stores && product.stores.length > 0}
<div class="panel">
<div class="panel-header flex items-center gap-2"><Store size={14} /> Läden</div>
<div class="space-y-2">
{#each product.stores as ps}
<div class="flex items-center justify-between text-sm">
<div>
<span class="font-medium">{ps.store_name}</span>
{#if ps.store_location}
<span class="text-[var(--text-muted)]">{ps.store_location}</span>
{/if}
</div>
{#if ps.price}
<span class="text-[var(--accent)] font-medium">{ps.price.toFixed(2)}</span>
{/if}
</div>
{/each}
</div>
</div>
{/if}
<!-- Tags -->
{#if product.tags && product.tags.length > 0}
<div class="flex flex-wrap gap-1">
{#each product.tags as tag}
<span class="badge badge-accent"><Tag size={10} /> {tag.name}</span>
{/each}
</div>
{/if}
<!-- Notizen -->
{#if product.notes}
<div class="panel">
<div class="panel-header">Notizen</div>
<p class="text-sm whitespace-pre-wrap">{product.notes}</p>
</div>
{/if}
<!-- Zutaten -->
{#if product.ingredients}
<details class="panel cursor-pointer">
<summary class="text-xs font-semibold text-[var(--text-secondary)] uppercase">Zutaten</summary>
<p class="text-xs text-[var(--text-muted)] mt-2 whitespace-pre-wrap">{product.ingredients}</p>
</details>
{/if}
<!-- Actions -->
<div class="flex gap-2 pt-2">
<button class="btn btn-danger flex-1" onclick={() => confirmDelete = true}>
<Trash2 size={16} /> Löschen
</button>
</div>
{#if confirmDelete}
<div class="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
<div class="panel max-w-sm w-full space-y-4">
<p class="text-sm">Produkt wirklich löschen?</p>
<div class="flex gap-2">
<button class="btn btn-outline flex-1" onclick={() => confirmDelete = false}>Abbrechen</button>
<button class="btn btn-danger flex-1" onclick={deleteProduct}>Löschen</button>
</div>
</div>
</div>
{/if}
{:else}
<p class="text-center text-[var(--text-muted)] py-12">Produkt nicht gefunden</p>
{/if}
</div>

View file

@ -0,0 +1,246 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { api } from '$lib/api';
import BarcodeScanner from '$lib/scanner/BarcodeScanner.svelte';
import { Loader, Save, Plus, Milk, Store, Tag } from 'lucide-svelte';
let phase = $state<'scan' | 'loading' | 'form'>('scan');
let barcode = $state('');
let offData = $state<any>(null);
let saving = $state(false);
let error = $state('');
let form = $state({
name: '', brand: '', image_url: '', nutri_score: '',
ingredients: '', categories: '', allergens: '',
has_milk: false, is_lactose_free: false,
lactose_per_100g: null as number | null,
lactose_source: null as string | null,
notes: '', rating: 0,
store_name: '', store_location: '', price: null as number | null,
tags: '',
});
let allStores = $state<any[]>([]);
let storeSuggestions = $derived(
form.store_name.length > 0
? allStores.filter(s => s.name.toLowerCase().includes(form.store_name.toLowerCase()))
: []
);
async function onBarcodeDetected(code: string, format: string) {
barcode = code;
phase = 'loading';
error = '';
try {
const [offResult, stores] = await Promise.all([
api.off.lookup(code),
api.stores.list(),
]);
offData = offResult;
allStores = stores;
if (offResult?.product) {
const p = offResult.product;
form.name = p.name || '';
form.brand = p.brand || '';
form.image_url = p.image_url || '';
form.nutri_score = p.nutri_score || '';
form.ingredients = p.ingredients || '';
form.categories = p.categories || '';
form.allergens = p.allergens || '';
form.has_milk = p.has_milk ?? false;
form.is_lactose_free = p.is_lactose_free ?? false;
form.lactose_per_100g = p.lactose_per_100g ?? null;
form.lactose_source = p.lactose_source ?? null;
}
} catch (e: any) {
error = `OFF-Abfrage fehlgeschlagen: ${e.message}`;
}
phase = 'form';
}
async function saveProduct() {
saving = true;
error = '';
try {
const result = await api.products.create({
barcode,
...form,
store: form.store_name ? {
name: form.store_name,
location: form.store_location,
price: form.price,
} : null,
tags: form.tags ? form.tags.split(',').map(t => t.trim()).filter(Boolean) : [],
});
goto(`/produkt/${result.id}`);
} catch (e: any) {
error = `Speichern fehlgeschlagen: ${e.message}`;
saving = false;
}
}
function selectStore(store: any) {
form.store_name = store.name;
form.store_location = store.location || '';
}
function reScan() {
phase = 'scan';
barcode = '';
offData = null;
error = '';
}
</script>
<div class="p-4">
{#if phase === 'scan'}
<h2 class="text-sm font-semibold text-[var(--text-secondary)] uppercase tracking-wide mb-3">Barcode scannen</h2>
<BarcodeScanner {onDetected}={onBarcodeDetected} />
<p class="text-center text-xs text-[var(--text-muted)] mt-3">
Halte den Barcode in den Sucher. Dual-Decoder (ZXing + zbar).
</p>
{:else if phase === 'loading'}
<div class="flex flex-col items-center justify-center h-64 gap-4">
<Loader size={32} class="animate-spin text-[var(--accent)]" />
<p class="text-[var(--text-muted)]">Lade Produktinfos für {barcode}...</p>
</div>
{:else if phase === 'form'}
<div class="space-y-4">
<div class="flex items-center justify-between">
<h2 class="text-sm font-semibold text-[var(--text-secondary)] uppercase tracking-wide">
{offData?.product ? 'Produkt gefunden' : 'Manuell eintragen'}
</h2>
<button class="btn btn-outline text-xs" onclick={reScan}>Neu scannen</button>
</div>
{#if error}
<div class="p-3 rounded bg-[rgba(252,84,91,0.1)] text-[var(--danger)] text-sm">{error}</div>
{/if}
<div class="text-xs text-[var(--text-muted)]">EAN: {barcode}</div>
{#if form.image_url}
<img src={form.image_url} alt="" class="w-24 h-24 rounded-lg object-cover mx-auto bg-[var(--bg-input)]" />
{/if}
<div class="space-y-3">
<div>
<label>Produktname *</label>
<input bind:value={form.name} placeholder="z.B. Hafer Drink Barista" />
</div>
<div>
<label>Marke</label>
<input bind:value={form.brand} placeholder="z.B. Oatly" />
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label>Nutri-Score</label>
<select bind:value={form.nutri_score}>
<option value="">-</option>
<option value="a">A</option>
<option value="b">B</option>
<option value="c">C</option>
<option value="d">D</option>
<option value="e">E</option>
</select>
</div>
<div>
<label>Bewertung</label>
<select bind:value={form.rating}>
<option value={0}>-</option>
<option value={1}>1 ⭐</option>
<option value={2}>2 ⭐</option>
<option value={3}>3 ⭐</option>
<option value={4}>4 ⭐</option>
<option value={5}>5 ⭐</option>
</select>
</div>
</div>
<!-- Laktose -->
<div class="panel space-y-3">
<div class="panel-header flex items-center gap-2">
<Milk size={14} /> Laktose-Info
</div>
<div class="flex gap-4">
<label class="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" bind:checked={form.has_milk} class="w-4 h-4" />
Milch enthalten
</label>
<label class="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" bind:checked={form.is_lactose_free} class="w-4 h-4" />
Laktosefrei
</label>
</div>
{#if form.has_milk && !form.is_lactose_free}
<div>
<label>Laktose (g/100g) {form.lactose_source === 'estimate' ? '(geschätzt)' : ''}</label>
<input type="number" step="0.1" min="0" max="50" bind:value={form.lactose_per_100g} placeholder="z.B. 4.8" />
</div>
{/if}
</div>
<!-- Laden -->
<div class="panel space-y-3">
<div class="panel-header flex items-center gap-2">
<Store size={14} /> Wo gekauft?
</div>
<div class="relative">
<label>Laden</label>
<input bind:value={form.store_name} placeholder="z.B. REWE, dm, Alnatura" autocomplete="off" />
{#if storeSuggestions.length > 0 && form.store_name.length > 0}
<div class="absolute top-full left-0 right-0 z-10 mt-1 bg-[var(--bg-panel)] border border-[var(--border)] rounded shadow-lg max-h-40 overflow-y-auto">
{#each storeSuggestions as s}
<button class="w-full text-left px-3 py-2 text-sm hover:bg-[var(--bg-hover)] transition-colors" onclick={() => selectStore(s)}>
{s.name} {s.location ? `— ${s.location}` : ''}
</button>
{/each}
</div>
{/if}
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label>Standort / Filiale</label>
<input bind:value={form.store_location} placeholder="z.B. Bahnhofstr." />
</div>
<div>
<label>Preis (€)</label>
<input type="number" step="0.01" min="0" bind:value={form.price} placeholder="z.B. 2.49" />
</div>
</div>
</div>
<!-- Tags -->
<div>
<label class="flex items-center gap-1"><Tag size={12} /> Tags (kommagetrennt)</label>
<input bind:value={form.tags} placeholder="z.B. gesund, histaminarm, vegan" />
</div>
<!-- Notizen -->
<div>
<label>Notizen</label>
<textarea bind:value={form.notes} rows="2" placeholder="z.B. Guter Ersatz für..."></textarea>
</div>
<button class="btn btn-success btn-lg btn-block" onclick={saveProduct} disabled={saving || !form.name}>
{#if saving}
<Loader size={16} class="animate-spin" /> Speichern...
{:else}
<Save size={16} /> Produkt speichern
{/if}
</button>
</div>
</div>
{/if}
</div>

4
web/static/favicon.svg Normal file
View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<rect width="100" height="100" rx="20" fill="#1d1e20"/>
<text x="50" y="68" font-size="55" text-anchor="middle" fill="#4390dc">📖</text>
</svg>

After

Width:  |  Height:  |  Size: 213 B

13
web/static/manifest.json Normal file
View file

@ -0,0 +1,13 @@
{
"name": "Leckerbuch",
"short_name": "Leckerbuch",
"description": "Lebensmittel-Favoriten — scannen, merken, wiederfinden",
"start_url": "/",
"display": "standalone",
"background_color": "#1d1e20",
"theme_color": "#1d1e20",
"orientation": "portrait-primary",
"icons": [
{ "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml" }
]
}

9
web/svelte.config.js Normal file
View file

@ -0,0 +1,9 @@
import adapter from '@sveltejs/adapter-static';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
export default {
preprocess: vitePreprocess(),
kit: {
adapter: adapter({ fallback: 'index.html' }),
},
};

14
web/tsconfig.json Normal file
View file

@ -0,0 +1,14 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
}

8
web/vite.config.ts Normal file
View file

@ -0,0 +1,8 @@
import { sveltekit } from '@sveltejs/kit/vite';
import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [tailwindcss(), sveltekit()],
server: { proxy: { '/api': 'http://localhost:3300' } },
});