feat: stabilization + recipe edit/create UI
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.db
|
||||||
|
data/
|
||||||
|
.env
|
||||||
BIN
Gehirnanatomie-Zusammenfassung.pdf
Normal file
BIN
backend/data/images/recipes/01KHPTHD73XRVXTKRMFMAWAPA9/hero.webp
Normal file
|
After Width: | Height: | Size: 110 B |
|
After Width: | Height: | Size: 106 B |
BIN
backend/data/images/recipes/01KHPW02KFYAZN8Z8R0ZA6MQST/hero.webp
Normal file
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 24 KiB |
BIN
backend/data/images/recipes/01KHPW02KZQ9A19V49F192HNKB/hero.webp
Normal file
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 23 KiB |
BIN
backend/data/images/recipes/01KHPW02MFRCZ1EMDZX2YEMPWR/hero.webp
Normal file
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 18 KiB |
BIN
backend/data/recipes.db
Normal file
BIN
backend/data/recipes.db-shm
Normal file
BIN
backend/data/recipes.db-wal
Normal file
2561
backend/package-lock.json
generated
Normal file
32
backend/package.json
Normal 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
@@ -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;
|
||||||
|
}
|
||||||
24
backend/src/db/connection.ts
Normal 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
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
136
backend/src/db/migrations/001_initial.sql
Normal 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
@@ -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
@@ -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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
15
backend/src/routes/categories.ts
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
7
backend/src/routes/health.ts
Normal 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() };
|
||||||
|
});
|
||||||
|
}
|
||||||
40
backend/src/routes/images.ts
Normal 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
34
backend/src/routes/notes.ts
Normal 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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
59
backend/src/routes/recipes.ts
Normal 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
41
backend/src/routes/shopping.ts
Normal 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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
15
backend/src/routes/tags.ts
Normal 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
5
backend/src/schemas/note.schema.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const createNoteSchema = z.object({
|
||||||
|
content: z.string().min(1),
|
||||||
|
});
|
||||||
31
backend/src/schemas/recipe.schema.ts
Normal 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();
|
||||||
7
backend/src/schemas/shopping.schema.ts
Normal 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(),
|
||||||
|
});
|
||||||
51
backend/src/services/image.service.ts
Normal 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);
|
||||||
|
}
|
||||||
26
backend/src/services/note.service.ts
Normal 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;
|
||||||
|
}
|
||||||
197
backend/src/services/recipe.service.ts
Normal 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 };
|
||||||
|
}
|
||||||
64
backend/src/services/shopping.service.ts
Normal 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;
|
||||||
|
}
|
||||||
26
backend/src/services/tag.service.ts
Normal 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
@@ -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
@@ -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
@@ -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 | 22–28px |
|
||||||
|
| **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, 15–30, 30–60, >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 (22–28px)
|
||||||
|
│ 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)
|
||||||
27
features/LESSONS-FROM-WERK.md
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
39
frontend/package.json
Normal 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
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
6
frontend/src/api/categories.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { apiFetch } from './client'
|
||||||
|
import type { Category } from './types'
|
||||||
|
|
||||||
|
export function fetchCategories() {
|
||||||
|
return apiFetch<Category[]>('/categories')
|
||||||
|
}
|
||||||
17
frontend/src/api/client.ts
Normal 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()
|
||||||
|
}
|
||||||
74
frontend/src/api/recipes.ts
Normal 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 }>
|
||||||
|
})
|
||||||
|
}
|
||||||
45
frontend/src/api/shopping.ts
Normal 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
@@ -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
|
||||||
|
}
|
||||||
45
frontend/src/components/ErrorBoundary.tsx
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
13
frontend/src/components/layout/AppShell.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
33
frontend/src/components/layout/BottomNav.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
58
frontend/src/components/recipe/RecipeCard.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
34
frontend/src/components/recipe/RecipeCardSmall.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
20
frontend/src/components/ui/Badge.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
22
frontend/src/components/ui/Button.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
15
frontend/src/components/ui/EmptyState.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
15
frontend/src/components/ui/Skeleton.tsx
Normal 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
@@ -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>,
|
||||||
|
)
|
||||||
71
frontend/src/pages/HomePage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
9
frontend/src/pages/PlaceholderPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
486
frontend/src/pages/RecipeFormPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
193
frontend/src/pages/RecipePage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
62
frontend/src/pages/SearchPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
281
frontend/src/pages/ShoppingPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
32
frontend/src/styles/globals.css
Normal 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;
|
||||||
|
}
|
||||||
28
frontend/tsconfig.app.json
Normal 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
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
26
frontend/tsconfig.node.json
Normal 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
@@ -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
@@ -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}")
|
||||||