feat: stabilization + recipe edit/create UI

This commit is contained in:
clawd
2026-02-18 09:55:39 +00:00
commit ee452efa6a
75 changed files with 15160 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules/
dist/
*.db
data/
.env

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
backend/data/recipes.db Normal file

Binary file not shown.

BIN
backend/data/recipes.db-shm Normal file

Binary file not shown.

BIN
backend/data/recipes.db-wal Normal file

Binary file not shown.

2561
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

32
backend/package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "backend",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@fastify/cors": "^11.2.0",
"@fastify/multipart": "^9.4.0",
"@fastify/static": "^9.0.0",
"@types/sharp": "^0.31.1",
"better-sqlite3": "^12.6.2",
"fastify": "^5.7.4",
"sharp": "^0.34.5",
"ulid": "^3.0.2",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^25.2.3",
"tsx": "^4.21.0",
"typescript": "^5.9.3"
}
}

43
backend/src/app.ts Normal file
View File

@@ -0,0 +1,43 @@
import Fastify from 'fastify';
import cors from '@fastify/cors';
import { healthRoutes } from './routes/health.js';
import { categoryRoutes } from './routes/categories.js';
import { recipeRoutes } from './routes/recipes.js';
import { noteRoutes } from './routes/notes.js';
import { shoppingRoutes } from './routes/shopping.js';
import { tagRoutes } from './routes/tags.js';
import { imageRoutes } from './routes/images.js';
import { botRoutes } from './routes/bot.js';
export async function buildApp() {
const app = Fastify({ logger: true });
await app.register(cors, { origin: true });
await app.after();
await app.register(healthRoutes);
await app.after();
await app.register(categoryRoutes);
await app.after();
await app.register(recipeRoutes);
await app.after();
await app.register(noteRoutes);
await app.after();
await app.register(shoppingRoutes);
await app.after();
await app.register(tagRoutes);
await app.after();
await app.register(imageRoutes);
await app.after();
await app.register(botRoutes);
await app.after();
return app;
}

View File

@@ -0,0 +1,24 @@
import Database from 'better-sqlite3';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const DB_PATH = path.resolve(__dirname, '../../data/recipes.db');
let db: Database.Database | null = null;
export function getDb(): Database.Database {
if (!db) {
db = new Database(DB_PATH);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
}
return db;
}
export function closeDb(): void {
if (db) {
db.close();
db = null;
}
}

35
backend/src/db/migrate.ts Normal file
View File

@@ -0,0 +1,35 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { getDb } from './connection.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const MIGRATIONS_DIR = path.resolve(__dirname, 'migrations');
export function runMigrations(): void {
const db = getDb();
db.exec(`
CREATE TABLE IF NOT EXISTS _migrations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
const applied = new Set(
db.prepare('SELECT name FROM _migrations').all().map((r: any) => r.name)
);
const files = fs.readdirSync(MIGRATIONS_DIR)
.filter(f => f.endsWith('.sql'))
.sort();
for (const file of files) {
if (applied.has(file)) continue;
const sql = fs.readFileSync(path.join(MIGRATIONS_DIR, file), 'utf-8');
console.log(`Running migration: ${file}`);
db.exec(sql);
db.prepare('INSERT INTO _migrations (name) VALUES (?)').run(file);
}
}

View File

@@ -0,0 +1,136 @@
-- Categories
CREATE TABLE IF NOT EXISTS categories (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
slug TEXT NOT NULL UNIQUE,
icon TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
INSERT OR IGNORE INTO categories (id, name, slug, sort_order) VALUES
('01BACKEN00000000000000000', 'Backen', 'backen', 1),
('01TORTEN00000000000000000', 'Torten', 'torten', 2),
('01FRUEHSTUECK000000000000', 'Frühstück', 'fruehstueck', 3),
('01MITTAG00000000000000000', 'Mittag', 'mittag', 4),
('01ABEND000000000000000000', 'Abend', 'abend', 5),
('01SNACKS0000000000000000A', 'Snacks', 'snacks', 6),
('01DESSERTS000000000000000', 'Desserts', 'desserts', 7);
-- Tags
CREATE TABLE IF NOT EXISTS tags (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
slug TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Recipes
CREATE TABLE IF NOT EXISTS recipes (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
description TEXT,
category_id TEXT REFERENCES categories(id) ON DELETE SET NULL,
difficulty TEXT CHECK(difficulty IN ('easy', 'medium', 'hard')) DEFAULT 'medium',
prep_time INTEGER,
cook_time INTEGER,
total_time INTEGER,
servings INTEGER DEFAULT 4,
image_url TEXT,
source_url TEXT,
is_favorite INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Recipe Tags (many-to-many)
CREATE TABLE IF NOT EXISTS recipe_tags (
recipe_id TEXT NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (recipe_id, tag_id)
);
-- Ingredients
CREATE TABLE IF NOT EXISTS ingredients (
id TEXT PRIMARY KEY,
recipe_id TEXT NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
amount REAL,
unit TEXT,
name TEXT NOT NULL,
group_name TEXT,
sort_order INTEGER NOT NULL DEFAULT 0
);
-- Steps
CREATE TABLE IF NOT EXISTS steps (
id TEXT PRIMARY KEY,
recipe_id TEXT NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
step_number INTEGER NOT NULL,
instruction TEXT NOT NULL,
duration_minutes INTEGER,
image_url TEXT
);
-- Notes
CREATE TABLE IF NOT EXISTS notes (
id TEXT PRIMARY KEY,
recipe_id TEXT NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
content TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Shopping Items
CREATE TABLE IF NOT EXISTS shopping_items (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
amount REAL,
unit TEXT,
checked INTEGER NOT NULL DEFAULT 0,
recipe_id TEXT REFERENCES recipes(id) ON DELETE SET NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- FTS5 for recipes
CREATE VIRTUAL TABLE IF NOT EXISTS recipes_fts USING fts5(
title, description, content=recipes, content_rowid=rowid
);
CREATE TRIGGER IF NOT EXISTS recipes_ai AFTER INSERT ON recipes BEGIN
INSERT INTO recipes_fts(rowid, title, description) VALUES (new.rowid, new.title, new.description);
END;
CREATE TRIGGER IF NOT EXISTS recipes_ad AFTER DELETE ON recipes BEGIN
INSERT INTO recipes_fts(recipes_fts, rowid, title, description) VALUES ('delete', old.rowid, old.title, old.description);
END;
CREATE TRIGGER IF NOT EXISTS recipes_au AFTER UPDATE ON recipes BEGIN
INSERT INTO recipes_fts(recipes_fts, rowid, title, description) VALUES ('delete', old.rowid, old.title, old.description);
INSERT INTO recipes_fts(rowid, title, description) VALUES (new.rowid, new.title, new.description);
END;
-- FTS5 for ingredients
CREATE VIRTUAL TABLE IF NOT EXISTS ingredients_fts USING fts5(
name, content=ingredients, content_rowid=rowid
);
CREATE TRIGGER IF NOT EXISTS ingredients_ai AFTER INSERT ON ingredients BEGIN
INSERT INTO ingredients_fts(rowid, name) VALUES (new.rowid, new.name);
END;
CREATE TRIGGER IF NOT EXISTS ingredients_ad AFTER DELETE ON ingredients BEGIN
INSERT INTO ingredients_fts(ingredients_fts, rowid, name) VALUES ('delete', old.rowid, old.name);
END;
CREATE TRIGGER IF NOT EXISTS ingredients_au AFTER UPDATE ON ingredients BEGIN
INSERT INTO ingredients_fts(ingredients_fts, rowid, name) VALUES ('delete', old.rowid, old.name);
INSERT INTO ingredients_fts(rowid, name) VALUES (new.rowid, new.name);
END;
-- Indexes
CREATE INDEX IF NOT EXISTS idx_recipes_category ON recipes(category_id);
CREATE INDEX IF NOT EXISTS idx_recipes_slug ON recipes(slug);
CREATE INDEX IF NOT EXISTS idx_recipes_favorite ON recipes(is_favorite);
CREATE INDEX IF NOT EXISTS idx_ingredients_recipe ON ingredients(recipe_id);
CREATE INDEX IF NOT EXISTS idx_steps_recipe ON steps(recipe_id);
CREATE INDEX IF NOT EXISTS idx_notes_recipe ON notes(recipe_id);

18
backend/src/index.ts Normal file
View File

@@ -0,0 +1,18 @@
import { buildApp } from './app.js';
import { runMigrations } from './db/migrate.js';
const PORT = Number(process.env.PORT || 6001);
async function main() {
console.log('Running migrations...');
runMigrations();
const app = await buildApp();
await app.listen({ port: PORT, host: '0.0.0.0' });
console.log(`Server running on http://localhost:${PORT}`);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

43
backend/src/routes/bot.ts Normal file
View File

@@ -0,0 +1,43 @@
import { FastifyInstance } from 'fastify';
import * as recipeSvc from '../services/recipe.service.js';
import * as imageSvc from '../services/image.service.js';
export async function botRoutes(app: FastifyInstance) {
const BOT_TOKEN = process.env.BOT_API_TOKEN;
app.addHook('onRequest', async (request, reply) => {
if (!BOT_TOKEN) return reply.status(503).send({ error: 'Bot API not configured' });
const auth = request.headers.authorization;
if (!auth || auth !== `Bearer ${BOT_TOKEN}`) {
return reply.status(401).send({ error: 'Unauthorized' });
}
});
app.get('/api/bot/recipes', async (request) => {
const query = request.query as any;
return recipeSvc.listRecipes({
page: query.page ? Number(query.page) : undefined,
limit: query.limit ? Number(query.limit) : undefined,
});
});
app.post('/api/bot/recipes', async (request, reply) => {
const body = request.body as recipeSvc.CreateRecipeInput;
if (!body.title) return reply.status(400).send({ error: 'title required' });
const recipe = recipeSvc.createRecipe(body);
return reply.status(201).send(recipe);
});
app.post('/api/bot/recipes/:id/image-url', async (request, reply) => {
const { id } = request.params as { id: string };
const { url } = request.body as { url: string };
if (!url) return reply.status(400).send({ error: 'url required' });
try {
const result = await imageSvc.downloadAndSaveImage(id, url);
if (!result) return reply.status(404).send({ error: 'Recipe not found' });
return result;
} catch (e: any) {
return reply.status(400).send({ error: e.message });
}
});
}

View File

@@ -0,0 +1,15 @@
import { FastifyInstance } from 'fastify';
import { listCategories, createCategory } from '../services/recipe.service.js';
export async function categoryRoutes(app: FastifyInstance) {
app.get('/api/categories', async () => {
return listCategories();
});
app.post('/api/categories', async (request, reply) => {
const { name } = request.body as { name: string };
if (!name) return reply.status(400).send({ error: 'name required' });
const cat = createCategory(name);
return reply.status(201).send(cat);
});
}

View File

@@ -0,0 +1,7 @@
import { FastifyInstance } from 'fastify';
export async function healthRoutes(app: FastifyInstance) {
app.get('/api/health', async () => {
return { status: 'ok', timestamp: new Date().toISOString() };
});
}

View File

@@ -0,0 +1,40 @@
import { FastifyInstance } from 'fastify';
import multipart from '@fastify/multipart';
import fastifyStatic from '@fastify/static';
import path from 'path';
import { fileURLToPath } from 'url';
import * as svc from '../services/image.service.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export async function imageRoutes(app: FastifyInstance) {
await app.register(multipart, { limits: { fileSize: 10 * 1024 * 1024 } });
await app.after();
await app.register(fastifyStatic, {
root: path.resolve(__dirname, '../../data/images'),
prefix: '/images/',
decorateReply: false,
});
await app.after();
app.post('/api/recipes/:id/image', async (request, reply) => {
const { id } = request.params as { id: string };
const file = await request.file();
if (!file) return reply.status(400).send({ error: 'No file uploaded' });
const buffer = await file.toBuffer();
const result = await svc.saveRecipeImage(id, buffer);
if (!result) return reply.status(404).send({ error: 'Recipe not found' });
return result;
});
app.post('/api/recipes/:id/steps/:stepNumber/image', async (request, reply) => {
const { id, stepNumber } = request.params as { id: string; stepNumber: string };
const file = await request.file();
if (!file) return reply.status(400).send({ error: 'No file uploaded' });
const buffer = await file.toBuffer();
const result = await svc.saveStepImage(id, Number(stepNumber), buffer);
if (!result) return reply.status(404).send({ error: 'Step not found' });
return result;
});
}

View File

@@ -0,0 +1,34 @@
import { FastifyInstance } from 'fastify';
import * as svc from '../services/note.service.js';
export async function noteRoutes(app: FastifyInstance) {
app.get('/api/recipes/:id/notes', async (request) => {
const { id } = request.params as { id: string };
return svc.listNotes(id);
});
app.post('/api/recipes/:id/notes', async (request, reply) => {
const { id } = request.params as { id: string };
const { content } = request.body as { content: string };
if (!content) return reply.status(400).send({ error: 'content required' });
const note = svc.createNote(id, content);
if (!note) return reply.status(404).send({ error: 'Recipe not found' });
return reply.status(201).send(note);
});
app.put('/api/notes/:id', async (request, reply) => {
const { id } = request.params as { id: string };
const { content } = request.body as { content: string };
if (!content) return reply.status(400).send({ error: 'content required' });
const note = svc.updateNote(id, content);
if (!note) return reply.status(404).send({ error: 'Not found' });
return note;
});
app.delete('/api/notes/:id', async (request, reply) => {
const { id } = request.params as { id: string };
const ok = svc.deleteNote(id);
if (!ok) return reply.status(404).send({ error: 'Not found' });
return { ok: true };
});
}

View File

@@ -0,0 +1,59 @@
import { FastifyInstance } from 'fastify';
import * as svc from '../services/recipe.service.js';
export async function recipeRoutes(app: FastifyInstance) {
app.get('/api/recipes/search', async (request) => {
const { q } = request.query as { q?: string };
if (!q) return { data: [], total: 0 };
const results = svc.searchRecipes(q);
return { data: results, total: results.length };
});
app.get('/api/recipes', async (request) => {
const query = request.query as any;
return svc.listRecipes({
page: query.page ? Number(query.page) : undefined,
limit: query.limit ? Number(query.limit) : undefined,
category_id: query.category_id,
category_slug: query.category,
favorite: query.favorite !== undefined ? query.favorite === 'true' : undefined,
difficulty: query.difficulty,
maxTime: query.maxTime ? Number(query.maxTime) : undefined,
});
});
app.get('/api/recipes/:slug', async (request, reply) => {
const { slug } = request.params as { slug: string };
const recipe = svc.getRecipeBySlug(slug);
if (!recipe) return reply.status(404).send({ error: 'Not found' });
return recipe;
});
app.post('/api/recipes', async (request, reply) => {
const body = request.body as svc.CreateRecipeInput;
if (!body.title) return reply.status(400).send({ error: 'title required' });
const recipe = svc.createRecipe(body);
return reply.status(201).send(recipe);
});
app.put('/api/recipes/:id', async (request, reply) => {
const { id } = request.params as { id: string };
const recipe = svc.updateRecipe(id, request.body as any);
if (!recipe) return reply.status(404).send({ error: 'Not found' });
return recipe;
});
app.delete('/api/recipes/:id', async (request, reply) => {
const { id } = request.params as { id: string };
const ok = svc.deleteRecipe(id);
if (!ok) return reply.status(404).send({ error: 'Not found' });
return { ok: true };
});
app.patch('/api/recipes/:id/favorite', async (request, reply) => {
const { id } = request.params as { id: string };
const result = svc.toggleFavorite(id);
if (!result) return reply.status(404).send({ error: 'Not found' });
return result;
});
}

View File

@@ -0,0 +1,41 @@
import { FastifyInstance } from 'fastify';
import * as svc from '../services/shopping.service.js';
export async function shoppingRoutes(app: FastifyInstance) {
app.get('/api/shopping', async () => {
return svc.listItems();
});
app.post('/api/shopping/from-recipe/:id', async (request, reply) => {
const { id } = request.params as { id: string };
const items = svc.addFromRecipe(id);
if (!items) return reply.status(404).send({ error: 'Recipe not found' });
return reply.status(201).send({ added: items.length });
});
app.post('/api/shopping', async (request, reply) => {
const { name, amount, unit } = request.body as { name: string; amount?: number; unit?: string };
if (!name) return reply.status(400).send({ error: 'name required' });
const item = svc.addItem(name, amount, unit);
return reply.status(201).send(item);
});
app.patch('/api/shopping/:id/check', async (request, reply) => {
const { id } = request.params as { id: string };
const item = svc.toggleCheck(id);
if (!item) return reply.status(404).send({ error: 'Not found' });
return item;
});
app.delete('/api/shopping/checked', async () => {
const count = svc.deleteChecked();
return { ok: true, deleted: count };
});
app.delete('/api/shopping/:id', async (request, reply) => {
const { id } = request.params as { id: string };
const ok = svc.deleteItem(id);
if (!ok) return reply.status(404).send({ error: 'Not found' });
return { ok: true };
});
}

View File

@@ -0,0 +1,15 @@
import { FastifyInstance } from 'fastify';
import * as svc from '../services/tag.service.js';
export async function tagRoutes(app: FastifyInstance) {
app.get('/api/tags', async () => {
return svc.listTags();
});
app.get('/api/tags/:name/recipes', async (request, reply) => {
const { name } = request.params as { name: string };
const result = svc.getRecipesByTag(name);
if (!result) return reply.status(404).send({ error: 'Tag not found' });
return result;
});
}

View File

@@ -0,0 +1,5 @@
import { z } from 'zod';
export const createNoteSchema = z.object({
content: z.string().min(1),
});

View File

@@ -0,0 +1,31 @@
import { z } from 'zod';
export const ingredientSchema = z.object({
amount: z.number().optional(),
unit: z.string().optional(),
name: z.string().min(1),
group_name: z.string().optional(),
sort_order: z.number().optional(),
});
export const stepSchema = z.object({
step_number: z.number().int().min(1),
instruction: z.string().min(1),
duration_minutes: z.number().int().optional(),
});
export const createRecipeSchema = z.object({
title: z.string().min(1),
description: z.string().optional(),
category_id: z.string().optional(),
difficulty: z.enum(['easy', 'medium', 'hard']).optional(),
prep_time: z.number().int().optional(),
cook_time: z.number().int().optional(),
servings: z.number().int().optional(),
image_url: z.string().optional(),
source_url: z.string().optional(),
ingredients: z.array(ingredientSchema).optional(),
steps: z.array(stepSchema).optional(),
});
export const updateRecipeSchema = createRecipeSchema.partial();

View File

@@ -0,0 +1,7 @@
import { z } from 'zod';
export const createShoppingItemSchema = z.object({
name: z.string().min(1),
amount: z.number().optional(),
unit: z.string().optional(),
});

View File

@@ -0,0 +1,51 @@
import { getDb } from '../db/connection.js';
import sharp from 'sharp';
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const DATA_DIR = path.resolve(__dirname, '../../data/images');
async function ensureDir(dir: string) {
await fs.promises.mkdir(dir, { recursive: true });
}
export async function saveRecipeImage(recipeId: string, buffer: Buffer) {
const db = getDb();
const recipe = db.prepare('SELECT id FROM recipes WHERE id = ?').get(recipeId);
if (!recipe) return null;
const dir = path.join(DATA_DIR, 'recipes', recipeId);
await ensureDir(dir);
await sharp(buffer).resize({ width: 1200, withoutEnlargement: true }).webp({ quality: 80 }).toFile(path.join(dir, 'hero.webp'));
await sharp(buffer).resize({ width: 400, withoutEnlargement: true }).webp({ quality: 70 }).toFile(path.join(dir, 'hero_thumb.webp'));
const imagePath = `/images/recipes/${recipeId}/hero.webp`;
db.prepare('UPDATE recipes SET image_url = ?, updated_at = datetime(\'now\') WHERE id = ?').run(imagePath, recipeId);
return { image_url: imagePath, thumb_url: `/images/recipes/${recipeId}/hero_thumb.webp` };
}
export async function saveStepImage(recipeId: string, stepNumber: number, buffer: Buffer) {
const db = getDb();
const step = db.prepare('SELECT id FROM steps WHERE recipe_id = ? AND step_number = ?').get(recipeId, stepNumber) as any;
if (!step) return null;
const dir = path.join(DATA_DIR, 'recipes', recipeId, 'steps');
await ensureDir(dir);
const filename = `step_${stepNumber}.webp`;
await sharp(buffer).resize({ width: 1200, withoutEnlargement: true }).webp({ quality: 80 }).toFile(path.join(dir, filename));
const imageUrl = `/images/recipes/${recipeId}/steps/${filename}`;
db.prepare('UPDATE steps SET image_url = ? WHERE id = ?').run(imageUrl, step.id);
return { image_url: imageUrl };
}
export async function downloadAndSaveImage(recipeId: string, url: string) {
const response = await fetch(url);
if (!response.ok) throw new Error(`Failed to download image: ${response.status}`);
const buffer = Buffer.from(await response.arrayBuffer());
return saveRecipeImage(recipeId, buffer);
}

View File

@@ -0,0 +1,26 @@
import { getDb } from '../db/connection.js';
import { ulid } from 'ulid';
export function listNotes(recipeId: string) {
return getDb().prepare('SELECT * FROM notes WHERE recipe_id = ? ORDER BY created_at DESC').all(recipeId);
}
export function createNote(recipeId: string, content: string) {
const db = getDb();
const recipe = db.prepare('SELECT id FROM recipes WHERE id = ?').get(recipeId);
if (!recipe) return null;
const id = ulid();
db.prepare('INSERT INTO notes (id, recipe_id, content) VALUES (?, ?, ?)').run(id, recipeId, content);
return db.prepare('SELECT * FROM notes WHERE id = ?').get(id);
}
export function updateNote(id: string, content: string) {
const db = getDb();
const result = db.prepare('UPDATE notes SET content = ? WHERE id = ?').run(content, id);
if (result.changes === 0) return null;
return db.prepare('SELECT * FROM notes WHERE id = ?').get(id);
}
export function deleteNote(id: string): boolean {
return getDb().prepare('DELETE FROM notes WHERE id = ?').run(id).changes > 0;
}

View File

@@ -0,0 +1,197 @@
import { getDb } from '../db/connection.js';
import { ulid } from 'ulid';
function slugify(text: string): string {
return text
.toLowerCase()
.replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
}
function ensureUniqueSlug(baseSlug: string, excludeId?: string): string {
const db = getDb();
let slug = baseSlug;
let i = 1;
while (true) {
const existing = excludeId
? db.prepare('SELECT id FROM recipes WHERE slug = ? AND id != ?').get(slug, excludeId)
: db.prepare('SELECT id FROM recipes WHERE slug = ?').get(slug);
if (!existing) return slug;
slug = `${baseSlug}-${i++}`;
}
}
export interface CreateRecipeInput {
title: string;
description?: string;
category_id?: string;
difficulty?: 'easy' | 'medium' | 'hard';
prep_time?: number;
cook_time?: number;
servings?: number;
image_url?: string;
source_url?: string;
ingredients?: { amount?: number; unit?: string; name: string; group_name?: string; sort_order?: number }[];
steps?: { step_number: number; instruction: string; duration_minutes?: number }[];
}
function mapTimeFields(row: any) {
if (!row) return row;
row.prep_time_min = row.prep_time ?? null;
row.cook_time_min = row.cook_time ?? null;
row.total_time_min = row.total_time ?? null;
return row;
}
export function listRecipes(opts: {
page?: number; limit?: number; category_id?: string; category_slug?: string;
favorite?: boolean; difficulty?: string; maxTime?: number;
}) {
const db = getDb();
const page = opts.page || 1;
const limit = opts.limit || 20;
const offset = (page - 1) * limit;
const conditions: string[] = [];
const params: any[] = [];
if (opts.category_id) { conditions.push('r.category_id = ?'); params.push(opts.category_id); }
if (opts.category_slug) { conditions.push('c.slug = ?'); params.push(opts.category_slug); }
if (opts.favorite !== undefined) { conditions.push('r.is_favorite = ?'); params.push(opts.favorite ? 1 : 0); }
if (opts.difficulty) { conditions.push('r.difficulty = ?'); params.push(opts.difficulty); }
if (opts.maxTime) { conditions.push('r.total_time <= ?'); params.push(opts.maxTime); }
const where = conditions.length ? 'WHERE ' + conditions.join(' AND ') : '';
const countRow = db.prepare(`SELECT COUNT(*) as total FROM recipes r LEFT JOIN categories c ON r.category_id = c.id ${where}`).get(...params) as any;
const rows = db.prepare(
`SELECT r.*, c.name as category_name, c.slug as category_slug FROM recipes r LEFT JOIN categories c ON r.category_id = c.id ${where} ORDER BY r.created_at DESC LIMIT ? OFFSET ?`
).all(...params, limit, offset);
const data = rows.map(mapTimeFields);
return { data, total: countRow.total, page, limit, totalPages: Math.ceil(countRow.total / limit) };
}
export function getRecipeBySlug(slug: string) {
const db = getDb();
const recipe = db.prepare(
'SELECT r.*, c.name as category_name FROM recipes r LEFT JOIN categories c ON r.category_id = c.id WHERE r.slug = ?'
).get(slug) as any;
if (!recipe) return null;
recipe.ingredients = db.prepare('SELECT * FROM ingredients WHERE recipe_id = ? ORDER BY sort_order').all(recipe.id);
recipe.steps = db.prepare('SELECT * FROM steps WHERE recipe_id = ? ORDER BY step_number').all(recipe.id);
recipe.notes = db.prepare('SELECT * FROM notes WHERE recipe_id = ? ORDER BY created_at DESC').all(recipe.id);
const tagRows = db.prepare(
'SELECT t.name FROM tags t JOIN recipe_tags rt ON t.id = rt.tag_id WHERE rt.recipe_id = ?'
).all(recipe.id) as { name: string }[];
recipe.tags = tagRows.map(t => t.name);
mapTimeFields(recipe);
return recipe;
}
export function createRecipe(input: CreateRecipeInput) {
const db = getDb();
const id = ulid();
const slug = ensureUniqueSlug(slugify(input.title));
const totalTime = (input.prep_time || 0) + (input.cook_time || 0) || null;
const insertRecipe = db.prepare(`
INSERT INTO recipes (id, title, slug, description, category_id, difficulty, prep_time, cook_time, total_time, servings, image_url, source_url)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const insertIngredient = db.prepare(`
INSERT INTO ingredients (id, recipe_id, amount, unit, name, group_name, sort_order)
VALUES (?, ?, ?, ?, ?, ?, ?)
`);
const insertStep = db.prepare(`
INSERT INTO steps (id, recipe_id, step_number, instruction, duration_minutes)
VALUES (?, ?, ?, ?, ?)
`);
const transaction = db.transaction(() => {
insertRecipe.run(id, input.title, slug, input.description || null, input.category_id || null,
input.difficulty || 'medium', input.prep_time || null, input.cook_time || null, totalTime,
input.servings || 4, input.image_url || null, input.source_url || null);
if (input.ingredients) {
for (let i = 0; i < input.ingredients.length; i++) {
const ing = input.ingredients[i];
insertIngredient.run(ulid(), id, ing.amount || null, ing.unit || null, ing.name, ing.group_name || null, ing.sort_order ?? i);
}
}
if (input.steps) {
for (const step of input.steps) {
insertStep.run(ulid(), id, step.step_number, step.instruction, step.duration_minutes || null);
}
}
});
transaction();
return getRecipeBySlug(slug);
}
export function updateRecipe(id: string, input: Partial<CreateRecipeInput>) {
const db = getDb();
const existing = db.prepare('SELECT * FROM recipes WHERE id = ?').get(id) as any;
if (!existing) return null;
const slug = input.title ? ensureUniqueSlug(slugify(input.title), id) : existing.slug;
const totalTime = ((input.prep_time ?? existing.prep_time) || 0) + ((input.cook_time ?? existing.cook_time) || 0) || null;
db.prepare(`
UPDATE recipes SET title=?, slug=?, description=?, category_id=?, difficulty=?, prep_time=?, cook_time=?, total_time=?, servings=?, image_url=?, source_url=?, updated_at=datetime('now')
WHERE id=?
`).run(
input.title ?? existing.title, slug, input.description ?? existing.description,
input.category_id ?? existing.category_id, input.difficulty ?? existing.difficulty,
input.prep_time ?? existing.prep_time, input.cook_time ?? existing.cook_time, totalTime,
input.servings ?? existing.servings, input.image_url ?? existing.image_url,
input.source_url ?? existing.source_url, id
);
return getRecipeBySlug(slug);
}
export function deleteRecipe(id: string): boolean {
const result = getDb().prepare('DELETE FROM recipes WHERE id = ?').run(id);
return result.changes > 0;
}
export function toggleFavorite(id: string) {
const db = getDb();
const recipe = db.prepare('SELECT is_favorite FROM recipes WHERE id = ?').get(id) as any;
if (!recipe) return null;
const newVal = recipe.is_favorite ? 0 : 1;
db.prepare('UPDATE recipes SET is_favorite = ? WHERE id = ?').run(newVal, id);
return { id, is_favorite: newVal };
}
export function searchRecipes(query: string) {
const db = getDb();
// Add * for prefix matching
const ftsQuery = query.trim().split(/\s+/).map(t => `"${t}"*`).join(' ');
return db.prepare(`
SELECT r.*, c.name as category_name
FROM recipes_fts fts
JOIN recipes r ON r.rowid = fts.rowid
LEFT JOIN categories c ON r.category_id = c.id
WHERE recipes_fts MATCH ?
ORDER BY rank
`).all(ftsQuery).map(mapTimeFields);
}
export function listCategories() {
return getDb().prepare('SELECT * FROM categories ORDER BY sort_order').all();
}
export function createCategory(name: string) {
const db = getDb();
const id = ulid();
const slug = name.toLowerCase().replace(/ä/g,'ae').replace(/ö/g,'oe').replace(/ü/g,'ue').replace(/ß/g,'ss').replace(/[^a-z0-9]+/g,'-').replace(/^-|-$/g,'');
const maxOrder = (db.prepare('SELECT MAX(sort_order) as m FROM categories').get() as any).m || 0;
db.prepare('INSERT INTO categories (id, name, slug, sort_order) VALUES (?, ?, ?, ?)').run(id, name, slug, maxOrder + 1);
return { id, name, slug, sort_order: maxOrder + 1 };
}

View File

@@ -0,0 +1,64 @@
import { getDb } from '../db/connection.js';
import { ulid } from 'ulid';
export function listItems() {
const db = getDb();
const items = db.prepare(`
SELECT si.*, r.title as recipe_title
FROM shopping_items si
LEFT JOIN recipes r ON si.recipe_id = r.id
ORDER BY si.checked, si.created_at DESC
`).all() as any[];
const grouped: Record<string, any> = {};
for (const item of items) {
const key = item.recipe_id || '_custom';
if (!grouped[key]) {
grouped[key] = { recipe_id: item.recipe_id, recipe_title: item.recipe_title || 'Eigene Einträge', items: [] };
}
grouped[key].items.push(item);
}
return Object.values(grouped);
}
export function addFromRecipe(recipeId: string) {
const db = getDb();
const recipe = db.prepare('SELECT id FROM recipes WHERE id = ?').get(recipeId);
if (!recipe) return null;
const ingredients = db.prepare('SELECT * FROM ingredients WHERE recipe_id = ?').all(recipeId) as any[];
const insert = db.prepare('INSERT INTO shopping_items (id, name, amount, unit, recipe_id) VALUES (?, ?, ?, ?, ?)');
const added: any[] = [];
const txn = db.transaction(() => {
for (const ing of ingredients) {
const id = ulid();
insert.run(id, ing.name, ing.amount, ing.unit, recipeId);
added.push({ id, name: ing.name, amount: ing.amount, unit: ing.unit, recipe_id: recipeId, checked: 0 });
}
});
txn();
return added;
}
export function addItem(name: string, amount?: number, unit?: string) {
const db = getDb();
const id = ulid();
db.prepare('INSERT INTO shopping_items (id, name, amount, unit) VALUES (?, ?, ?, ?)').run(id, name, amount ?? null, unit ?? null);
return db.prepare('SELECT * FROM shopping_items WHERE id = ?').get(id);
}
export function toggleCheck(id: string) {
const db = getDb();
const item = db.prepare('SELECT * FROM shopping_items WHERE id = ?').get(id) as any;
if (!item) return null;
const newVal = item.checked ? 0 : 1;
db.prepare('UPDATE shopping_items SET checked = ? WHERE id = ?').run(newVal, id);
return { ...item, checked: newVal };
}
export function deleteItem(id: string): boolean {
return getDb().prepare('DELETE FROM shopping_items WHERE id = ?').run(id).changes > 0;
}
export function deleteChecked(): number {
return getDb().prepare('DELETE FROM shopping_items WHERE checked = 1').run().changes;
}

View File

@@ -0,0 +1,26 @@
import { getDb } from '../db/connection.js';
export function listTags() {
return getDb().prepare(`
SELECT t.*, COUNT(rt.recipe_id) as recipe_count
FROM tags t
LEFT JOIN recipe_tags rt ON t.id = rt.tag_id
GROUP BY t.id
ORDER BY t.name
`).all();
}
export function getRecipesByTag(tagName: string) {
const db = getDb();
const tag = db.prepare('SELECT * FROM tags WHERE name = ? OR slug = ?').get(tagName, tagName) as any;
if (!tag) return null;
const recipes = db.prepare(`
SELECT r.*, c.name as category_name
FROM recipes r
JOIN recipe_tags rt ON r.id = rt.recipe_id
LEFT JOIN categories c ON r.category_id = c.id
WHERE rt.tag_id = ?
ORDER BY r.created_at DESC
`).all(tag.id);
return { tag, recipes };
}

18
backend/tsconfig.json Normal file
View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"resolveJsonModule": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

366
features/DATA-MODEL.md Normal file
View File

@@ -0,0 +1,366 @@
# 🗄️ Luna Recipes — Datenmodell
## 1. Entity-Relationship Übersicht
```
┌──────────┐ ┌──────────────┐ ┌──────────┐
│ Category │────<│ Recipe │>────│ Tag │
└──────────┘ └──────┬───────┘ └──────────┘
┌─────────┼──────────┐
│ │ │
┌─────┴──┐ ┌────┴───┐ ┌───┴────┐
│Ingredi-│ │ Step │ │ Note │
│ ent │ │ │ │ │
└────┬───┘ └────────┘ └────────┘
┌────┴────────┐
│ShoppingItem │
└─────────────┘
```
## 2. Entitäten im Detail
### Recipe (Rezept)
| Feld | Typ | Beschreibung |
|---|---|---|
| id | TEXT (ULID) | Primärschlüssel |
| title | TEXT | Rezeptname |
| slug | TEXT | URL-freundlicher Name (unique) |
| description | TEXT | Kurzbeschreibung |
| image_path | TEXT | Pfad zum Hauptbild |
| category_id | TEXT | FK → Category |
| servings | INTEGER | Anzahl Portionen |
| prep_time_min | INTEGER | Vorbereitungszeit in Minuten |
| cook_time_min | INTEGER | Koch-/Backzeit in Minuten |
| total_time_min | INTEGER | Gesamtzeit (computed oder manuell) |
| difficulty | TEXT | 'easy', 'medium', 'hard' |
| source_url | TEXT | Originalquelle (Pinterest-Link etc.) |
| is_favorite | INTEGER | 0/1 Boolean |
| is_draft | INTEGER | 0/1 Entwurf |
| created_at | TEXT | ISO 8601 Timestamp |
| updated_at | TEXT | ISO 8601 Timestamp |
| created_by | TEXT | 'user' oder 'bot' |
### Category (Kategorie)
| Feld | Typ | Beschreibung |
|---|---|---|
| id | TEXT (ULID) | Primärschlüssel |
| name | TEXT | Anzeigename |
| slug | TEXT | URL-freundlich (unique) |
| icon | TEXT | Emoji oder Icon-Name |
| sort_order | INTEGER | Sortierung |
| is_default | INTEGER | Vom System vorgegeben |
**Standard-Kategorien:** Backen, Torten, Frühstück, Mittag, Abend, Snacks, Desserts
### Tag
| Feld | Typ | Beschreibung |
|---|---|---|
| id | TEXT (ULID) | Primärschlüssel |
| name | TEXT | Tag-Name (unique, lowercase) |
### RecipeTag (n:m Verknüpfung)
| Feld | Typ | Beschreibung |
|---|---|---|
| recipe_id | TEXT | FK → Recipe |
| tag_id | TEXT | FK → Tag |
### Ingredient (Zutat)
| Feld | Typ | Beschreibung |
|---|---|---|
| id | TEXT (ULID) | Primärschlüssel |
| recipe_id | TEXT | FK → Recipe |
| amount | REAL | Menge (nullable für "eine Prise") |
| unit | TEXT | Einheit (g, ml, EL, TL, Stück, ...) |
| name | TEXT | Zutatname |
| group_name | TEXT | Optionale Gruppe ("Für den Teig", "Für die Creme") |
| sort_order | INTEGER | Reihenfolge |
### Step (Zubereitungsschritt)
| Feld | Typ | Beschreibung |
|---|---|---|
| id | TEXT (ULID) | Primärschlüssel |
| recipe_id | TEXT | FK → Recipe |
| step_number | INTEGER | Schrittnummer |
| instruction | TEXT | Anweisung |
| image_path | TEXT | Optionales Bild zum Schritt |
| timer_minutes | INTEGER | Timer in Minuten (nullable) |
| timer_label | TEXT | Timer-Beschreibung ("Im Ofen backen") |
### Note (Notiz)
| Feld | Typ | Beschreibung |
|---|---|---|
| id | TEXT (ULID) | Primärschlüssel |
| recipe_id | TEXT | FK → Recipe |
| content | TEXT | Markdown-Text |
| created_at | TEXT | ISO 8601 |
| updated_at | TEXT | ISO 8601 |
### ShoppingItem (Einkaufslisteneintrag)
| Feld | Typ | Beschreibung |
|---|---|---|
| id | TEXT (ULID) | Primärschlüssel |
| ingredient_id | TEXT | FK → Ingredient (nullable) |
| recipe_id | TEXT | FK → Recipe (nullable, für Gruppierung) |
| name | TEXT | Anzeigename |
| amount | REAL | Menge |
| unit | TEXT | Einheit |
| is_checked | INTEGER | 0/1 abgehakt |
| is_custom | INTEGER | 0/1 manuell hinzugefügt |
| sort_order | INTEGER | Sortierung |
| created_at | TEXT | ISO 8601 |
## 3. SQLite Schema
```sql
-- Pragmas
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
-- ============================================================
-- Kategorien
-- ============================================================
CREATE TABLE categories (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
icon TEXT DEFAULT '🍽️',
sort_order INTEGER DEFAULT 0,
is_default INTEGER DEFAULT 0
);
INSERT INTO categories (id, name, slug, icon, sort_order, is_default) VALUES
('cat_backen', 'Backen', 'backen', '🧁', 1, 1),
('cat_torten', 'Torten', 'torten', '🎂', 2, 1),
('cat_fruehstueck','Frühstück', 'fruehstueck','🥐', 3, 1),
('cat_mittag', 'Mittag', 'mittag', '🍝', 4, 1),
('cat_abend', 'Abend', 'abend', '🥘', 5, 1),
('cat_snacks', 'Snacks', 'snacks', '🥨', 6, 1),
('cat_desserts', 'Desserts', 'desserts', '🍮', 7, 1);
-- ============================================================
-- Tags
-- ============================================================
CREATE TABLE tags (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE
);
CREATE INDEX idx_tags_name ON tags(name);
-- ============================================================
-- Rezepte
-- ============================================================
CREATE TABLE recipes (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
description TEXT,
image_path TEXT,
category_id TEXT NOT NULL REFERENCES categories(id),
servings INTEGER DEFAULT 4,
prep_time_min INTEGER,
cook_time_min INTEGER,
total_time_min INTEGER,
difficulty TEXT DEFAULT 'medium' CHECK(difficulty IN ('easy','medium','hard')),
source_url TEXT,
is_favorite INTEGER DEFAULT 0,
is_draft INTEGER DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
created_by TEXT DEFAULT 'user'
);
CREATE INDEX idx_recipes_category ON recipes(category_id);
CREATE INDEX idx_recipes_favorite ON recipes(is_favorite) WHERE is_favorite = 1;
CREATE INDEX idx_recipes_slug ON recipes(slug);
-- Volltextsuche
CREATE VIRTUAL TABLE recipes_fts USING fts5(
title, description, content='recipes', content_rowid='rowid'
);
-- Trigger für FTS-Sync
CREATE TRIGGER recipes_ai AFTER INSERT ON recipes BEGIN
INSERT INTO recipes_fts(rowid, title, description)
VALUES (new.rowid, new.title, new.description);
END;
CREATE TRIGGER recipes_ad AFTER DELETE ON recipes BEGIN
INSERT INTO recipes_fts(recipes_fts, rowid, title, description)
VALUES ('delete', old.rowid, old.title, old.description);
END;
CREATE TRIGGER recipes_au AFTER UPDATE ON recipes BEGIN
INSERT INTO recipes_fts(recipes_fts, rowid, title, description)
VALUES ('delete', old.rowid, old.title, old.description);
INSERT INTO recipes_fts(rowid, title, description)
VALUES (new.rowid, new.title, new.description);
END;
-- Auto-Update updated_at
CREATE TRIGGER recipes_updated AFTER UPDATE ON recipes BEGIN
UPDATE recipes SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ','now')
WHERE id = new.id;
END;
-- ============================================================
-- Recipe-Tags (n:m)
-- ============================================================
CREATE TABLE recipe_tags (
recipe_id TEXT NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (recipe_id, tag_id)
);
CREATE INDEX idx_recipe_tags_tag ON recipe_tags(tag_id);
-- ============================================================
-- Zutaten
-- ============================================================
CREATE TABLE ingredients (
id TEXT PRIMARY KEY,
recipe_id TEXT NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
amount REAL,
unit TEXT,
name TEXT NOT NULL,
group_name TEXT,
sort_order INTEGER DEFAULT 0
);
CREATE INDEX idx_ingredients_recipe ON ingredients(recipe_id);
-- Zutaten-Volltextsuche (für "Suche nach Zutat")
CREATE VIRTUAL TABLE ingredients_fts USING fts5(
name, content='ingredients', content_rowid='rowid'
);
CREATE TRIGGER ingredients_ai AFTER INSERT ON ingredients BEGIN
INSERT INTO ingredients_fts(rowid, name) VALUES (new.rowid, new.name);
END;
CREATE TRIGGER ingredients_ad AFTER DELETE ON ingredients BEGIN
INSERT INTO ingredients_fts(ingredients_fts, rowid, name)
VALUES ('delete', old.rowid, old.name);
END;
-- ============================================================
-- Zubereitungsschritte
-- ============================================================
CREATE TABLE steps (
id TEXT PRIMARY KEY,
recipe_id TEXT NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
step_number INTEGER NOT NULL,
instruction TEXT NOT NULL,
image_path TEXT,
timer_minutes INTEGER,
timer_label TEXT
);
CREATE INDEX idx_steps_recipe ON steps(recipe_id);
-- ============================================================
-- Notizen
-- ============================================================
CREATE TABLE notes (
id TEXT PRIMARY KEY,
recipe_id TEXT NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
content TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
);
CREATE INDEX idx_notes_recipe ON notes(recipe_id);
-- ============================================================
-- Einkaufsliste
-- ============================================================
CREATE TABLE shopping_items (
id TEXT PRIMARY KEY,
ingredient_id TEXT REFERENCES ingredients(id) ON DELETE SET NULL,
recipe_id TEXT REFERENCES recipes(id) ON DELETE SET NULL,
name TEXT NOT NULL,
amount REAL,
unit TEXT,
is_checked INTEGER DEFAULT 0,
is_custom INTEGER DEFAULT 0,
sort_order INTEGER DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
);
CREATE INDEX idx_shopping_recipe ON shopping_items(recipe_id);
CREATE INDEX idx_shopping_checked ON shopping_items(is_checked);
```
## 4. Beispiel-Queries
```sql
-- Alle Rezepte einer Kategorie, neuste zuerst
SELECT r.*, c.name as category_name, c.icon as category_icon
FROM recipes r
JOIN categories c ON r.category_id = c.id
WHERE c.slug = 'torten' AND r.is_draft = 0
ORDER BY r.created_at DESC;
-- Volltextsuche
SELECT r.*
FROM recipes r
JOIN recipes_fts ON recipes_fts.rowid = r.rowid
WHERE recipes_fts MATCH 'schokolade'
ORDER BY rank;
-- Rezepte die eine bestimmte Zutat enthalten
SELECT DISTINCT r.*
FROM recipes r
JOIN ingredients i ON i.recipe_id = r.id
JOIN ingredients_fts ON ingredients_fts.rowid = i.rowid
WHERE ingredients_fts MATCH 'mascarpone';
-- Komplettansicht eines Rezepts
SELECT r.*, c.name as category_name
FROM recipes r
JOIN categories c ON r.category_id = c.id
WHERE r.slug = 'schwarzwaelder-kirschtorte';
-- Zutaten für Einkaufsliste generieren
INSERT INTO shopping_items (id, ingredient_id, recipe_id, name, amount, unit)
SELECT
'shop_' || hex(randomblob(8)),
i.id,
i.recipe_id,
i.name,
i.amount,
i.unit
FROM ingredients i
WHERE i.recipe_id = ?;
-- Einkaufsliste gruppiert nach Rezept
SELECT si.*, r.title as recipe_title
FROM shopping_items si
LEFT JOIN recipes r ON si.recipe_id = r.id
ORDER BY si.is_checked ASC, r.title, si.sort_order;
```
## 5. ID-Strategie
**ULID** (Universally Unique Lexicographically Sortable Identifier) statt Auto-Increment:
- Sortierbar nach Erstellzeitpunkt
- Generierbar auf Client und Server (offline-fähig)
- Keine Kollisionen bei Bot-API + UI gleichzeitig
- Format: `01ARZ3NDEKTSV4RRFFQ69G5FAV` (26 Zeichen)
## 6. Bild-Speicherung
```
/data/images/
recipes/
{recipe_id}/
hero.webp ← Hauptbild (max 1200px breit)
hero_thumb.webp ← Thumbnail (400px)
step_{n}.webp ← Schrittbilder
```
- Format: WebP (beste Kompression für Web)
- Thumbnails automatisch beim Upload generiert
- Bilder werden über statische Route ausgeliefert: `/images/recipes/{id}/hero.webp`

344
features/DESIGN-CONCEPT.md Normal file
View File

@@ -0,0 +1,344 @@
# 🎨 Luna Recipes — Design-Konzept
## 1. Farbschema
Warm, einladend, modern-feminin ohne kitschig zu wirken. Inspiriert von Backzutaten: Vanille, Karamell, Beeren.
| Rolle | Farbe | Hex | Verwendung |
|---|---|---|---|
| **Primary** | Dusty Rose | `#C4737E` | Buttons, aktive States, Akzente |
| **Primary Light** | Soft Blush | `#F2D7DB` | Hover, Badges, Tags |
| **Secondary** | Warm Caramel | `#D4A574` | Sekundäre Buttons, Icons |
| **Background** | Cream White | `#FBF8F5` | Seitenhintergrund |
| **Surface** | Pure White | `#FFFFFF` | Cards, Modals |
| **Text Primary** | Espresso | `#2D2016` | Überschriften, Body |
| **Text Secondary** | Warm Grey | `#7A6E65` | Subtexte, Metadaten |
| **Success** | Sage Green | `#7BAE7F` | Timer fertig, Erfolg |
| **Error** | Berry Red | `#C94C4C` | Fehler, Löschen |
| **Border** | Sand | `#E8E0D8` | Trennlinien, Card-Borders |
### Dark Mode (optional, Phase 2)
- Background: `#1A1614`
- Surface: `#2D2620`
- Text: `#F5EDE8`
## 2. Typografie
| Rolle | Font | Gewicht | Größe |
|---|---|---|---|
| **Logo/Brand** | *Playfair Display* | 700 | 28px |
| **H1** | *Playfair Display* | 600 | 24px |
| **H2** | *Playfair Display* | 600 | 20px |
| **H3** | *Inter* | 600 | 17px |
| **Body** | *Inter* | 400 | 15px |
| **Small/Meta** | *Inter* | 400 | 13px |
| **Kochmodus** | *Inter* | 500 | 2228px |
| **Timer** | *JetBrains Mono* | 500 | 48px |
> Playfair Display für Eleganz in Überschriften, Inter als saubere UI-Schrift. Monospace nur für Timer-Anzeige.
## 3. Screen-Übersicht
```
┌─────────────────────────────────────────────┐
│ App-Struktur │
├─────────────────────────────────────────────┤
│ │
│ 📱 Bottom Navigation: │
│ [Startseite] [Suche] [+Neu] [Liste] [Me] │
│ │
│ Screens: │
│ ├── Startseite (Feed) │
│ ├── Kategorie-Ansicht │
│ ├── Suche + Filter │
│ ├── Rezept-Detail │
│ │ ├── Kochmodus (Fullscreen) │
│ │ └── Notizen-Sheet │
│ ├── Rezept erstellen/bearbeiten │
│ ├── Einkaufsliste │
│ ├── Profil / Einstellungen │
│ └── Onboarding (Erststart) │
│ │
└─────────────────────────────────────────────┘
```
## 4. Component-Liste
### Layout
- `AppShell` — Wrapper mit Bottom-Nav
- `BottomNav` — 5 Icons, aktiver State mit Farbe + Label
- `TopBar` — Kontextuell: Titel, Back-Button, Actions
- `FullscreenOverlay` — Für Kochmodus
### Cards
- `RecipeCard` — Foto (3:4), Titel, Kategorie-Badge, Dauer, Favorit-Herz
- `RecipeCardSmall` — Horizontal, Thumbnail links, Infos rechts
- `CategoryCard` — Rundes Bild + Label darunter
### Rezept-Detail
- `HeroImage` — Parallax-Scroll Foto oben
- `RecipeMeta` — Dauer, Portionen, Schwierigkeit als Icons
- `IngredientList` — Checkbare Zutatenliste mit Mengenrechner
- `StepList` — Nummerierte Schritte mit optionalen Bildern
- `NoteSection` — Eigene Notizen mit Bearbeiten-Button
- `ActionBar` — Kochmodus starten, zur Liste hinzufügen, Teilen
### Kochmodus
- `CookingStep` — Großer Text, Schrittnummer, Swipe-Geste
- `CookingTimer` — Kreisförmiger Countdown, Vibration bei Ende
- `CookingProgress` — Fortschrittsbalken oben
- `CookingNav` — Vor/Zurück-Buttons unten
### Suche & Filter
- `SearchBar` — Prominent, mit Mikrofon-Icon (Phase 2)
- `FilterChips` — Horizontale Scroll-Chips (Kategorie, Zeit, Zutat)
- `FilterSheet` — Bottom-Sheet mit erweiterten Filtern
### Einkaufsliste
- `ShoppingItem` — Checkbox + Zutat + Menge + Rezeptherkunft
- `ShoppingSection` — Gruppiert nach Rezept oder Abteilung
- `AddItemInput` — Schnelles Hinzufügen eigener Einträge
### Formulare
- `RecipeForm` — Multi-Step Formular für Rezepterstellung
- `ImageUpload` — Drag/Drop + Kamera-Auslöser
- `IngredientInput` — Menge + Einheit + Zutat als Inline-Row
- `StepInput` — Textfeld + optionales Bild + Timer-Feld
### Allgemein
- `Button` — Primary, Secondary, Ghost, Danger Varianten
- `Badge` — Kategorie-Tags farblich kodiert
- `Toast` — Erfolgs-/Fehlermeldungen
- `EmptyState` — Illustration + Text wenn Liste leer
- `Skeleton` — Loading-Platzhalter für Cards
## 5. Wireframe-Beschreibungen
### 5.1 Startseite (Feed)
```
┌──────────────────────────┐
│ 🍰 Luna Recipes [👤] │ ← TopBar mit Logo + Profil-Avatar
├──────────────────────────┤
│ ○Backen ○Torten ○Mittag │ ← Horizontale Kategorie-Chips (scrollbar)
│ ○Frühstück ○Snacks ... │
├──────────────────────────┤
│ ┌────────┐ ┌────────┐ │
│ │ 📷 │ │ 📷 │ │ ← Pinterest-artiges Masonry Grid
│ │ │ │ groß │ │ Unterschiedliche Höhen
│ │ Titel │ │ │ │ Tap → Rezept-Detail
│ │ 30min ❤│ │ Titel │ │
│ └────────┘ │ 45min ❤│ │
│ ┌────────┐ └────────┘ │
│ │ 📷 │ ┌────────┐ │
│ │ Titel │ │ 📷 │ │
│ │ 20min ❤│ │ Titel │ │
│ └────────┘ └────────┘ │
├──────────────────────────┤
│ [🏠] [🔍] [] [🛒] [👤] │ ← Bottom Navigation
└──────────────────────────┘
```
- Standard: Alle Rezepte, neuste zuerst
- Kategorie-Chips filtern den Feed inline
- Pull-to-Refresh
- Endlos-Scroll mit Lazy Loading
- Favorit-Herz direkt auf der Card antippbar
### 5.2 Suche + Filter
```
┌──────────────────────────┐
│ [← ] 🔍 Suche... [✕] │ ← Autofokus auf Suchfeld
├──────────────────────────┤
│ Letzte Suchen: │
│ Schokoladentorte Pasta │ ← Antippbare Tags
├──────────────────────────┤
│ [Kategorie ▼] [Zeit ▼] │ ← Filter-Chips
│ [Schwierigkeit ▼] │
├──────────────────────────┤
│ 3 Ergebnisse │
│ ┌────────────────────┐ │
│ │ 🖼 │ Schwarzwälder │ │ ← RecipeCardSmall Layout
│ │ │ Kirschtorte │ │ Horizontal für Suchergebnisse
│ │ │ ⏱90min ⭐Mittel │ │
│ └────────────────────┘ │
│ ┌────────────────────┐ │
│ │ 🖼 │ Schoko-Mousse │ │
│ └────────────────────┘ │
└──────────────────────────┘
```
- Suche durchsucht: Titel, Zutaten, Tags, Notizen
- Debounced (300ms) Live-Ergebnisse
- Filter kombinierbar (AND-Verknüpfung)
- Zeitfilter: <15min, 1530, 3060, >60min
### 5.3 Rezept-Detail
```
┌──────────────────────────┐
│ [←] [❤] [⋯]│ ← Floating über Hero-Bild
│ │
│ ┌──────────────┐ │
│ │ │ │ ← Hero-Image, 16:10
│ │ 📷 Foto │ │ Parallax beim Scrollen
│ │ │ │
│ └──────────────┘ │
├──────────────────────────┤
│ Schwarzwälder Kirschtorte│ ← H1 Playfair Display
│ 🏷 Torten ⏱ 90min │
│ 👤 8 Portionen ⭐ Mittel │
├──────────────────────────┤
│ [🍳 Kochen] [🛒 Liste] │ ← Primary + Secondary Button
├──────────────────────────┤
│ ── Zutaten ──────────────│
│ Portionen: [] 8 [+] │ ← Mengenrechner
│ ☐ 200g Mehl │
│ ☐ 150g Zucker │ ← Antippbar → Einkaufsliste
│ ☐ 5 Eier │
│ ☐ 500ml Sahne │
│ ... │
├──────────────────────────┤
│ ── Zubereitung ──────────│
│ 1. Eier trennen und... │
│ 2. Mehl mit Kakao... │ ← Kompakte Schritt-Liste
│ 3. Backofen auf 180°... │
│ ... │
├──────────────────────────┤
│ ── Meine Notizen ────────│
│ 📝 "Nächstes Mal weniger │ ← Editierbar
│ Zucker, dafür mehr │
│ Kirschen!" │
│ [✏️ Bearbeiten] │
├──────────────────────────┤
│ [🏠] [🔍] [] [🛒] [👤] │
└──────────────────────────┘
```
- Drei-Punkte-Menü (⋯): Bearbeiten, Teilen, Löschen
- Zutaten-Checkbox → einzeln zur Einkaufsliste
- Portionen-Rechner skaliert alle Mengen live
- Notizen: Inline-Edit, Markdown-Unterstützung
### 5.4 Kochmodus (Fullscreen)
```
┌──────────────────────────┐
│ Schritt 3 von 12 [✕] │ ← Progress-Info + Schließen
│ ████████░░░░░░░░░░░░░░░░ │ ← Fortschrittsbalken
├──────────────────────────┤
│ │
│ │
│ Backofen auf 180°C │ ← GROSSE SCHRIFT (2228px)
│ Ober-/Unterhitze │ Zentriert
│ vorheizen. │ Max 3-4 Zeilen
│ │
│ │
│ ┌──────────┐ │
│ │ 05:00 │ │ ← Timer (falls Schritt Timer hat)
│ │ ▶ Start │ │ Kreisförmig
│ └──────────┘ │
│ │
├──────────────────────────┤
│ [← Zurück] [Weiter →] │ ← Navigation + Swipe-Geste
└──────────────────────────┘
```
- Bildschirm bleibt an (Wake Lock API)
- Swipe links/rechts für Navigation
- Timer: Antippen startet, Vibration + Sound bei 0:00
- Mehrere Timer gleichzeitig möglich (Badge-Anzeige)
- Schließen fragt "Wirklich beenden?"
### 5.5 Einkaufsliste
```
┌──────────────────────────┐
│ Einkaufsliste [🗑 ✓] │ ← Erledigte löschen
├──────────────────────────┤
│ [+ Eigenen Artikel ...] │ ← Quick-Add Input
├──────────────────────────┤
│ 🍰 Schwarzwälder Kirsch. │ ← Gruppiert nach Rezept
│ ☐ 200g Mehl │
│ ☑ 150g Zucker │ ← Durchgestrichen
│ ☐ 5 Eier │
│ ☐ 500ml Sahne │
├──────────────────────────┤
│ 🥗 Caesar Salad │
│ ☐ 1 Römersalat │
│ ☐ Parmesan │
├──────────────────────────┤
│ 📝 Eigene │
│ ☐ Küchenrolle │
├──────────────────────────┤
│ [🏠] [🔍] [] [🛒] [👤] │
└──────────────────────────┘
```
- Gleiche Zutaten aus verschiedenen Rezepten werden zusammengefasst (Phase 2)
- Wisch nach links → Artikel entfernen
- Sortierbar: nach Rezept oder nach Abteilung
- Offline verfügbar (PWA!)
### 5.6 Rezept erstellen/bearbeiten
```
┌──────────────────────────┐
│ [✕] Neues Rezept [Save] │
├──────────────────────────┤
│ Schritt 1 von 4 │
│ ●───○───○───○ │ ← Stepper
├──────────────────────────┤
│ │
│ [📷 Foto hinzufügen] │ ← Kamera oder Galerie
│ │
│ Titel * │
│ [________________________]│
│ │
│ Kategorie * │
│ [Backen ▼] │
│ │
│ Tags │
│ [Schoko] [Torte] [+ ...]│
│ │
│ Portionen Dauer │
│ [_8_] [_90_] min │
│ │
│ Schwierigkeit │
│ ○ Einfach ● Mittel ○ Pro│
│ │
│ [Weiter →] │
└──────────────────────────┘
```
- 4 Schritte: Basis → Zutaten → Zubereitung → Vorschau
- Auto-Save als Entwurf
- Zutaten: dynamische Rows (Menge + Einheit + Name)
- Zubereitung: Schritte mit optionalem Timer pro Schritt
### 5.7 Profil / Einstellungen
- Anzahl Rezepte, Favoriten
- Kategorien verwalten (eigene hinzufügen)
- Einheiten-Präferenz (metrisch/imperial)
- Dark Mode Toggle
- Daten exportieren/importieren
- App-Version & Info
## 6. Design-Prinzipien
1. **Fotos im Fokus** — Große, appetitliche Bilder treiben die Navigation
2. **One-Thumb-Use** — Alle wichtigen Aktionen im Daumenbereich
3. **Kochmodus = Stressfrei** — Große Schrift, einfache Navigation, keine Ablenkung
4. **Schnelles Wiederfinden** — Suche, Kategorien, Favoriten auf max 2 Taps
5. **Offline First** — Einkaufsliste und gespeicherte Rezepte immer verfügbar
## 7. Animationen & Micro-Interactions
- Card-Tap: Sanftes Scale-Up (0.98 → 1.0)
- Favorit-Herz: Kurze Bounce-Animation + Farbwechsel
- Kochmodus-Swipe: Slide-Transition zwischen Schritten
- Timer-Ende: Pulsieren + Vibration
- Pull-to-Refresh: Custom Cupcake-Animation (Phase 2)
- Page-Transitions: Shared Element Transition für Hero-Image (Card → Detail)

View File

@@ -0,0 +1,27 @@
# Lessons from WERK — Was wir bei der Rezept-App NICHT wiederholen
## Architektur
- **Kein Monorepo** — Eine App, ein Repo, fertig
- **Kein shared-stubs Chaos** — Kein Packages-Ordner, alles in einem Projekt
- **SQLite statt PostgreSQL** — Kein Docker für DB nötig, einfacher
- **Kein SSR** — Reines SPA/PWA, kein hydrateRoot-Desaster
## Frontend
- **Relative API-URLs** — Kein hardcoded localhost in .env
- **Keine Radix Dialog Forms** — Forms als eigene Seiten/Routes
- **Mobile-first testen** — Von Anfang an, nicht nachträglich
## Backend
- **Fastify JWT richtig** — `register(jwt); await app.after();`
- **NODE_ENV=production** in Deployment, immer
- **Kein pino-pretty** in Production
## Deployment
- **Docker simpel** — Ein Container, ein Service
- **Keine Caddy URI-Rewrites** — Einfaches Routing
- **CORS von Anfang an richtig** — Mit Port testen
## Vorgehen
- **Inkrementell** — Erst lauffähiges Minimum, dann Features
- **Jeden Schritt testen** — Nicht 5 Features auf einmal, einzeln bauen + verifizieren
- **Agent-Tasks klein halten** — Ein Task = eine Sache

418
features/TECH-STACK.md Normal file
View File

@@ -0,0 +1,418 @@
# ⚙️ Luna Recipes — Tech-Stack
## 1. Übersicht
```
┌─────────────────────────────────────────┐
│ Frontend (PWA) │
│ React 19 + Vite + TailwindCSS v4 │
│ React Router v7 · Tanstack Query v5 │
├─────────────────────────────────────────┤
│ Backend (API) │
│ Fastify v5 + better-sqlite3 │
│ Bild-Upload: @fastify/multipart │
├─────────────────────────────────────────┤
│ Datenbank │
│ SQLite (WAL-Mode) + FTS5 │
└─────────────────────────────────────────┘
```
## 2. Frontend
### Core
| Paket | Version | Zweck |
|---|---|---|
| `react` | ^19.0 | UI-Framework |
| `react-dom` | ^19.0 | DOM-Rendering |
| `vite` | ^6.x | Build-Tool, Dev-Server, HMR |
| `tailwindcss` | ^4.x | Utility-first CSS |
| `react-router` | ^7.x | Client-Side Routing |
| `@tanstack/react-query` | ^5.x | Server-State, Caching, Mutations |
### UI & Interaktion
| Paket | Zweck |
|---|---|
| `react-masonry-css` | Pinterest-artiges Grid-Layout |
| `framer-motion` | Animationen, Page-Transitions, Swipe-Gesten |
| `react-hot-toast` | Toast-Benachrichtigungen |
| `lucide-react` | Icon-Set (sauber, konsistent) |
| `react-hook-form` + `zod` | Formulare + Validierung |
### PWA
| Paket | Zweck |
|---|---|
| `vite-plugin-pwa` | Service Worker Generation, Manifest |
| `workbox` (via Plugin) | Caching-Strategien, Offline-Support |
### Projektstruktur
```
frontend/
├── public/
│ ├── icons/ ← PWA Icons (192, 512px)
│ └── manifest.webmanifest
├── src/
│ ├── main.tsx
│ ├── App.tsx
│ ├── api/ ← API-Client (fetch-Wrapper)
│ │ ├── client.ts
│ │ ├── recipes.ts
│ │ ├── shopping.ts
│ │ └── types.ts
│ ├── components/
│ │ ├── layout/ ← AppShell, BottomNav, TopBar
│ │ ├── recipe/ ← RecipeCard, IngredientList, StepList
│ │ ├── cooking/ ← CookingStep, CookingTimer
│ │ ├── shopping/ ← ShoppingItem, ShoppingSection
│ │ ├── forms/ ← RecipeForm, ImageUpload
│ │ └── ui/ ← Button, Badge, Input, Skeleton
│ ├── pages/
│ │ ├── HomePage.tsx
│ │ ├── SearchPage.tsx
│ │ ├── RecipePage.tsx
│ │ ├── CookingModePage.tsx
│ │ ├── ShoppingPage.tsx
│ │ ├── CreateRecipePage.tsx
│ │ └── SettingsPage.tsx
│ ├── hooks/ ← useRecipes, useTimer, useWakeLock
│ ├── lib/ ← Hilfsfunktionen, Konstanten
│ └── styles/
│ └── globals.css ← Tailwind-Imports, Custom Properties
├── index.html
├── tailwind.config.ts
├── vite.config.ts
├── tsconfig.json
└── package.json
```
### PWA-Konfiguration (vite.config.ts)
```typescript
import { VitePWA } from 'vite-plugin-pwa';
export default defineConfig({
plugins: [
react(),
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['icons/*.png'],
manifest: {
name: 'Luna Recipes',
short_name: 'Luna',
description: 'Deine persönliche Rezeptsammlung',
theme_color: '#C4737E',
background_color: '#FBF8F5',
display: 'standalone',
orientation: 'portrait',
start_url: '/',
icons: [
{ src: 'icons/icon-192.png', sizes: '192x192', type: 'image/png' },
{ src: 'icons/icon-512.png', sizes: '512x512', type: 'image/png' },
{ src: 'icons/icon-512-maskable.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
],
},
workbox: {
runtimeCaching: [
{
urlPattern: /\/api\/recipes/,
handler: 'StaleWhileRevalidate',
options: { cacheName: 'recipes-cache', expiration: { maxEntries: 200 } },
},
{
urlPattern: /\/images\//,
handler: 'CacheFirst',
options: { cacheName: 'image-cache', expiration: { maxEntries: 500, maxAgeSeconds: 30 * 24 * 60 * 60 } },
},
],
},
}),
],
});
```
### Tailwind Theme (tailwind.config.ts)
```typescript
export default {
theme: {
extend: {
colors: {
primary: { DEFAULT: '#C4737E', light: '#F2D7DB' },
secondary: '#D4A574',
cream: '#FBF8F5',
espresso: '#2D2016',
'warm-grey': '#7A6E65',
sage: '#7BAE7F',
berry: '#C94C4C',
sand: '#E8E0D8',
},
fontFamily: {
display: ['Playfair Display', 'serif'],
sans: ['Inter', 'system-ui', 'sans-serif'],
mono: ['JetBrains Mono', 'monospace'],
},
},
},
};
```
## 3. Backend
### Core
| Paket | Zweck |
|---|---|
| `fastify` v5 | HTTP-Framework (schnell, Schema-basiert) |
| `better-sqlite3` | SQLite-Treiber (synchron, schnell) |
| `@fastify/multipart` | Bild-Upload |
| `@fastify/static` | Statische Dateien (Bilder) |
| `@fastify/cors` | CORS für Frontend |
| `@fastify/rate-limit` | Rate-Limiting (Bot-API Schutz) |
| `sharp` | Bild-Verarbeitung (Resize, WebP) |
| `ulid` | ID-Generierung |
| `zod` | Request-Validierung |
### Projektstruktur
```
backend/
├── src/
│ ├── index.ts ← Server-Start
│ ├── app.ts ← Fastify-App Setup
│ ├── db/
│ │ ├── connection.ts ← SQLite-Verbindung
│ │ ├── migrate.ts ← Schema-Migrationen
│ │ └── migrations/ ← SQL-Dateien
│ ├── routes/
│ │ ├── recipes.ts ← CRUD + Suche
│ │ ├── categories.ts ← Kategorien
│ │ ├── shopping.ts ← Einkaufsliste
│ │ ├── notes.ts ← Notizen
│ │ ├── images.ts ← Bild-Upload
│ │ └── bot.ts ← Bot-API (Token-Auth)
│ ├── services/
│ │ ├── recipe.service.ts
│ │ ├── shopping.service.ts
│ │ └── image.service.ts
│ ├── schemas/ ← Zod-Schemas
│ └── lib/ ← Hilfsfunktionen
├── data/
│ ├── luna.db ← SQLite-Datenbank
│ └── images/ ← Hochgeladene Bilder
├── tsconfig.json
└── package.json
```
### Authentifizierung
Einfaches Token-System (Single-User App):
```
# .env
BOT_API_TOKEN=geheimer-bot-token-hier
ADMIN_TOKEN=optionaler-admin-token
```
- **Frontend:** Kein Auth nötig (lokale Nutzung / privat)
- **Bot-API:** Bearer-Token im Header: `Authorization: Bearer {BOT_API_TOKEN}`
- **Optional Phase 2:** Session-basierte Auth falls öffentlich gehostet
## 4. API-Endpunkte
### Rezepte
| Methode | Pfad | Beschreibung |
|---|---|---|
| `GET` | `/api/recipes` | Alle Rezepte (paginiert, filterbar) |
| `GET` | `/api/recipes/:slug` | Einzelnes Rezept (komplett mit Zutaten, Steps, Notes) |
| `POST` | `/api/recipes` | Neues Rezept erstellen |
| `PUT` | `/api/recipes/:id` | Rezept aktualisieren |
| `DELETE` | `/api/recipes/:id` | Rezept löschen |
| `PATCH` | `/api/recipes/:id/favorite` | Favorit togglen |
| `GET` | `/api/recipes/search` | Volltextsuche |
#### Query-Parameter für `GET /api/recipes`
| Parameter | Typ | Beschreibung |
|---|---|---|
| `category` | string | Filter nach Kategorie-Slug |
| `favorite` | boolean | Nur Favoriten |
| `difficulty` | string | easy/medium/hard |
| `maxTime` | number | Maximale Gesamtzeit in Minuten |
| `ingredient` | string | Enthält Zutat (Textsuche) |
| `page` | number | Seite (default: 1) |
| `limit` | number | Einträge pro Seite (default: 20, max: 50) |
| `sort` | string | `newest`, `oldest`, `title`, `time` |
#### Request-Body `POST /api/recipes`
```json
{
"title": "Schwarzwälder Kirschtorte",
"description": "Klassiker der deutschen Backkunst",
"category_id": "cat_torten",
"servings": 12,
"prep_time_min": 45,
"cook_time_min": 35,
"difficulty": "medium",
"source_url": "https://pinterest.com/...",
"tags": ["schokolade", "kirschen", "sahne"],
"ingredients": [
{ "amount": 200, "unit": "g", "name": "Mehl", "group_name": "Teig" },
{ "amount": 150, "unit": "g", "name": "Zucker", "group_name": "Teig" },
{ "amount": 500, "unit": "ml", "name": "Sahne", "group_name": "Füllung" }
],
"steps": [
{ "instruction": "Eier trennen und Eiweiß steif schlagen.", "timer_minutes": null },
{ "instruction": "Teig in Springform füllen und backen.", "timer_minutes": 35, "timer_label": "Backen" }
]
}
```
### Kategorien
| Methode | Pfad | Beschreibung |
|---|---|---|
| `GET` | `/api/categories` | Alle Kategorien |
| `POST` | `/api/categories` | Neue Kategorie (custom) |
### Notizen
| Methode | Pfad | Beschreibung |
|---|---|---|
| `GET` | `/api/recipes/:id/notes` | Notizen eines Rezepts |
| `POST` | `/api/recipes/:id/notes` | Notiz hinzufügen |
| `PUT` | `/api/notes/:id` | Notiz bearbeiten |
| `DELETE` | `/api/notes/:id` | Notiz löschen |
### Einkaufsliste
| Methode | Pfad | Beschreibung |
|---|---|---|
| `GET` | `/api/shopping` | Aktuelle Einkaufsliste |
| `POST` | `/api/shopping/from-recipe/:id` | Zutaten eines Rezepts hinzufügen |
| `POST` | `/api/shopping` | Eigenen Eintrag hinzufügen |
| `PATCH` | `/api/shopping/:id/check` | Abhaken/Enthaken |
| `DELETE` | `/api/shopping/:id` | Eintrag entfernen |
| `DELETE` | `/api/shopping/checked` | Alle abgehakten entfernen |
### Bilder
| Methode | Pfad | Beschreibung |
|---|---|---|
| `POST` | `/api/recipes/:id/image` | Hauptbild hochladen (multipart) |
| `POST` | `/api/recipes/:id/steps/:n/image` | Schrittbild hochladen |
### Bot-API
Alle Bot-Endpunkte erfordern `Authorization: Bearer {token}`.
| Methode | Pfad | Beschreibung |
|---|---|---|
| `POST` | `/api/bot/recipes` | Rezept per Bot erstellen (wie POST /api/recipes) |
| `POST` | `/api/bot/recipes/:id/image` | Bild per URL importieren |
| `GET` | `/api/bot/recipes` | Rezepte auflisten (für Bot-Abfragen) |
#### Bot-spezifisches Feld
```json
{
"image_url": "https://example.com/foto.jpg",
"...normaler recipe body..."
}
```
Das Backend lädt das Bild herunter, konvertiert zu WebP, erstellt Thumbnail.
### Tags
| Methode | Pfad | Beschreibung |
|---|---|---|
| `GET` | `/api/tags` | Alle Tags (mit Anzahl Rezepte) |
| `GET` | `/api/tags/:name/recipes` | Rezepte eines Tags |
## 5. Deployment
### Entwicklung
```bash
# Backend
cd backend && npm install && npm run dev # Port 3000
# Frontend
cd frontend && npm install && npm run dev # Port 5173 (Proxy → 3000)
```
### Produktion
```bash
# Frontend bauen
cd frontend && npm run build # → dist/
# Backend serviert Frontend-Build als Static Files
# Alles als ein Prozess:
cd backend && NODE_ENV=production npm start
```
### Docker (empfohlen)
```dockerfile
FROM node:22-alpine
WORKDIR /app
# Backend Dependencies
COPY backend/package*.json backend/
RUN cd backend && npm ci --production
# Frontend Build
COPY frontend/ frontend/
RUN cd frontend && npm ci && npm run build
# Backend Source
COPY backend/ backend/
# Daten-Verzeichnis
VOLUME /app/data
ENV NODE_ENV=production
ENV DATABASE_PATH=/app/data/luna.db
ENV IMAGES_PATH=/app/data/images
EXPOSE 3000
CMD ["node", "backend/dist/index.js"]
```
```yaml
# docker-compose.yml
services:
luna:
build: .
ports:
- "3000:3000"
volumes:
- ./data:/app/data
environment:
- BOT_API_TOKEN=${BOT_API_TOKEN}
restart: unless-stopped
```
## 6. Performance-Überlegungen
- **SQLite WAL-Mode** → Paralleles Lesen während Schreiben
- **FTS5** → Schnelle Volltextsuche ohne externen Service
- **Bild-Thumbnails** → Kleine Bilder für Card-Grid, große nur bei Detail
- **Stale-While-Revalidate** → Instant UI, Background-Refresh
- **CacheFirst für Bilder** → Bilder ändern sich selten
- **Lazy Loading** → Bilder + Seiten on-demand laden
- **Bundle-Splitting** → Kochmodus als separater Chunk
## 7. Offline-Strategie (PWA)
| Ressource | Strategie | Cache |
|---|---|---|
| App-Shell (HTML/JS/CSS) | Precache | Beim Build |
| API-Responses | StaleWhileRevalidate | Runtime |
| Bilder | CacheFirst | Runtime, max 500 |
| Einkaufsliste | NetworkFirst | Runtime |
Die Einkaufsliste bekommt NetworkFirst, damit im Laden immer der aktuellste Stand gezeigt wird, aber offline trotzdem funktioniert.

24
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

73
frontend/README.md Normal file
View File

@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

23
frontend/eslint.config.js Normal file
View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#C4737E" />
<title>Luna Recipes</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

8320
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

39
frontend/package.json Normal file
View File

@@ -0,0 +1,39 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@tanstack/react-query": "^5.90.21",
"framer-motion": "^12.34.1",
"lucide-react": "^0.574.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-hot-toast": "^2.6.0",
"react-masonry-css": "^1.0.16",
"react-router": "^7.13.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@tailwindcss/vite": "^4.1.18",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"tailwindcss": "^4.1.18",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
"vite": "^7.3.1",
"vite-plugin-pwa": "^1.2.0"
}
}

26
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,26 @@
import { BrowserRouter, Routes, Route } from 'react-router'
import { AppShell } from './components/layout/AppShell'
import { HomePage } from './pages/HomePage'
import { RecipePage } from './pages/RecipePage'
import { SearchPage } from './pages/SearchPage'
import { PlaceholderPage } from './pages/PlaceholderPage'
import { RecipeFormPage } from './pages/RecipeFormPage'
import { ShoppingPage } from './pages/ShoppingPage'
export default function App() {
return (
<BrowserRouter>
<Routes>
<Route element={<AppShell />}>
<Route index element={<HomePage />} />
<Route path="recipe/:slug" element={<RecipePage />} />
<Route path="search" element={<SearchPage />} />
<Route path="new" element={<RecipeFormPage />} />
<Route path="recipe/:slug/edit" element={<RecipeFormPage />} />
<Route path="shopping" element={<ShoppingPage />} />
<Route path="profile" element={<PlaceholderPage title="Profil" icon="👤" />} />
</Route>
</Routes>
</BrowserRouter>
)
}

View File

@@ -0,0 +1,6 @@
import { apiFetch } from './client'
import type { Category } from './types'
export function fetchCategories() {
return apiFetch<Category[]>('/categories')
}

View File

@@ -0,0 +1,17 @@
const BASE_URL = '/api'
export async function apiFetch<T>(path: string, options?: RequestInit): Promise<T> {
const method = options?.method?.toUpperCase() || 'GET';
const headers: Record<string, string> = { ...options?.headers as Record<string, string> };
if (['POST', 'PUT', 'PATCH'].includes(method)) {
headers['Content-Type'] = headers['Content-Type'] || 'application/json';
}
const res = await fetch(`${BASE_URL}${path}`, {
...options,
headers,
})
if (!res.ok) {
throw new Error(`API Error: ${res.status} ${res.statusText}`)
}
return res.json()
}

View File

@@ -0,0 +1,74 @@
import { apiFetch } from './client'
import type { Recipe, PaginatedResponse } from './types'
interface RecipeListParams {
category?: string
favorite?: boolean
sort?: string
page?: number
limit?: number
}
export function fetchRecipes(params?: RecipeListParams) {
const sp = new URLSearchParams()
if (params?.category) sp.set('category', params.category)
if (params?.favorite) sp.set('favorite', 'true')
if (params?.sort) sp.set('sort', params.sort)
if (params?.page) sp.set('page', String(params.page))
if (params?.limit) sp.set('limit', String(params.limit))
const qs = sp.toString()
return apiFetch<PaginatedResponse<Recipe>>(`/recipes${qs ? `?${qs}` : ''}`)
}
export function fetchRecipe(slug: string) {
return apiFetch<Recipe>(`/recipes/${slug}`)
}
export function searchRecipes(q: string) {
return apiFetch<PaginatedResponse<Recipe>>(`/recipes/search?q=${encodeURIComponent(q)}`)
}
export function toggleFavorite(id: string) {
return apiFetch<Recipe>(`/recipes/${id}/favorite`, { method: 'PATCH' })
}
export interface RecipeFormData {
title: string
description?: string
category_id?: string
difficulty?: 'easy' | 'medium' | 'hard'
prep_time?: number
cook_time?: number
servings?: number
image_url?: string
source_url?: string
ingredients?: { amount?: number; unit?: string; name: string; group_name?: string; sort_order?: number }[]
steps?: { step_number: number; instruction: string; duration_minutes?: number }[]
}
export function createRecipe(data: RecipeFormData) {
return apiFetch<Recipe>('/recipes', {
method: 'POST',
body: JSON.stringify(data),
})
}
export function updateRecipe(id: string, data: RecipeFormData) {
return apiFetch<Recipe>(`/recipes/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
})
}
export function deleteRecipe(id: string) {
return apiFetch<{ ok: boolean }>(`/recipes/${id}`, { method: 'DELETE' })
}
export function uploadRecipeImage(id: string, file: File) {
const formData = new FormData()
formData.append('file', file)
return fetch(`/api/recipes/${id}/image`, { method: 'POST', body: formData }).then(r => {
if (!r.ok) throw new Error('Upload failed')
return r.json() as Promise<{ image_url: string }>
})
}

View File

@@ -0,0 +1,45 @@
import { apiFetch } from './client'
export interface ShoppingItem {
id: string
name: string
amount?: number
unit?: string
checked: boolean
recipe_id?: string
recipe_title?: string
created_at?: string
}
export interface ShoppingGroup {
recipe_title: string
recipe_id?: string
items: ShoppingItem[]
}
export function fetchShopping() {
return apiFetch<ShoppingGroup[]>('/shopping')
}
export function addFromRecipe(recipeId: string) {
return apiFetch<{ added: number }>(`/shopping/from-recipe/${recipeId}`, { method: 'POST' })
}
export function addCustomItem(item: { name: string; amount?: number; unit?: string }) {
return apiFetch<ShoppingItem>('/shopping', {
method: 'POST',
body: JSON.stringify(item),
})
}
export function toggleCheck(id: string) {
return apiFetch<ShoppingItem>(`/shopping/${id}/check`, { method: 'PATCH' })
}
export function deleteItem(id: string) {
return apiFetch<void>(`/shopping/${id}`, { method: 'DELETE' })
}
export function deleteChecked() {
return apiFetch<void>('/shopping/checked', { method: 'DELETE' })
}

62
frontend/src/api/types.ts Normal file
View File

@@ -0,0 +1,62 @@
export interface Category {
id: string
name: string
slug: string
icon?: string
recipe_count?: number
}
export interface Recipe {
id: string
title: string
slug: string
description?: string
category_id?: string
category_name?: string
category_slug?: string
servings?: number
prep_time_min?: number
cook_time_min?: number
total_time_min?: number
difficulty?: 'easy' | 'medium' | 'hard'
image_url?: string
thumbnail_url?: string
is_favorite: boolean
tags?: string[]
ingredients?: Ingredient[]
steps?: Step[]
notes?: Note[]
created_at?: string
updated_at?: string
}
export interface Ingredient {
id?: string
amount?: number
unit?: string
name: string
group_name?: string
}
export interface Step {
id?: string
step_number: number
instruction: string
timer_minutes?: number
timer_label?: string
image_url?: string
}
export interface Note {
id: string
content: string
created_at: string
}
export interface PaginatedResponse<T> {
data: T[]
total: number
page: number
limit: number
totalPages: number
}

View File

@@ -0,0 +1,45 @@
import { Component, type ReactNode } from 'react'
interface Props {
children: ReactNode
}
interface State {
hasError: boolean
error?: Error
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props)
this.state = { hasError: false }
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error }
}
render() {
if (this.state.hasError) {
return (
<div className="min-h-screen flex items-center justify-center p-4 bg-cream">
<div className="text-center space-y-4">
<span className="text-5xl">😵</span>
<h1 className="font-display text-xl text-espresso">Etwas ist schiefgelaufen</h1>
<p className="text-warm-grey text-sm">{this.state.error?.message}</p>
<button
onClick={() => {
this.setState({ hasError: false })
window.location.href = '/'
}}
className="bg-primary text-white px-6 py-3 rounded-xl text-sm font-medium min-h-[44px]"
>
Zurück zur Startseite
</button>
</div>
</div>
)
}
return this.props.children
}
}

View File

@@ -0,0 +1,13 @@
import { Outlet } from 'react-router'
import { BottomNav } from './BottomNav'
export function AppShell() {
return (
<div className="min-h-screen bg-cream">
<div className="max-w-lg mx-auto pb-20">
<Outlet />
</div>
<BottomNav />
</div>
)
}

View File

@@ -0,0 +1,33 @@
import { NavLink } from 'react-router'
import { Home, Search, PlusCircle, ShoppingCart, User } from 'lucide-react'
const navItems = [
{ to: '/', icon: Home, label: 'Home' },
{ to: '/search', icon: Search, label: 'Suche' },
{ to: '/new', icon: PlusCircle, label: 'Neu' },
{ to: '/shopping', icon: ShoppingCart, label: 'Einkauf' },
{ to: '/profile', icon: User, label: 'Profil' },
]
export function BottomNav() {
return (
<nav className="fixed bottom-0 left-0 right-0 bg-surface border-t border-sand z-50">
<div className="max-w-lg mx-auto flex justify-around items-center h-16">
{navItems.map(({ to, icon: Icon, label }) => (
<NavLink
key={to}
to={to}
className={({ isActive }) =>
`flex flex-col items-center gap-0.5 text-xs transition-colors ${
isActive ? 'text-primary' : 'text-warm-grey'
}`
}
>
<Icon size={22} />
<span>{label}</span>
</NavLink>
))}
</div>
</nav>
)
}

View File

@@ -0,0 +1,58 @@
import { Link } from 'react-router'
import { Heart, Clock } from 'lucide-react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toggleFavorite } from '../../api/recipes'
import type { Recipe } from '../../api/types'
const gradients = [
'from-primary/60 to-secondary/60',
'from-secondary/60 to-sage/60',
'from-primary-light to-primary/40',
'from-sage/40 to-secondary/60',
]
export function RecipeCard({ recipe }: { recipe: Recipe }) {
const qc = useQueryClient()
const favMutation = useMutation({
mutationFn: () => toggleFavorite(recipe.id),
onSuccess: () => qc.invalidateQueries({ queryKey: ['recipes'] }),
})
const gradient = gradients[recipe.title.length % gradients.length]
const totalTime = recipe.total_time_min || ((recipe.prep_time_min || 0) + (recipe.cook_time_min || 0))
return (
<div className="bg-surface rounded-2xl overflow-hidden shadow-sm break-inside-avoid mb-4">
<Link to={`/recipe/${recipe.slug}`}>
{recipe.image_url ? (
<img src={recipe.image_url} alt={recipe.title} className="w-full h-auto object-cover" loading="lazy" />
) : (
<div className={`w-full aspect-[3/4] bg-gradient-to-br ${gradient} flex items-center justify-center`}>
<span className="text-4xl">🍰</span>
</div>
)}
</Link>
<div className="p-3">
<Link to={`/recipe/${recipe.slug}`}>
<h3 className="font-display text-base text-espresso line-clamp-2">{recipe.title}</h3>
</Link>
<div className="flex items-center justify-between mt-2">
{totalTime > 0 && (
<span className="flex items-center gap-1 text-warm-grey text-xs">
<Clock size={14} /> {totalTime} min
</span>
)}
<button
onClick={(e) => { e.preventDefault(); favMutation.mutate() }}
className="ml-auto"
>
<Heart
size={20}
className={recipe.is_favorite ? 'fill-primary text-primary' : 'text-warm-grey'}
/>
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,34 @@
import { Link } from 'react-router'
import { Clock } from 'lucide-react'
import type { Recipe } from '../../api/types'
const gradients = [
'from-primary/60 to-secondary/60',
'from-secondary/60 to-sage/60',
]
export function RecipeCardSmall({ recipe }: { recipe: Recipe }) {
const gradient = gradients[recipe.title.length % gradients.length]
const totalTime = recipe.total_time_min || ((recipe.prep_time_min || 0) + (recipe.cook_time_min || 0))
return (
<Link to={`/recipe/${recipe.slug}`} className="flex bg-surface rounded-2xl overflow-hidden shadow-sm">
{recipe.image_url ? (
<img src={recipe.image_url} alt={recipe.title} className="w-24 h-24 object-cover flex-shrink-0" />
) : (
<div className={`w-24 h-24 flex-shrink-0 bg-gradient-to-br ${gradient} flex items-center justify-center`}>
<span className="text-2xl">🍰</span>
</div>
)}
<div className="p-3 flex flex-col justify-center min-w-0">
<h3 className="font-display text-sm text-espresso line-clamp-2">{recipe.title}</h3>
<div className="flex items-center gap-2 mt-1 text-warm-grey text-xs">
{totalTime > 0 && (
<span className="flex items-center gap-1"><Clock size={12} /> {totalTime} min</span>
)}
{recipe.difficulty && <span className="capitalize"> {recipe.difficulty}</span>}
</div>
</div>
</Link>
)
}

View File

@@ -0,0 +1,20 @@
interface Props {
children: React.ReactNode
active?: boolean
onClick?: () => void
}
export function Badge({ children, active, onClick }: Props) {
return (
<span
onClick={onClick}
className={`inline-block px-3 py-1 rounded-full text-sm font-medium cursor-pointer transition-colors whitespace-nowrap ${
active
? 'bg-primary text-white'
: 'bg-primary-light text-primary'
}`}
>
{children}
</span>
)
}

View File

@@ -0,0 +1,22 @@
import type { ButtonHTMLAttributes } from 'react'
type Variant = 'primary' | 'secondary' | 'ghost'
const styles: Record<Variant, string> = {
primary: 'bg-primary text-white hover:bg-primary/90',
secondary: 'bg-secondary text-white hover:bg-secondary/90',
ghost: 'bg-transparent text-espresso hover:bg-sand/50',
}
interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: Variant
}
export function Button({ variant = 'primary', className = '', ...props }: Props) {
return (
<button
className={`px-4 py-2 rounded-xl font-medium transition-colors ${styles[variant]} ${className}`}
{...props}
/>
)
}

View File

@@ -0,0 +1,15 @@
interface Props {
icon?: string
title: string
description?: string
}
export function EmptyState({ icon = '🍰', title, description }: Props) {
return (
<div className="flex flex-col items-center justify-center py-16 text-center">
<span className="text-5xl mb-4">{icon}</span>
<h3 className="font-display text-xl text-espresso mb-2">{title}</h3>
{description && <p className="text-warm-grey text-sm">{description}</p>}
</div>
)
}

View File

@@ -0,0 +1,15 @@
export function Skeleton({ className = '' }: { className?: string }) {
return <div className={`animate-pulse bg-sand/50 rounded-xl ${className}`} />
}
export function RecipeCardSkeleton() {
return (
<div className="bg-surface rounded-2xl overflow-hidden shadow-sm">
<Skeleton className="w-full h-48 rounded-none" />
<div className="p-3 space-y-2">
<Skeleton className="h-5 w-3/4" />
<Skeleton className="h-4 w-1/2" />
</div>
</div>
)
}

24
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,24 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { Toaster } from 'react-hot-toast'
import { ErrorBoundary } from './components/ErrorBoundary'
import App from './App'
import './styles/globals.css'
const queryClient = new QueryClient({
defaultOptions: {
queries: { staleTime: 1000 * 60 * 5, retry: 1 },
},
})
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<ErrorBoundary>
<App />
</ErrorBoundary>
<Toaster position="top-center" toastOptions={{ duration: 2000 }} />
</QueryClientProvider>
</StrictMode>,
)

View File

@@ -0,0 +1,71 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import Masonry from 'react-masonry-css'
import { fetchRecipes } from '../api/recipes'
import { fetchCategories } from '../api/categories'
import { RecipeCard } from '../components/recipe/RecipeCard'
import type { Recipe } from '../api/types'
import { Badge } from '../components/ui/Badge'
import { RecipeCardSkeleton } from '../components/ui/Skeleton'
import { EmptyState } from '../components/ui/EmptyState'
export function HomePage() {
const [activeCategory, setActiveCategory] = useState<string | undefined>()
const { data: categories } = useQuery({
queryKey: ['categories'],
queryFn: fetchCategories,
})
const { data: recipesData, isLoading } = useQuery({
queryKey: ['recipes', { category: activeCategory }],
queryFn: () => fetchRecipes({ category: activeCategory, sort: 'newest' }),
})
const recipes = recipesData?.data ?? []
return (
<div>
{/* TopBar */}
<div className="flex items-center justify-between px-4 py-4">
<h1 className="font-display text-2xl text-espresso">🍰 Luna Recipes</h1>
<div className="w-9 h-9 rounded-full bg-primary-light flex items-center justify-center text-sm">👤</div>
</div>
{/* Category Chips */}
{categories && (Array.isArray(categories) ? categories : []).length > 0 && (
<div className="flex gap-2 px-4 pb-4 overflow-x-auto scrollbar-hide">
<Badge active={!activeCategory} onClick={() => setActiveCategory(undefined)}>
Alle
</Badge>
{(Array.isArray(categories) ? categories : []).map((cat) => (
<Badge
key={cat.id}
active={activeCategory === cat.slug}
onClick={() => setActiveCategory(activeCategory === cat.slug ? undefined : cat.slug)}
>
{cat.icon || ''} {cat.name}
</Badge>
))}
</div>
)}
{/* Recipe Grid */}
<div className="px-4">
{isLoading ? (
<Masonry breakpointCols={2} className="flex -ml-4" columnClassName="pl-4">
{Array.from({ length: 6 }).map((_, i) => <RecipeCardSkeleton key={i} />)}
</Masonry>
) : recipes.length === 0 ? (
<EmptyState title="Noch keine Rezepte" description="Füge dein erstes Rezept hinzu!" />
) : (
<Masonry breakpointCols={2} className="flex -ml-4" columnClassName="pl-4">
{recipes.map((recipe: Recipe) => (
<RecipeCard key={recipe.id} recipe={recipe} />
))}
</Masonry>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,9 @@
import { EmptyState } from '../components/ui/EmptyState'
export function PlaceholderPage({ title, icon }: { title: string; icon: string }) {
return (
<div className="p-4">
<EmptyState icon={icon} title={title} description="Kommt bald!" />
</div>
)
}

View File

@@ -0,0 +1,486 @@
import { useState, useEffect, useRef } from 'react'
import { useParams, useNavigate } from 'react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import { ArrowLeft, Plus, Trash2, Camera, X, GripVertical } from 'lucide-react'
import { fetchRecipe, createRecipe, updateRecipe, deleteRecipe, uploadRecipeImage } from '../api/recipes'
import { fetchCategories } from '../api/categories'
import type { RecipeFormData } from '../api/recipes'
import type { Ingredient, Step } from '../api/types'
interface IngredientRow {
key: string
name: string
amount: string
unit: string
group_name: string
}
interface StepRow {
key: string
instruction: string
}
let keyCounter = 0
function nextKey() { return `k${++keyCounter}` }
export function RecipeFormPage() {
const { slug } = useParams<{ slug: string }>()
const isEdit = !!slug
const navigate = useNavigate()
const qc = useQueryClient()
const fileInputRef = useRef<HTMLInputElement>(null)
const { data: recipe, isLoading: recipeLoading } = useQuery({
queryKey: ['recipe', slug],
queryFn: () => fetchRecipe(slug!),
enabled: isEdit,
})
const { data: categories } = useQuery({
queryKey: ['categories'],
queryFn: fetchCategories,
})
// Form state
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [categoryId, setCategoryId] = useState('')
const [difficulty, setDifficulty] = useState<'easy' | 'medium' | 'hard'>('medium')
const [prepTime, setPrepTime] = useState('')
const [cookTime, setCookTime] = useState('')
const [servings, setServings] = useState('4')
const [sourceUrl, setSourceUrl] = useState('')
const [imageUrl, setImageUrl] = useState('')
const [imageFile, setImageFile] = useState<File | null>(null)
const [imagePreview, setImagePreview] = useState<string | null>(null)
const [ingredients, setIngredients] = useState<IngredientRow[]>([
{ key: nextKey(), name: '', amount: '', unit: '', group_name: '' },
])
const [steps, setSteps] = useState<StepRow[]>([
{ key: nextKey(), instruction: '' },
])
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
// Populate form when editing
useEffect(() => {
if (recipe && isEdit) {
setTitle(recipe.title)
setDescription(recipe.description || '')
setCategoryId(recipe.category_id || '')
setDifficulty(recipe.difficulty || 'medium')
setPrepTime(recipe.prep_time_min ? String(recipe.prep_time_min) : '')
setCookTime(recipe.cook_time_min ? String(recipe.cook_time_min) : '')
setServings(recipe.servings ? String(recipe.servings) : '4')
setSourceUrl((recipe as any).source_url || '')
setImageUrl(recipe.image_url || '')
if (recipe.image_url) setImagePreview(recipe.image_url)
if (recipe.ingredients && recipe.ingredients.length > 0) {
setIngredients(recipe.ingredients.map(ing => ({
key: nextKey(),
name: ing.name,
amount: ing.amount ? String(ing.amount) : '',
unit: ing.unit || '',
group_name: ing.group_name || '',
})))
}
if (recipe.steps && recipe.steps.length > 0) {
setSteps(recipe.steps.map(s => ({
key: nextKey(),
instruction: s.instruction,
})))
}
}
}, [recipe, isEdit])
const saveMutation = useMutation({
mutationFn: async (data: RecipeFormData) => {
if (isEdit && recipe) {
return updateRecipe(recipe.id, data)
}
return createRecipe(data)
},
onSuccess: async (saved) => {
// Upload image if selected
if (imageFile && saved?.id) {
try {
await uploadRecipeImage(saved.id, imageFile)
} catch {
toast.error('Rezept gespeichert, aber Bild-Upload fehlgeschlagen')
}
}
qc.invalidateQueries({ queryKey: ['recipes'] })
qc.invalidateQueries({ queryKey: ['recipe', slug] })
toast.success(isEdit ? 'Rezept aktualisiert!' : 'Rezept erstellt!')
navigate(saved?.slug ? `/recipe/${saved.slug}` : '/')
},
onError: () => toast.error('Fehler beim Speichern'),
})
const deleteMutation = useMutation({
mutationFn: () => deleteRecipe(recipe!.id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['recipes'] })
toast.success('Rezept gelöscht')
navigate('/')
},
onError: () => toast.error('Fehler beim Löschen'),
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!title.trim()) {
toast.error('Titel ist erforderlich')
return
}
const data: RecipeFormData = {
title: title.trim(),
description: description.trim() || undefined,
category_id: categoryId || undefined,
difficulty,
prep_time: prepTime ? Number(prepTime) : undefined,
cook_time: cookTime ? Number(cookTime) : undefined,
servings: servings ? Number(servings) : undefined,
source_url: sourceUrl.trim() || undefined,
image_url: imageUrl || undefined,
ingredients: ingredients
.filter(i => i.name.trim())
.map((i, idx) => ({
name: i.name.trim(),
amount: i.amount ? Number(i.amount) : undefined,
unit: i.unit.trim() || undefined,
group_name: i.group_name.trim() || undefined,
sort_order: idx,
})),
steps: steps
.filter(s => s.instruction.trim())
.map((s, idx) => ({
step_number: idx + 1,
instruction: s.instruction.trim(),
})),
}
saveMutation.mutate(data)
}
const handleImageSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
setImageFile(file)
const url = URL.createObjectURL(file)
setImagePreview(url)
}
const addIngredient = () => {
setIngredients(prev => [...prev, { key: nextKey(), name: '', amount: '', unit: '', group_name: '' }])
}
const removeIngredient = (key: string) => {
setIngredients(prev => prev.filter(i => i.key !== key))
}
const updateIngredient = (key: string, field: keyof IngredientRow, value: string) => {
setIngredients(prev => prev.map(i => i.key === key ? { ...i, [field]: value } : i))
}
const addStep = () => {
setSteps(prev => [...prev, { key: nextKey(), instruction: '' }])
}
const removeStep = (key: string) => {
setSteps(prev => prev.filter(s => s.key !== key))
}
const updateStep = (key: string, instruction: string) => {
setSteps(prev => prev.map(s => s.key === key ? { ...s, instruction } : s))
}
if (isEdit && recipeLoading) {
return <div className="p-4 text-warm-grey">Laden...</div>
}
const inputClass = "w-full bg-surface border border-sand rounded-xl px-4 py-3 text-base text-espresso placeholder:text-warm-grey/50 focus:outline-none focus:ring-2 focus:ring-primary/30 min-h-[44px]"
const labelClass = "block text-sm font-semibold text-espresso mb-1"
return (
<div className="min-h-screen bg-cream">
{/* Header */}
<div className="sticky top-0 z-40 bg-cream/95 backdrop-blur-sm border-b border-sand px-4 py-3 flex items-center gap-3">
<button onClick={() => navigate(-1)} className="p-2 -ml-2 min-w-[44px] min-h-[44px] flex items-center justify-center">
<ArrowLeft size={20} className="text-espresso" />
</button>
<h1 className="font-display text-lg text-espresso flex-1">
{isEdit ? 'Rezept bearbeiten' : 'Neues Rezept'}
</h1>
</div>
<form onSubmit={handleSubmit} className="p-4 pb-32 space-y-6">
{/* Image */}
<div>
<label className={labelClass}>Bild</label>
<input
ref={fileInputRef}
type="file"
accept="image/*"
capture="environment"
onChange={handleImageSelect}
className="hidden"
/>
{imagePreview ? (
<div className="relative">
<img src={imagePreview} alt="Preview" className="w-full h-48 object-cover rounded-2xl" />
<button
type="button"
onClick={() => { setImageFile(null); setImagePreview(null); setImageUrl('') }}
className="absolute top-2 right-2 bg-surface/80 backdrop-blur-sm rounded-full p-1.5"
>
<X size={16} className="text-espresso" />
</button>
</div>
) : (
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="w-full h-48 border-2 border-dashed border-sand rounded-2xl flex flex-col items-center justify-center gap-2 text-warm-grey hover:border-primary/50 transition-colors"
>
<Camera size={32} />
<span className="text-sm">Foto aufnehmen oder auswählen</span>
</button>
)}
</div>
{/* Title */}
<div>
<label className={labelClass}>Titel *</label>
<input
type="text"
value={title}
onChange={e => setTitle(e.target.value)}
placeholder="z.B. Omas Apfelkuchen"
className={inputClass}
required
/>
</div>
{/* Description */}
<div>
<label className={labelClass}>Beschreibung</label>
<textarea
value={description}
onChange={e => setDescription(e.target.value)}
placeholder="Kurze Beschreibung..."
rows={3}
className={`${inputClass} resize-none`}
/>
</div>
{/* Category + Difficulty row */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className={labelClass}>Kategorie</label>
<select value={categoryId} onChange={e => setCategoryId(e.target.value)} className={inputClass}>
<option value="">Keine</option>
{(categories || []).map(cat => (
<option key={cat.id} value={cat.id}>{cat.name}</option>
))}
</select>
</div>
<div>
<label className={labelClass}>Schwierigkeit</label>
<select value={difficulty} onChange={e => setDifficulty(e.target.value as any)} className={inputClass}>
<option value="easy">Einfach</option>
<option value="medium">Mittel</option>
<option value="hard">Schwer</option>
</select>
</div>
</div>
{/* Times + Servings */}
<div className="grid grid-cols-3 gap-3">
<div>
<label className={labelClass}>Vorbereitung</label>
<input
type="number"
value={prepTime}
onChange={e => setPrepTime(e.target.value)}
placeholder="Min"
min="0"
className={inputClass}
/>
</div>
<div>
<label className={labelClass}>Kochzeit</label>
<input
type="number"
value={cookTime}
onChange={e => setCookTime(e.target.value)}
placeholder="Min"
min="0"
className={inputClass}
/>
</div>
<div>
<label className={labelClass}>Portionen</label>
<input
type="number"
value={servings}
onChange={e => setServings(e.target.value)}
placeholder="4"
min="1"
className={inputClass}
/>
</div>
</div>
{/* Source URL */}
<div>
<label className={labelClass}>Quelle (URL)</label>
<input
type="url"
value={sourceUrl}
onChange={e => setSourceUrl(e.target.value)}
placeholder="https://pinterest.com/..."
className={inputClass}
/>
</div>
{/* Ingredients */}
<div>
<div className="flex items-center justify-between mb-2">
<label className={labelClass}>Zutaten</label>
<button type="button" onClick={addIngredient} className="flex items-center gap-1 text-primary text-sm font-medium min-h-[44px] px-2">
<Plus size={16} /> Hinzufügen
</button>
</div>
<div className="space-y-2">
{ingredients.map((ing) => (
<div key={ing.key} className="flex gap-2 items-start">
<input
type="text"
value={ing.amount}
onChange={e => updateIngredient(ing.key, 'amount', e.target.value)}
placeholder="Menge"
className="w-16 bg-surface border border-sand rounded-xl px-3 py-3 text-sm text-espresso min-h-[44px]"
/>
<input
type="text"
value={ing.unit}
onChange={e => updateIngredient(ing.key, 'unit', e.target.value)}
placeholder="Einheit"
className="w-16 bg-surface border border-sand rounded-xl px-3 py-3 text-sm text-espresso min-h-[44px]"
/>
<input
type="text"
value={ing.name}
onChange={e => updateIngredient(ing.key, 'name', e.target.value)}
placeholder="Zutat"
className="flex-1 bg-surface border border-sand rounded-xl px-3 py-3 text-sm text-espresso min-h-[44px]"
/>
<button
type="button"
onClick={() => removeIngredient(ing.key)}
className="p-2 text-warm-grey hover:text-berry-red min-w-[44px] min-h-[44px] flex items-center justify-center"
>
<X size={16} />
</button>
</div>
))}
</div>
</div>
{/* Steps */}
<div>
<div className="flex items-center justify-between mb-2">
<label className={labelClass}>Zubereitung</label>
<button type="button" onClick={addStep} className="flex items-center gap-1 text-primary text-sm font-medium min-h-[44px] px-2">
<Plus size={16} /> Schritt
</button>
</div>
<div className="space-y-3">
{steps.map((step, idx) => (
<div key={step.key} className="flex gap-2 items-start">
<span className="flex-shrink-0 w-7 h-7 rounded-full bg-primary text-white text-sm flex items-center justify-center font-medium mt-2">
{idx + 1}
</span>
<textarea
value={step.instruction}
onChange={e => updateStep(step.key, e.target.value)}
placeholder={`Schritt ${idx + 1}...`}
rows={2}
className="flex-1 bg-surface border border-sand rounded-xl px-3 py-3 text-sm text-espresso resize-none min-h-[44px]"
/>
<button
type="button"
onClick={() => removeStep(step.key)}
className="p-2 text-warm-grey hover:text-berry-red min-w-[44px] min-h-[44px] flex items-center justify-center"
>
<X size={16} />
</button>
</div>
))}
</div>
</div>
{/* Action Buttons */}
<div className="space-y-3">
<button
type="submit"
disabled={saveMutation.isPending}
className="w-full bg-primary text-white px-6 py-4 rounded-2xl font-medium text-base shadow-sm transition-colors hover:bg-primary/90 disabled:opacity-50 min-h-[44px]"
>
{saveMutation.isPending ? 'Speichern...' : (isEdit ? 'Änderungen speichern' : 'Rezept erstellen')}
</button>
<button
type="button"
onClick={() => navigate(-1)}
className="w-full bg-surface text-espresso px-6 py-4 rounded-2xl font-medium text-base border border-sand min-h-[44px]"
>
Abbrechen
</button>
{isEdit && (
<>
<button
type="button"
onClick={() => setShowDeleteConfirm(true)}
className="w-full text-berry-red px-6 py-4 rounded-2xl font-medium text-base min-h-[44px]"
>
<Trash2 size={16} className="inline mr-2" />
Rezept löschen
</button>
{/* Delete Confirmation Dialog */}
{showDeleteConfirm && (
<div className="fixed inset-0 z-50 bg-black/40 flex items-center justify-center p-4" onClick={() => setShowDeleteConfirm(false)}>
<div className="bg-surface rounded-2xl p-6 max-w-sm w-full shadow-xl space-y-4" onClick={e => e.stopPropagation()}>
<h2 className="font-display text-lg text-espresso">Rezept löschen?</h2>
<p className="text-sm text-warm-grey">
Möchtest du "{recipe?.title}" wirklich löschen? Das kann nicht rückgängig gemacht werden.
</p>
<div className="flex gap-3">
<button
type="button"
onClick={() => setShowDeleteConfirm(false)}
className="flex-1 bg-surface border border-sand text-espresso px-4 py-3 rounded-xl font-medium min-h-[44px]"
>
Abbrechen
</button>
<button
type="button"
onClick={() => deleteMutation.mutate()}
disabled={deleteMutation.isPending}
className="flex-1 bg-berry-red text-white px-4 py-3 rounded-xl font-medium min-h-[44px] disabled:opacity-50"
>
{deleteMutation.isPending ? 'Löschen...' : 'Löschen'}
</button>
</div>
</div>
</div>
)}
</>
)}
</div>
</form>
</div>
)
}

View File

@@ -0,0 +1,193 @@
import { useParams, useNavigate, Link } from 'react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import { ArrowLeft, Heart, Clock, Users, ChefHat, ShoppingCart, Pencil } from 'lucide-react'
import { fetchRecipe, toggleFavorite } from '../api/recipes'
import { addFromRecipe } from '../api/shopping'
import { Badge } from '../components/ui/Badge'
import { Skeleton } from '../components/ui/Skeleton'
const gradients = ['from-primary/40 to-secondary/40', 'from-secondary/40 to-sage/40']
export function RecipePage() {
const { slug } = useParams<{ slug: string }>()
const navigate = useNavigate()
const qc = useQueryClient()
const { data: recipe, isLoading } = useQuery({
queryKey: ['recipe', slug],
queryFn: () => fetchRecipe(slug!),
enabled: !!slug,
})
const favMutation = useMutation({
mutationFn: () => toggleFavorite(recipe!.id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['recipe', slug] })
qc.invalidateQueries({ queryKey: ['recipes'] })
},
onError: () => toast.error('Fehler beim Ändern des Favoriten-Status'),
})
const shoppingMutation = useMutation({
mutationFn: () => addFromRecipe(recipe!.id),
onSuccess: (data) => {
qc.invalidateQueries({ queryKey: ['shopping'] })
toast.success(`${data.added} Zutaten zur Einkaufsliste hinzugefügt!`)
},
onError: () => toast.error('Fehler beim Hinzufügen zur Einkaufsliste'),
})
if (isLoading) {
return (
<div className="p-4 space-y-4">
<Skeleton className="w-full h-64" />
<Skeleton className="h-8 w-3/4" />
<Skeleton className="h-4 w-1/2" />
</div>
)
}
if (!recipe) return <div className="p-4">Rezept nicht gefunden.</div>
const totalTime = recipe.total_time_min || ((recipe.prep_time_min || 0) + (recipe.cook_time_min || 0))
const gradient = gradients[recipe.title.length % gradients.length]
// Group ingredients
const ingredientGroups = (recipe.ingredients || []).reduce<Record<string, typeof recipe.ingredients>>((acc, ing) => {
const group = ing.group_name || 'Zutaten'
if (!acc[group]) acc[group] = []
acc[group]!.push(ing)
return acc
}, {})
return (
<div>
{/* Hero */}
<div className="relative">
{recipe.image_url ? (
<img src={recipe.image_url} alt={recipe.title} className="w-full h-64 object-cover" />
) : (
<div className={`w-full h-64 bg-gradient-to-br ${gradient} flex items-center justify-center`}>
<span className="text-6xl">🍰</span>
</div>
)}
<button onClick={() => navigate(-1)} className="absolute top-4 left-4 bg-surface/80 backdrop-blur-sm rounded-full p-2">
<ArrowLeft size={20} className="text-espresso" />
</button>
<div className="absolute top-4 right-4 flex gap-2">
<Link to={`/recipe/${recipe.slug}/edit`} className="bg-surface/80 backdrop-blur-sm rounded-full p-2">
<Pencil size={20} className="text-espresso" />
</Link>
<button onClick={() => favMutation.mutate()} className="bg-surface/80 backdrop-blur-sm rounded-full p-2">
<Heart size={20} className={recipe.is_favorite ? 'fill-primary text-primary' : 'text-espresso'} />
</button>
</div>
</div>
<div className="p-4 space-y-6">
{/* Title & Meta */}
<div>
<h1 className="font-display text-2xl text-espresso">{recipe.title}</h1>
<div className="flex flex-wrap items-center gap-2 mt-2">
{recipe.category_name && <Badge>{recipe.category_name}</Badge>}
{totalTime > 0 && (
<span className="flex items-center gap-1 text-warm-grey text-sm"><Clock size={14} /> {totalTime} min</span>
)}
{recipe.servings && (
<span className="flex items-center gap-1 text-warm-grey text-sm"><Users size={14} /> {recipe.servings} Portionen</span>
)}
{recipe.difficulty && (
<span className="flex items-center gap-1 text-warm-grey text-sm"><ChefHat size={14} /> {recipe.difficulty}</span>
)}
</div>
{recipe.description && <p className="text-warm-grey mt-2 text-sm">{recipe.description}</p>}
</div>
{/* Tags */}
{recipe.tags && recipe.tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{recipe.tags.map((tag) => (
<span key={tag} className="text-xs bg-sand/50 text-warm-grey px-2 py-0.5 rounded-full">#{tag}</span>
))}
</div>
)}
{/* Ingredients */}
{Object.keys(ingredientGroups).length > 0 && (
<div>
<h2 className="font-display text-lg text-espresso mb-3">Zutaten</h2>
{Object.entries(ingredientGroups).map(([group, items]) => (
<div key={group} className="mb-3">
{Object.keys(ingredientGroups).length > 1 && (
<h3 className="font-semibold text-sm text-warm-grey mb-1">{group}</h3>
)}
<ul className="space-y-1">
{items!.map((ing, i) => (
<li key={i} className="flex gap-2 text-sm text-espresso py-1 border-b border-sand/50">
<span className="text-warm-grey min-w-[60px]">
{ing.amount ? `${ing.amount} ${ing.unit || ''}`.trim() : ''}
</span>
<span>{ing.name}</span>
</li>
))}
</ul>
</div>
))}
</div>
)}
{/* Add to shopping list */}
{recipe.ingredients && recipe.ingredients.length > 0 && (
<button
onClick={() => shoppingMutation.mutate()}
disabled={shoppingMutation.isPending}
className="w-full flex items-center justify-center gap-2 bg-secondary text-white px-4 py-3 rounded-xl font-medium transition-colors hover:bg-secondary/90 disabled:opacity-50 min-h-[44px]"
>
<ShoppingCart size={18} />
{shoppingMutation.isPending ? 'Wird hinzugefügt...' : '🛒 Zur Einkaufsliste'}
</button>
)}
{/* Steps */}
{recipe.steps && recipe.steps.length > 0 && (
<div>
<h2 className="font-display text-lg text-espresso mb-3">Zubereitung</h2>
<ol className="space-y-4">
{recipe.steps.map((step, i) => (
<li key={i} className="flex gap-3">
<span className="flex-shrink-0 w-7 h-7 rounded-full bg-primary text-white text-sm flex items-center justify-center font-medium">
{step.step_number || i + 1}
</span>
<div className="text-sm text-espresso pt-1">
<p>{step.instruction}</p>
{step.timer_minutes && (
<span className="inline-flex items-center gap-1 mt-1 text-xs text-primary">
<Clock size={12} /> {step.timer_label || 'Timer'}: {step.timer_minutes} min
</span>
)}
</div>
</li>
))}
</ol>
</div>
)}
{/* Notes */}
{recipe.notes && recipe.notes.length > 0 && (
<div>
<h2 className="font-display text-lg text-espresso mb-3">Notizen</h2>
<div className="space-y-2">
{recipe.notes.map((note) => (
<div key={note.id} className="bg-primary-light/30 rounded-xl p-3 text-sm text-espresso">
📝 {note.content}
</div>
))}
</div>
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,62 @@
import { useState, useEffect, useRef } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Search } from 'lucide-react'
import { searchRecipes } from '../api/recipes'
import { RecipeCardSmall } from '../components/recipe/RecipeCardSmall'
import { EmptyState } from '../components/ui/EmptyState'
export function SearchPage() {
const [query, setQuery] = useState('')
const [debouncedQuery, setDebouncedQuery] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => { inputRef.current?.focus() }, [])
useEffect(() => {
const timer = setTimeout(() => setDebouncedQuery(query), 300)
return () => clearTimeout(timer)
}, [query])
const { data, isLoading } = useQuery({
queryKey: ['search', debouncedQuery],
queryFn: () => searchRecipes(debouncedQuery),
enabled: debouncedQuery.length >= 2,
})
const results = data?.data ?? (Array.isArray(data) ? data as any[] : [])
return (
<div className="p-4">
{/* Search Input */}
<div className="relative mb-4">
<Search size={18} className="absolute left-3 top-1/2 -translate-y-1/2 text-warm-grey" />
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Rezept suchen..."
className="w-full pl-10 pr-4 py-3 bg-surface rounded-xl border border-sand text-espresso text-sm focus:outline-none focus:border-primary"
/>
</div>
{/* Results */}
{debouncedQuery.length < 2 ? (
<EmptyState icon="🔍" title="Suche starten" description="Gib mindestens 2 Zeichen ein" />
) : isLoading ? (
<p className="text-warm-grey text-sm text-center py-8">Suche...</p>
) : results.length === 0 ? (
<EmptyState icon="😔" title="Nichts gefunden" description={`Keine Ergebnisse für "${debouncedQuery}"`} />
) : (
<>
<p className="text-warm-grey text-xs mb-3">{results.length} Ergebnis{results.length !== 1 ? 'se' : ''}</p>
<div className="space-y-3">
{results.map((recipe: any) => (
<RecipeCardSmall key={recipe.id} recipe={recipe} />
))}
</div>
</>
)}
</div>
)
}

View File

@@ -0,0 +1,281 @@
import { useState, useRef } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Trash2, Plus, ShoppingCart, X } from 'lucide-react'
import {
fetchShopping,
addCustomItem,
toggleCheck,
deleteItem,
deleteChecked,
} from '../api/shopping'
import type { ShoppingGroup, ShoppingItem } from '../api/shopping'
import { EmptyState } from '../components/ui/EmptyState'
export function ShoppingPage() {
const qc = useQueryClient()
const [newItem, setNewItem] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
const { data: groups = [], isLoading, refetch } = useQuery({
queryKey: ['shopping'],
queryFn: fetchShopping,
})
const checkMutation = useMutation({
mutationFn: toggleCheck,
onSuccess: () => qc.invalidateQueries({ queryKey: ['shopping'] }),
})
const deleteMutation = useMutation({
mutationFn: deleteItem,
onSuccess: () => qc.invalidateQueries({ queryKey: ['shopping'] }),
})
const deleteCheckedMutation = useMutation({
mutationFn: deleteChecked,
onSuccess: () => qc.invalidateQueries({ queryKey: ['shopping'] }),
})
const addMutation = useMutation({
mutationFn: addCustomItem,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['shopping'] })
setNewItem('')
inputRef.current?.focus()
},
})
const handleAdd = () => {
const name = newItem.trim()
if (!name) return
addMutation.mutate({ name })
}
const hasChecked = groups.some((g) => g.items.some((i) => i.checked))
const totalUnchecked = groups.reduce((acc, g) => acc + g.items.filter((i) => !i.checked).length, 0)
// Sort items: unchecked first, checked last
const sortItems = (items: ShoppingItem[]) => {
const unchecked = items.filter((i) => !i.checked)
const checked = items.filter((i) => i.checked)
return [...unchecked, ...checked]
}
// Pull-to-refresh via touch
const [pulling, setPulling] = useState(false)
const touchStartY = useRef(0)
const handleTouchStart = (e: React.TouchEvent) => {
if (window.scrollY === 0) {
touchStartY.current = e.touches[0].clientY
}
}
const handleTouchEnd = (e: React.TouchEvent) => {
if (pulling) {
const dy = e.changedTouches[0].clientY - touchStartY.current
if (dy > 80) {
refetch()
}
setPulling(false)
}
}
const handleTouchMove = (e: React.TouchEvent) => {
if (window.scrollY === 0) {
const dy = e.touches[0].clientY - touchStartY.current
if (dy > 30) setPulling(true)
}
}
if (isLoading) {
return (
<div className="p-4 space-y-4">
{[1, 2, 3].map((i) => (
<div key={i} className="bg-surface rounded-2xl p-4 animate-pulse h-20" />
))}
</div>
)
}
return (
<div
className="min-h-screen"
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
{/* TopBar */}
<div className="sticky top-0 z-40 bg-cream/95 backdrop-blur-sm border-b border-sand px-4 py-3 flex items-center justify-between">
<h1 className="font-display text-xl text-espresso">Einkaufsliste</h1>
<div className="flex items-center gap-2">
{totalUnchecked > 0 && (
<span className="text-sm text-warm-grey">{totalUnchecked} offen</span>
)}
{hasChecked && (
<button
onClick={() => deleteCheckedMutation.mutate()}
className="p-2 text-warm-grey hover:text-berry-red transition-colors min-w-[44px] min-h-[44px] flex items-center justify-center"
title="Erledigte löschen"
>
<Trash2 size={20} />
</button>
)}
</div>
</div>
{/* Pull indicator */}
{pulling && (
<div className="text-center text-warm-grey text-sm py-2"> Loslassen zum Aktualisieren</div>
)}
{/* Quick-Add */}
<div className="sticky top-[53px] z-30 bg-cream/95 backdrop-blur-sm px-4 py-3">
<form
onSubmit={(e) => {
e.preventDefault()
handleAdd()
}}
className="flex gap-2"
>
<input
ref={inputRef}
type="text"
value={newItem}
onChange={(e) => setNewItem(e.target.value)}
placeholder="Artikel hinzufügen..."
className="flex-1 bg-surface border border-sand rounded-xl px-4 py-3 text-base text-espresso placeholder:text-warm-grey/50 focus:outline-none focus:ring-2 focus:ring-primary/30"
/>
<button
type="submit"
disabled={!newItem.trim() || addMutation.isPending}
className="bg-primary text-white rounded-xl px-4 py-3 min-w-[44px] min-h-[44px] flex items-center justify-center disabled:opacity-40 transition-opacity"
>
<Plus size={20} />
</button>
</form>
</div>
{/* Content */}
<div className="px-4 pb-24 space-y-4">
{groups.length === 0 ? (
<EmptyState
icon="🛒"
title="Einkaufsliste leer"
description="Füge Zutaten aus einem Rezept hinzu oder erstelle eigene Einträge."
/>
) : (
groups.map((group) => (
<div key={group.recipe_title} className="bg-surface rounded-2xl shadow-sm overflow-hidden">
{/* Group header */}
<div className="px-4 py-3 border-b border-sand/50 flex items-center gap-2">
<span className="text-base">{group.recipe_id ? '🍰' : '📝'}</span>
<h3 className="font-semibold text-sm text-espresso truncate">
{group.recipe_title || 'Eigene'}
</h3>
<span className="text-xs text-warm-grey ml-auto">
{group.items.filter((i) => !i.checked).length}/{group.items.length}
</span>
</div>
{/* Items */}
<ul>
{sortItems(group.items).map((item) => (
<ShoppingItemRow
key={item.id}
item={item}
onToggle={() => checkMutation.mutate(item.id)}
onDelete={() => deleteMutation.mutate(item.id)}
/>
))}
</ul>
</div>
))
)}
</div>
</div>
)
}
function ShoppingItemRow({
item,
onToggle,
onDelete,
}: {
item: ShoppingItem
onToggle: () => void
onDelete: () => void
}) {
const [swipeX, setSwipeX] = useState(0)
const touchStartX = useRef(0)
const swiping = useRef(false)
const handleTouchStart = (e: React.TouchEvent) => {
touchStartX.current = e.touches[0].clientX
swiping.current = false
}
const handleTouchMove = (e: React.TouchEvent) => {
const dx = e.touches[0].clientX - touchStartX.current
if (dx < -10) {
swiping.current = true
setSwipeX(Math.max(dx, -80))
} else {
setSwipeX(0)
}
}
const handleTouchEnd = () => {
if (swipeX < -60) {
onDelete()
}
setSwipeX(0)
swiping.current = false
}
const amountText = [item.amount, item.unit].filter(Boolean).join(' ')
return (
<li className="relative overflow-hidden">
{/* Delete background */}
<div className="absolute inset-y-0 right-0 w-20 bg-berry-red flex items-center justify-center">
<X size={18} className="text-white" />
</div>
<div
className="relative bg-surface flex items-center gap-3 px-4 min-h-[52px] transition-transform"
style={{ transform: `translateX(${swipeX}px)` }}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
<button
onClick={onToggle}
className="flex-shrink-0 w-6 h-6 rounded-md border-2 flex items-center justify-center transition-colors min-w-[44px] min-h-[44px]"
style={{
borderColor: item.checked ? '#C4737E' : '#E8E0D8',
backgroundColor: item.checked ? '#C4737E' : 'transparent',
}}
>
{item.checked && (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M2.5 7L5.5 10L11.5 4" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)}
</button>
<div className={`flex-1 min-w-0 ${item.checked ? 'opacity-50' : ''}`}>
<span className={`text-base text-espresso ${item.checked ? 'line-through text-warm-grey' : ''}`}>
{item.name}
</span>
</div>
{amountText && (
<span className={`text-sm flex-shrink-0 ${item.checked ? 'text-warm-grey/50 line-through' : 'text-warm-grey'}`}>
{amountText}
</span>
)}
</div>
</li>
)
}

View File

@@ -0,0 +1,32 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Playfair+Display:wght@600;700&display=swap');
@import "tailwindcss";
@theme {
--color-primary: #C4737E;
--color-primary-light: #F2D7DB;
--color-secondary: #D4A574;
--color-cream: #FBF8F5;
--color-surface: #FFFFFF;
--color-espresso: #2D2016;
--color-warm-grey: #7A6E65;
--color-sage: #7BAE7F;
--color-berry: #C94C4C;
--color-sand: #E8E0D8;
--font-display: 'Playfair Display', serif;
--font-sans: 'Inter', system-ui, sans-serif;
}
body {
font-family: var(--font-sans);
background-color: #FBF8F5;
color: #2D2016;
-webkit-font-smoothing: antialiased;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}

View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

34
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,34 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import { VitePWA } from 'vite-plugin-pwa'
export default defineConfig({
plugins: [
react(),
tailwindcss(),
VitePWA({
registerType: 'autoUpdate',
manifest: {
name: 'Luna Recipes',
short_name: 'Luna',
description: 'Deine persönliche Rezeptsammlung',
theme_color: '#C4737E',
background_color: '#FBF8F5',
display: 'standalone',
orientation: 'portrait',
start_url: '/',
icons: [
{ src: '/icon-192.png', sizes: '192x192', type: 'image/png' },
{ src: '/icon-512.png', sizes: '512x512', type: 'image/png' },
],
},
}),
],
server: {
proxy: {
'/api': 'http://localhost:6001',
'/images': 'http://localhost:6001',
},
},
})

242
gehirn_pdf.py Normal file
View File

@@ -0,0 +1,242 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from fpdf import FPDF
class GehirnPDF(FPDF):
def header(self):
if self.page_no() == 1:
self.set_font("DejaVu", "B", 20)
self.set_text_color(30, 80, 140)
self.cell(0, 12, "Gehirnanatomie", new_x="LMARGIN", new_y="NEXT", align="C")
self.set_font("DejaVu", "", 11)
self.set_text_color(80, 80, 80)
self.cell(0, 7, "Lernzusammenfassung für Ergotherapie", new_x="LMARGIN", new_y="NEXT", align="C")
self.ln(2)
self.set_draw_color(30, 80, 140)
self.set_line_width(0.8)
self.line(10, self.get_y(), 200, self.get_y())
self.ln(6)
def footer(self):
self.set_y(-15)
self.set_font("DejaVu", "", 8)
self.set_text_color(128)
self.cell(0, 10, f"Seite {self.page_no()}/{{nb}}", align="C")
def section_title(self, title):
self.ln(4)
self.set_fill_color(30, 80, 140)
self.set_text_color(255)
self.set_font("DejaVu", "B", 12)
self.set_x(self.l_margin)
self.cell(0, 9, f" {title}", new_x="LMARGIN", new_y="NEXT", fill=True)
self.set_text_color(0)
self.ln(3)
def sub_title(self, title):
self.set_x(self.l_margin)
self.set_font("DejaVu", "B", 10)
self.set_text_color(30, 80, 140)
self.cell(0, 7, title, new_x="LMARGIN", new_y="NEXT")
self.set_text_color(0)
def body_text(self, text):
self.set_x(self.l_margin)
self.set_font("DejaVu", "", 9)
self.multi_cell(0, 5, text)
self.ln(1)
def bullet(self, text):
self.set_x(self.l_margin)
self.set_font("DejaVu", "", 9)
indent = 5
self.cell(indent, 5, "\u2022 ")
w = self.w - self.l_margin - indent - self.r_margin
self.multi_cell(w, 5, text, markdown=True)
def info_box(self, text):
self.set_x(self.l_margin)
self.set_fill_color(240, 248, 255)
self.set_draw_color(30, 80, 140)
self.set_font("DejaVu", "", 8)
self.multi_cell(0, 4.5, "\u27A4 " + text, border=1, fill=True)
self.ln(2)
pdf = GehirnPDF()
pdf.alias_nb_pages()
pdf.add_font("DejaVu", "", "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf")
pdf.add_font("DejaVu", "B", "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf")
pdf.add_font("DejaVu", "I", "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf")
pdf.add_font("DejaVu", "BI", "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf")
pdf.set_auto_page_break(auto=True, margin=20)
pdf.add_page()
# ============ 1. HIRNLAPPEN ============
pdf.section_title("1. Hirnlappen (Lobi cerebri) \u2013 Großhirnrinde")
pdf.sub_title("Frontallappen (Lobus frontalis)")
pdf.bullet("**Lage:** Vorderer Teil des Großhirns, vor dem Sulcus centralis.")
pdf.bullet("**Funktionen:** Willkürmotorik (Gyrus praecentralis), Handlungsplanung, Impulskontrolle, Persönlichkeit, Arbeitsgedächtnis.")
pdf.bullet("**Broca-Areal (Area 44/45):** Motorisches Sprachzentrum. Schädigung → Broca-Aphasie (gestörte Sprachproduktion, Verständnis erhalten).")
pdf.info_box("Ergotherapie: Frontalhirnschäden (z.B. nach SHT) führen zu Antriebsstörungen, Planungsproblemen und Persönlichkeitsveränderungen \u2013 Kernthemen der neuropsychologischen Ergotherapie!")
pdf.sub_title("Parietallappen (Lobus parietalis)")
pdf.bullet("**Lage:** Hinter dem Sulcus centralis, oberhalb des Temporallappens.")
pdf.bullet("**Funktionen:** Somatosensorik (Gyrus postcentralis), Körperschema, räumliche Orientierung, Rechnen.")
pdf.bullet("**Neglect:** Schädigung rechts-parietal → Vernachlässigung der linken Körper-/Raumhälfte.")
pdf.info_box("Ergotherapie: Neglect-Training und Sensibilitätstraining sind klassische ergotherapeutische Interventionen.")
pdf.sub_title("Temporallappen (Lobus temporalis)")
pdf.bullet("**Lage:** Seitlich, unterhalb der Sylvischen Furche.")
pdf.bullet("**Funktionen:** Auditorischer Kortex (Hören), Sprachverständnis, Gedächtnisbildung.")
pdf.bullet("**Wernicke-Areal (Area 22):** Sensorisches Sprachzentrum. Schädigung → Wernicke-Aphasie (flüssige aber inhaltsleere Sprache).")
pdf.sub_title("Okzipitallappen (Lobus occipitalis)")
pdf.bullet("**Lage:** Hinterer Pol des Großhirns.")
pdf.bullet("**Funktionen:** Primärer visueller Kortex (V1). Schädigung → kortikale Blindheit oder Gesichtsfeldausfälle.")
pdf.sub_title("Insula (Inselrinde)")
pdf.bullet("**Lage:** Verborgen in der Tiefe der Sylvischen Furche.")
pdf.bullet("**Funktionen:** Geschmack, viszerale Wahrnehmung, Empathie, Schmerzverarbeitung.")
# ============ 2. HIRNSTAMM & KLEINHIRN ============
pdf.section_title("2. Hirnstamm (Truncus cerebri) & Kleinhirn (Cerebellum)")
pdf.body_text("Der Hirnstamm verbindet Großhirn und Rückenmark. Enthält lebenswichtige Zentren und Hirnnervenkerne (III\u2013XII).")
pdf.sub_title("Mittelhirn (Mesencephalon)")
pdf.bullet("Augenbewegungen (Kerne III, IV), Pupillenreflex, Formatio reticularis (Wachheit), Substantia nigra (Dopamin → Bewegung).")
pdf.sub_title("Brücke (Pons)")
pdf.bullet("Verbindung Großhirn \u2194 Kleinhirn, Hirnnervenkerne (V, VI, VII, VIII), Atemregulation.")
pdf.sub_title("Verlängertes Mark (Medulla oblongata)")
pdf.bullet("Kreislaufzentrum, Atemzentrum, Schluck-/Hustenreflex, **Pyramidenkreuzung** (Motorik kreuzt die Seite!). Kerne IX\u2013XII.")
pdf.info_box("Ergotherapie: Hirnstammläsionen → komplexe Schluckstörungen (Dysphagie), Atemprobleme, Koordinationsstörungen.")
pdf.sub_title("Kleinhirn (Cerebellum)")
pdf.bullet("**Aufbau:** Zwei Hemisphären + Vermis (Wurm). Kleinhirnrinde mit Purkinje-Zellen.")
pdf.bullet("**Funktionen:** Bewegungskoordination, Gleichgewicht, Feinmotorik, motorisches Lernen, Muskeltonus.")
pdf.bullet("**Schädigung:** Ataxie (unsicherer Gang), Intentionstremor, Dysarthrie, Dysdiadochokinese.")
pdf.info_box("Ergotherapie: Koordinations-, Gleichgewichts- und Feinmotoriktraining bei zerebellären Störungen.")
# ============ 3. LIMBISCHES SYSTEM ============
pdf.section_title("3. Limbisches System")
pdf.body_text("Funktionelle Einheit für Emotionen, Gedächtnis, Motivation und vegetative Steuerung.")
pdf.sub_title("Amygdala (Mandelkern)")
pdf.bullet("Emotionale Bewertung (v.a. Angst/Furcht), emotionales Lernen, Kampf-oder-Flucht-Reaktion.")
pdf.sub_title("Hippocampus")
pdf.bullet("Überführung von Kurzzeit- in Langzeitgedächtnis (Konsolidierung), räumliche Orientierung.")
pdf.bullet("**Klinik:** Schädigung → anterograde Amnesie. Atrophie bei Alzheimer-Demenz.")
pdf.sub_title("Thalamus")
pdf.bullet("**\u201ETor zum Bewusstsein\u201C** \u2013 Filterstation: Leitet sensorische Infos (außer Geruch!) an den Kortex weiter.")
pdf.sub_title("Hypothalamus")
pdf.bullet("Steuerung des vegetativen NS (Sympathikus/Parasympathikus), Hormonregulation über Hypophyse, Temperatur, Hunger, Durst, Schlaf-Wach-Rhythmus.")
pdf.sub_title("Gyrus cinguli")
pdf.bullet("Motivation, Antrieb, Schmerzverarbeitung, emotionale Bewertung, Fehlererkennung.")
pdf.info_box("Ergotherapie: Limbische Störungen beeinflussen Motivation, Lernfähigkeit und emotionale Regulation \u2013 zentral für therapeutische Beziehung und Therapieplanung.")
# ============ 4. HIRNNERVEN ============
pdf.section_title("4. Die 12 Hirnnerven (Nervi craniales)")
pdf.set_font("DejaVu", "B", 8)
pdf.set_fill_color(30, 80, 140)
pdf.set_text_color(255)
col_w = [10, 42, 14, 104]
headers = ["Nr.", "Name", "Typ", "Funktion"]
pdf.set_x(pdf.l_margin)
for i, h in enumerate(headers):
pdf.cell(col_w[i], 6, h, border=1, fill=True, align="C")
pdf.ln()
pdf.set_text_color(0)
nerven = [
("I", "N. olfactorius", "S", "Riechen"),
("II", "N. opticus", "S", "Sehen"),
("III", "N. oculomotorius", "M", "Augenbewegung, Pupillenverengung, Lidhebung"),
("IV", "N. trochlearis", "M", "Augenbewegung (M. obliquus superior)"),
("V", "N. trigeminus", "S+M", "Gesichtssensibilität, Kaumuskulatur"),
("VI", "N. abducens", "M", "Augenbewegung (M. rectus lateralis)"),
("VII", "N. facialis", "S+M", "Mimik, Geschmack (vord. 2/3 Zunge), Tränen-/Speicheldrüsen"),
("VIII", "N. vestibulocochlearis", "S", "Hören und Gleichgewicht"),
("IX", "N. glossopharyngeus", "S+M", "Geschmack (hint. 1/3 Zunge), Schlucken"),
("X", "N. vagus", "S+M", "Parasympathikus! Herz, Lunge, Verdauung, Kehlkopf"),
("XI", "N. accessorius", "M", "M. trapezius, M. sternocleidomastoideus"),
("XII", "N. hypoglossus", "M", "Zungenmuskulatur"),
]
pdf.set_font("DejaVu", "", 8)
for i, (nr, name, typ, funk) in enumerate(nerven):
fill = i % 2 == 0
if fill:
pdf.set_fill_color(240, 248, 255)
else:
pdf.set_fill_color(255, 255, 255)
pdf.set_x(pdf.l_margin)
pdf.cell(col_w[0], 6, nr, border=1, fill=fill, align="C")
pdf.cell(col_w[1], 6, name, border=1, fill=fill)
pdf.cell(col_w[2], 6, typ, border=1, fill=fill, align="C")
pdf.cell(col_w[3], 6, funk, border=1, fill=fill)
pdf.ln()
pdf.ln(2)
pdf.set_x(pdf.l_margin)
pdf.set_font("DejaVu", "", 8)
pdf.cell(0, 5, "S = sensorisch | M = motorisch | S+M = gemischt", new_x="LMARGIN", new_y="NEXT")
pdf.ln(2)
pdf.sub_title("Merkspruch (Reihenfolge):")
pdf.set_x(pdf.l_margin)
pdf.set_font("DejaVu", "B", 9)
pdf.set_text_color(30, 80, 140)
pdf.multi_cell(0, 5, "\u201EOnkel Otto okuliert, trifft Tina ab, für vier gute Vögel, alle Hühner.\u201C")
pdf.set_text_color(0)
pdf.set_x(pdf.l_margin)
pdf.set_font("DejaVu", "", 7.5)
pdf.multi_cell(0, 4, "(Olfactorius, Opticus, Oculomotorius, Trochlearis, Trigeminus, Abducens, Facialis, Vestibulocochlearis, Glossopharyngeus, Vagus, Accessorius, Hypoglossus)")
pdf.ln(2)
pdf.sub_title("Merkspruch (Faserqualität S/M):")
pdf.set_x(pdf.l_margin)
pdf.set_font("DejaVu", "B", 9)
pdf.set_text_color(30, 80, 140)
pdf.multi_cell(0, 5, "\u201ESome Say Marry Money, But My Brother Says Big Brains Matter Most.\u201C")
pdf.set_text_color(0)
pdf.set_x(pdf.l_margin)
pdf.set_font("DejaVu", "", 7.5)
pdf.multi_cell(0, 4, "(S, S, M, M, B=both, M, B, S, B, B, M, M)")
pdf.ln(2)
# ============ 5. VENTRIKELSYSTEM ============
pdf.section_title("5. Ventrikelsystem & Liquor cerebrospinalis")
pdf.sub_title("Die 4 Ventrikel")
pdf.bullet("**1. und 2. Ventrikel (Seitenventrikel):** Je einer pro Hemisphäre, C-förmig. Verbindung zum 3. Ventrikel über Foramen interventriculare (Monroi).")
pdf.bullet("**3. Ventrikel:** Im Zwischenhirn (Diencephalon), zwischen den Thalami. Verbindung zum 4. Ventrikel über Aquaeductus cerebri (Sylvii).")
pdf.bullet("**4. Ventrikel:** Zwischen Kleinhirn und Hirnstamm. Öffnet sich in den Subarachnoidalraum (Foramina Luschkae und Magendii).")
pdf.sub_title("Liquor cerebrospinalis")
pdf.bullet("**Bildung:** Plexus choroideus (in allen 4 Ventrikeln), ca. 500 ml/Tag, Gesamtvolumen ca. 150 ml.")
pdf.bullet("**Funktionen:** Mechanischer Schutz (Polsterung), Nährstofftransport, Abtransport von Stoffwechselprodukten, Hirndruck.")
pdf.bullet("**Zirkulation:** Seitenventrikel → 3. Ventrikel → Aquädukt → 4. Ventrikel → Subarachnoidalraum → Resorption über Arachnoidalzotten in venöse Sinus.")
pdf.bullet("**Klinik:** Liquorzirkulationsstörung → Hydrozephalus (\u201EWasserkopf\u201C) mit erhöhtem Hirndruck.")
pdf.info_box("Ergotherapie: Hydrozephalus (z.B. bei Kindern mit Shunt) \u2013 Vorsicht bei Lagerung und Aktivitäten. Liquorpunktion liefert diagnostische Hinweise bei MS, Meningitis u.a.")
# Lerntipp
pdf.ln(3)
pdf.set_x(pdf.l_margin)
pdf.set_fill_color(255, 245, 220)
pdf.set_draw_color(200, 150, 50)
pdf.set_font("DejaVu", "B", 9)
pdf.set_text_color(120, 80, 0)
pdf.multi_cell(0, 5.5, "Lerntipp: Zeichne die Strukturen selbst! Beschrifte ein leeres Gehirnschema und wiederhole die Funktionen laut. Nutze die Merksprüche für die Hirnnerven \u2013 sie sind Prüfungsklassiker!", border=1, fill=True, align="C")
OUT = "/home/clawd/.openclaw/workspace/luna-recipes/Gehirnanatomie-Zusammenfassung.pdf"
pdf.output(OUT)
print(f"PDF erstellt: {OUT}")