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:
parent
1af9c0aea4
commit
24e05680f9
33 changed files with 6808 additions and 0 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
node_modules/\ndist/\nbuild/\n.svelte-kit/\n*.log
|
||||||
|
node_modules/
|
||||||
|
.svelte-kit/
|
||||||
27
Dockerfile
Normal file
27
Dockerfile
Normal 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
14
api/drizzle.config.ts
Normal 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
2722
api/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
27
api/package.json
Normal file
27
api/package.json
Normal 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
16
api/src/config.ts
Normal 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
55
api/src/db/schema.ts
Normal 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(),
|
||||||
|
});
|
||||||
38
api/src/einstellungen/routes.ts
Normal file
38
api/src/einstellungen/routes.ts
Normal 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
47
api/src/index.ts
Normal 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
26
api/src/laden/routes.ts
Normal 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 };
|
||||||
|
});
|
||||||
|
};
|
||||||
126
api/src/openfoodfacts/routes.ts
Normal file
126
api/src/openfoodfacts/routes.ts
Normal 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
125
api/src/produkte/routes.ts
Normal 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
18
api/src/produkte/tags.ts
Normal 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
14
api/tsconfig.json
Normal 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
15
docker-compose.yml
Normal 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
2258
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
30
web/package.json
Normal file
30
web/package.json
Normal 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
152
web/src/app.css
Normal 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
20
web/src/app.html
Normal 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
38
web/src/lib/api.ts
Normal 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) }),
|
||||||
|
},
|
||||||
|
};
|
||||||
208
web/src/lib/scanner/BarcodeScanner.svelte
Normal file
208
web/src/lib/scanner/BarcodeScanner.svelte
Normal 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
78
web/src/lib/types.ts
Normal 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;
|
||||||
|
}
|
||||||
44
web/src/routes/+layout.svelte
Normal file
44
web/src/routes/+layout.svelte
Normal 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
109
web/src/routes/+page.svelte
Normal 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>
|
||||||
75
web/src/routes/einstellungen/+page.svelte
Normal file
75
web/src/routes/einstellungen/+page.svelte
Normal 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>
|
||||||
67
web/src/routes/laden/+page.svelte
Normal file
67
web/src/routes/laden/+page.svelte
Normal 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>
|
||||||
162
web/src/routes/produkt/[id]/+page.svelte
Normal file
162
web/src/routes/produkt/[id]/+page.svelte
Normal 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>
|
||||||
246
web/src/routes/scan/+page.svelte
Normal file
246
web/src/routes/scan/+page.svelte
Normal 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
4
web/static/favicon.svg
Normal 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
13
web/static/manifest.json
Normal 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
9
web/svelte.config.js
Normal 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
14
web/tsconfig.json
Normal 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
8
web/vite.config.ts
Normal 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' } },
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue