Initial commit: What To Cook (WTC) v4.4 - Digitales Kochbuch, Wochenmenueplan & Buchdruck

This commit is contained in:
2026-10-14 20:24:37 +02:00
commit c01d2d7639
171 changed files with 14073 additions and 0 deletions
+754
View File
@@ -0,0 +1,754 @@
import os
import sqlite3
from datetime import datetime
from typing import List, Dict, Any, Optional
try:
from dotenv import load_dotenv
load_dotenv()
except ImportError:
pass
try:
import pymysql
import pymysql.cursors
except ImportError:
pymysql = None
DB_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "data", "wtc.db")
DB_ENGINE = os.getenv("DB_ENGINE", "mariadb").lower()
MARIADB_HOST = os.getenv("MARIADB_HOST", "db.waiswuaschd.de")
MARIADB_PORT = int(os.getenv("MARIADB_PORT", "3306"))
MARIADB_USER = os.getenv("MARIADB_USER", "v2_usr_wtc_cookbook")
MARIADB_PASS = os.getenv("MARIADB_PASS", "XKikHf&j6Wj!rs5%A8#ZzyvE#*A1hT")
MARIADB_NAME = os.getenv("MARIADB_NAME", "v2_db_wtc_cookbook")
LEGACY_CATEGORIES = [
"Reis",
"Nudeln",
"Kartoffeln",
"Teigig",
"asiatisch",
"Hausmannskost",
"Suppen",
"Eintöpfe",
"Süßspeisen",
"Fisch"
]
# 11 Kochbuch-Kapitel aus dem Benutzerentwurf mit handgezeichneten Doodles & Icons
CHAPTERS = [
{"number": 1, "title": "Suppen & Eintöpfe", "icon": "🍲", "doodle_hint": "Dampfende Suppenschale", "illustration_url": "/static/img/chapters/chapter_1.png"},
{"number": 2, "title": "Traditionell & Basics", "icon": "🥟", "doodle_hint": "Klassiker & Brotzeit", "illustration_url": "/static/img/chapters/chapter_2.png"},
{"number": 3, "title": "Pasta & Nudelgerichte", "icon": "🍝", "doodle_hint": "Spaghetti & Pasta", "illustration_url": "/static/img/chapters/chapter_3.png"},
{"number": 4, "title": "Kartoffeln", "icon": "🥔", "doodle_hint": "Kartoffelauflauf & Klöße", "illustration_url": "/static/img/chapters/chapter_4.png"},
{"number": 5, "title": "Reis & Getreide", "icon": "🍚", "doodle_hint": "Reisschale & Körner", "illustration_url": "/static/img/chapters/chapter_5.png"},
{"number": 6, "title": "Teig & Gebackenes", "icon": "🍕", "doodle_hint": "Pizza, Flammkuchen & Brot", "illustration_url": "/static/img/chapters/chapter_6.png"},
{"number": 7, "title": "Asiatische Küche", "icon": "🥢", "doodle_hint": "Wok, Ramen & Thai-Curry", "illustration_url": "/static/img/chapters/chapter_7.png"},
{"number": 8, "title": "Süßspeisen", "icon": "🥞", "doodle_hint": "Kaiserschmarrn & Knödel", "illustration_url": "/static/img/chapters/chapter_8.png"},
{"number": 9, "title": "Desserts", "icon": "🍰", "doodle_hint": "Tiramisu & Mousse", "illustration_url": "/static/img/chapters/chapter_9.png"},
{"number": 10, "title": "Kuchen & Gebäck", "icon": "🎂", "doodle_hint": "Torten & Feingebäck", "illustration_url": "/static/img/chapters/chapter_10.png"},
{"number": 11, "title": "Plätzchen", "icon": "🍪", "doodle_hint": "Weihnachtsplätzchen & Kekse", "illustration_url": "/static/img/chapters/chapter_11.png"}
]
CATEGORIES = [c["title"] for c in CHAPTERS] + [c for c in LEGACY_CATEGORIES if c not in [ch["title"] for ch in CHAPTERS]]
EFFORTS = ["gering", "mittel", "hoch"]
CHAPTER_MAP = {c["title"]: c["number"] for c in CHAPTERS}
for c in CHAPTERS:
CHAPTER_MAP[str(c["number"])] = c["number"]
def get_connection():
if DB_ENGINE == "mariadb":
if pymysql is None:
raise RuntimeError("PyMySQL ist nicht installiert, wird aber für MariaDB benötigt.")
return pymysql.connect(
host=MARIADB_HOST,
port=MARIADB_PORT,
user=MARIADB_USER,
password=MARIADB_PASS,
database=MARIADB_NAME,
charset="utf8mb4",
cursorclass=pymysql.cursors.DictCursor,
autocommit=True
)
else:
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def execute_query(query: str, params: tuple = (), fetch: str = "all", commit: bool = False):
conn = get_connection()
try:
if DB_ENGINE == "mariadb":
# MariaDB uses %s instead of ?
maria_query = query.replace("?", "%s")
with conn.cursor() as cursor:
cursor.execute(maria_query, params)
last_id = cursor.lastrowid
rowcount = cursor.rowcount
if fetch == "all":
rows = cursor.fetchall()
return rows
elif fetch == "one":
return cursor.fetchone()
else:
return {"lastrowid": last_id, "rowcount": rowcount}
else:
cursor = conn.cursor()
cursor.execute(query, params)
if commit:
conn.commit()
last_id = cursor.lastrowid
rowcount = cursor.rowcount
if fetch == "all":
rows = [dict(r) for r in cursor.fetchall()]
return rows
elif fetch == "one":
row = cursor.fetchone()
return dict(row) if row else None
else:
return {"lastrowid": last_id, "rowcount": rowcount}
finally:
conn.close()
def init_db():
conn = get_connection()
try:
if DB_ENGINE == "mariadb":
with conn.cursor() as cursor:
cursor.execute("""
CREATE TABLE IF NOT EXISTS dishes (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL UNIQUE,
category VARCHAR(100) NOT NULL,
effort VARCHAR(20) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_dish_cat (category),
INDEX idx_dish_eff (effort)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS recipes (
id INT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(255) NOT NULL,
category VARCHAR(100) NOT NULL,
chapter_number INT NOT NULL,
effort VARCHAR(20) NOT NULL DEFAULT 'mittel',
prep_time VARCHAR(50) DEFAULT '',
cook_time VARCHAR(50) DEFAULT '',
portions INT DEFAULT 4,
ingredients LONGTEXT NOT NULL,
instructions LONGTEXT NOT NULL,
notes TEXT,
source VARCHAR(255) DEFAULT '',
image_url VARCHAR(500) DEFAULT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_recipe_chapter (chapter_number),
INDEX idx_recipe_cat (category)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
""")
# Migration check for MariaDB
cursor.execute("""
SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = %s AND TABLE_NAME = 'recipes' AND COLUMN_NAME = 'source'
""", (MARIADB_NAME,))
if not cursor.fetchone():
cursor.execute("ALTER TABLE recipes ADD COLUMN source VARCHAR(255) DEFAULT ''")
cursor.execute("""
SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = %s AND TABLE_NAME = 'recipes' AND COLUMN_NAME = 'printed_at'
""", (MARIADB_NAME,))
if not cursor.fetchone():
cursor.execute("ALTER TABLE recipes ADD COLUMN printed_at DATETIME NULL DEFAULT NULL AFTER image_url")
cursor.execute("""
SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = %s AND TABLE_NAME = 'recipes' AND COLUMN_NAME = 'has_portion_table'
""", (MARIADB_NAME,))
if not cursor.fetchone():
cursor.execute("ALTER TABLE recipes ADD COLUMN has_portion_table TINYINT(1) NOT NULL DEFAULT 0 AFTER portions")
cursor.execute("""
CREATE TABLE IF NOT EXISTS meal_plans (
id INT AUTO_INCREMENT PRIMARY KEY,
year INT NOT NULL,
week INT NOT NULL,
day_of_week INT NOT NULL,
meal_type VARCHAR(20) NOT NULL,
recipe_id INT NULL,
custom_text VARCHAR(255) NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_meal_slot (year, week, day_of_week, meal_type),
INDEX idx_plan_week (year, week)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
""")
cursor.execute("DELETE FROM dishes")
else:
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS dishes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE COLLATE NOCASE,
category TEXT NOT NULL,
effort TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS recipes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
category TEXT NOT NULL,
chapter_number INTEGER NOT NULL,
effort TEXT NOT NULL DEFAULT 'mittel',
prep_time TEXT DEFAULT '',
cook_time TEXT DEFAULT '',
portions INTEGER DEFAULT 4,
has_portion_table INTEGER DEFAULT 0,
ingredients TEXT NOT NULL,
instructions TEXT NOT NULL,
notes TEXT DEFAULT '',
source TEXT DEFAULT '',
image_url TEXT DEFAULT '',
printed_at TIMESTAMP NULL DEFAULT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
# Migration check for SQLite
cursor.execute("PRAGMA table_info(recipes)")
existing_cols = [c[1] for c in cursor.fetchall()]
if "source" not in existing_cols:
cursor.execute("ALTER TABLE recipes ADD COLUMN source TEXT DEFAULT ''")
if "printed_at" not in existing_cols:
cursor.execute("ALTER TABLE recipes ADD COLUMN printed_at TIMESTAMP NULL DEFAULT NULL")
if "has_portion_table" not in existing_cols:
cursor.execute("ALTER TABLE recipes ADD COLUMN has_portion_table INTEGER DEFAULT 0")
cursor.execute("""
CREATE TABLE IF NOT EXISTS meal_plans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
year INTEGER NOT NULL,
week INTEGER NOT NULL,
day_of_week INTEGER NOT NULL,
meal_type TEXT NOT NULL,
recipe_id INTEGER NULL,
custom_text TEXT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE (year, week, day_of_week, meal_type)
)
""")
cursor.execute("DELETE FROM dishes")
conn.commit()
finally:
conn.close()
# --- Dishes (Zufallsgenerator) ---
def get_all_dishes(category: Optional[str] = None, effort: Optional[str] = None, search: Optional[str] = None) -> List[Dict[str, Any]]:
query = "SELECT id, name, category, effort, created_at FROM dishes WHERE 1=1"
params = []
if category and category.strip() and category != "alle":
query += " AND category = ?"
params.append(category.strip())
if effort and effort.strip() and effort != "alle":
query += " AND effort = ?"
params.append(effort.strip())
if search and search.strip():
query += " AND name LIKE ?"
params.append(f"%{search.strip()}%")
query += " ORDER BY category ASC, name ASC"
return execute_query(query, tuple(params), fetch="all")
def add_dish(name: str, category: str, effort: str) -> Dict[str, Any]:
name = name.strip()
category = category.strip()
effort = effort.strip()
if not name:
raise ValueError("Name darf nicht leer sein.")
if category not in CATEGORIES:
raise ValueError(f"Ungültige Kategorie '{category}'. Gültig sind: {', '.join(CATEGORIES)}")
if effort not in EFFORTS:
raise ValueError(f"Ungültiger Aufwand '{effort}'. Gültig sind: {', '.join(EFFORTS)}")
try:
res = execute_query(
"INSERT INTO dishes (name, category, effort) VALUES (?, ?, ?)",
(name, category, effort),
fetch="none",
commit=True
)
dish_id = res["lastrowid"]
return execute_query("SELECT id, name, category, effort, created_at FROM dishes WHERE id = ?", (dish_id,), fetch="one")
except Exception as e:
if "UNIQUE" in str(e).upper() or "DUPLICATE" in str(e).upper():
raise ValueError(f"Ein Gericht mit dem Namen '{name}' existiert bereits.")
raise
def update_dish(dish_id: int, name: str, category: str, effort: str) -> Dict[str, Any]:
name = name.strip()
category = category.strip()
effort = effort.strip()
if not name:
raise ValueError("Name darf nicht leer sein.")
if category not in CATEGORIES:
raise ValueError(f"Ungültige Kategorie '{category}'.")
if effort not in EFFORTS:
raise ValueError(f"Ungültiger Aufwand '{effort}'.")
try:
res = execute_query(
"UPDATE dishes SET name = ?, category = ?, effort = ? WHERE id = ?",
(name, category, effort, dish_id),
fetch="none",
commit=True
)
if res["rowcount"] == 0:
raise KeyError(f"Gericht #{dish_id} nicht gefunden.")
return execute_query("SELECT id, name, category, effort, created_at FROM dishes WHERE id = ?", (dish_id,), fetch="one")
except Exception as e:
if "UNIQUE" in str(e).upper() or "DUPLICATE" in str(e).upper():
raise ValueError(f"Ein Gericht mit dem Namen '{name}' existiert bereits.")
raise
def delete_dish(dish_id: int) -> bool:
res = execute_query("DELETE FROM dishes WHERE id = ?", (dish_id,), fetch="none", commit=True)
return res["rowcount"] > 0
def clear_all_dishes():
execute_query("DELETE FROM dishes", fetch="none", commit=True)
if DB_ENGINE == "sqlite":
try:
execute_query("DELETE FROM sqlite_sequence WHERE name = 'dishes'", fetch="none", commit=True)
except Exception:
pass
def get_stats() -> Dict[str, Any]:
total_res = execute_query("SELECT COUNT(*) as total FROM dishes", fetch="one")
total = total_res["total"] if total_res else 0
cat_rows = execute_query("SELECT category, COUNT(*) as cnt FROM dishes GROUP BY category", fetch="all")
by_category = {row["category"]: row["cnt"] for row in cat_rows}
eff_rows = execute_query("SELECT effort, COUNT(*) as cnt FROM dishes GROUP BY effort", fetch="all")
by_effort = {row["effort"]: row["cnt"] for row in eff_rows}
recipe_count_res = execute_query("SELECT COUNT(*) as total FROM recipes", fetch="one")
total_recipes = recipe_count_res["total"] if recipe_count_res else 0
return {
"total": total,
"by_category": by_category,
"by_effort": by_effort,
"total_recipes": total_recipes
}
def resolve_category_terms(cat_input: Optional[str]) -> List[str]:
if not cat_input or str(cat_input).strip().lower() in ["", "alle", "beliebig", "none"]:
return []
s = str(cat_input).strip()
chap_num = None
if s.isdigit():
chap_num = int(s)
else:
for c in CHAPTERS:
if s.lower() == c["title"].lower() or s.lower() in c["title"].lower():
chap_num = c["number"]
break
chapter_terms = {
1: ["Suppen", "Eintöpfe", "Suppe", "Eintopf", "Suppen & Eintöpfe", "Chili", "Gulasch"],
2: ["Traditionell & Basics", "Hausmannskost", "Traditionell", "Basics", "Fisch", "Brotzeit", "Klassiker", "Schnitzel", "Rouladen", "Klopse", "Forelle", "Lachs"],
3: ["Pasta & Nudelgerichte", "Nudeln", "Pasta", "Spaghetti", "Penne", "Tagliatelle", "Carbonara", "Lasagne", "Spätzle"],
4: ["Kartoffeln", "Kartoffel", "Gratin", "Pommes", "Püree"],
5: ["Reis & Getreide", "Reis", "Getreide", "Risotto", "Paella"],
6: ["Teig & Gebackenes", "Teigig", "Teig", "Pizza", "Flammkuchen", "Quiche", "Calzone"],
7: ["Asiatische Küche", "asiatisch", "Asiatisch", "Curry", "Pad Thai", "Bami", "Wok", "Ramen", "Sushi"],
8: ["Süßspeisen", "Süßspeise", "Kaiserschmarrn", "Germknödel", "Dampfnudel", "Apfelküchle", "Milchreis"],
9: ["Desserts", "Dessert", "Tiramisu", "Mousse", "Pudding"],
10: ["Kuchen & Gebäck", "Kuchen", "Gebäck", "Torte"],
11: ["Plätzchen", "Kekse", "Keks"]
}
if chap_num and chap_num in chapter_terms:
return chapter_terms[chap_num]
# Legacy category names mapping
legacy_map = {
"nudeln": chapter_terms[3],
"pasta": chapter_terms[3],
"reis": chapter_terms[5],
"kartoffeln": chapter_terms[4],
"teigig": chapter_terms[6],
"suppen": chapter_terms[1],
"eintöpfe": chapter_terms[1],
"eintoepfe": chapter_terms[1],
"hausmannskost": chapter_terms[2],
"asiatisch": chapter_terms[7],
"süßspeisen": chapter_terms[8],
"suessspeisen": chapter_terms[8],
"fisch": ["Fisch", "Forelle", "Lachs", "Kabeljau", "Hausmannskost"]
}
if s.lower() in legacy_map:
return legacy_map[s.lower()]
return [s]
def get_chapter_info_for_dish(dish_category: str = "", dish_name: str = "", recipe_chapter: Optional[int] = None) -> Dict[str, Any]:
if recipe_chapter and 1 <= recipe_chapter <= len(CHAPTERS):
return CHAPTERS[recipe_chapter - 1]
combo = f"{dish_category or ''} {dish_name or ''}".lower()
if any(k in combo for k in ["suppe", "eintopf", "chili", "brühe"]):
return CHAPTERS[0] # 1: Suppen & Eintöpfe
if any(k in combo for k in ["nudel", "pasta", "spaghetti", "penne", "tagliatelle", "carbonara", "bolognese", "spätzle", "lasagne"]):
return CHAPTERS[2] # 3: Pasta & Nudelgerichte
if any(k in combo for k in ["kartoffel", "pommes", "gratin", "püree"]):
return CHAPTERS[3] # 4: Kartoffeln
if any(k in combo for k in ["curry", "pad thai", "bami", "wok", "ramen", "sushi", "asiatisch"]):
return CHAPTERS[6] # 7: Asiatische Küche
if any(k in combo for k in ["reis", "risotto", "paella", "djuvec"]):
return CHAPTERS[4] # 5: Reis & Getreide
if any(k in combo for k in ["pizza", "flammkuchen", "quiche", "calzone", "teig", "brot"]):
return CHAPTERS[5] # 6: Teig & Gebackenes
if any(k in combo for k in ["kuchen", "torte", "muffin", "gebäck"]):
return CHAPTERS[9] # 10: Kuchen & Gebäck
if any(k in combo for k in ["keks", "plätzchen"]):
return CHAPTERS[10] # 11: Plätzchen
if any(k in combo for k in ["tiramisu", "mousse", "pudding", "dessert"]):
return CHAPTERS[8] # 9: Desserts
if any(k in combo for k in ["kaiserschmarrn", "knödel", "apfelküchle", "dampfnudel", "milchreis", "süßspeis"]):
return CHAPTERS[7] # 8: Süßspeisen
return CHAPTERS[1] # 2: Traditionell & Basics
def query_recipe_candidates(chapter_number: Optional[int] = None, efforts: Optional[List[str]] = None) -> List[Dict[str, Any]]:
"""Gibt alle Rezepte aus der recipes-Tabelle zurück, die zu den Shuffle-Kriterien passen."""
all_recipes = get_all_recipes()
if not all_recipes:
return []
# 1. Nach Kapitel filtern (wenn spezifiziert)
if chapter_number is not None and chapter_number > 0:
pool = [r for r in all_recipes if r.get("chapter_number") == chapter_number]
else:
pool = list(all_recipes)
if not pool:
return []
# 2. Nach Aufwand filtern (nur, wenn eine Teilmenge gewählt wurde)
if efforts and len(efforts) > 0 and len(efforts) < len(EFFORTS):
clean_efforts = [e.strip().lower() for e in efforts if e.strip() in EFFORTS]
effort_pool = [r for r in pool if (r.get("effort") or "").lower() in clean_efforts]
if effort_pool:
return effort_pool
return pool
def query_candidates(category: Optional[str], efforts: Optional[List[str]]) -> List[Dict[str, Any]]:
"""Fallback-Funktion für alte Dish-Abfragen"""
return query_recipe_candidates()
# --- Recipes (Kochbuch) ---
def format_printed_date(p_at: Any) -> str:
if not p_at:
return ""
if hasattr(p_at, "strftime"):
return p_at.strftime("%d.%m.%Y")
try:
parts = str(p_at).split(" ")[0].split("-")
if len(parts) == 3:
return f"{parts[2]}.{parts[1]}.{parts[0]}"
except Exception:
pass
return str(p_at)
def add_recipe(
title: str,
category: str,
chapter_number: int,
effort: str = "mittel",
prep_time: str = "",
cook_time: str = "",
portions: int = 4,
ingredients: str = "",
instructions: str = "",
notes: str = "",
source: str = "",
image_url: str = "",
printed_at: Optional[str] = None,
has_portion_table: int = 0
) -> Dict[str, Any]:
title = title.strip()
category = category.strip()
effort = effort.strip().lower()
source = source.strip()
if not title:
raise ValueError("Rezepttitel darf nicht leer sein.")
if not category:
raise ValueError("Kategorie darf nicht leer sein.")
if chapter_number not in [c["number"] for c in CHAPTERS]:
# Fallback to mapped chapter number if possible
chapter_number = CHAPTER_MAP.get(category, 1)
res = execute_query(
"""
INSERT INTO recipes (title, category, chapter_number, effort, prep_time, cook_time, portions, has_portion_table, ingredients, instructions, notes, source, image_url, printed_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(title, category, chapter_number, effort, prep_time, cook_time, portions, int(bool(has_portion_table)), ingredients, instructions, notes, source, image_url, printed_at),
fetch="none",
commit=True
)
recipe_id = res["lastrowid"]
return get_recipe(recipe_id)
def update_recipe(
recipe_id: int,
title: str,
category: str,
chapter_number: int,
effort: str = "mittel",
prep_time: str = "",
cook_time: str = "",
portions: int = 4,
ingredients: str = "",
instructions: str = "",
notes: str = "",
source: str = "",
image_url: Optional[str] = None,
printed_at: Optional[str] = None,
has_portion_table: Optional[int] = None
) -> Dict[str, Any]:
title = title.strip()
category = category.strip()
effort = effort.strip().lower()
source = source.strip()
if not title:
raise ValueError("Rezepttitel darf nicht leer sein.")
existing = get_recipe(recipe_id)
if not existing:
raise KeyError(f"Rezept #{recipe_id} nicht gefunden.")
use_portion_table = int(bool(has_portion_table)) if has_portion_table is not None else existing.get("has_portion_table", 0)
if image_url is not None:
query = """
UPDATE recipes
SET title = ?, category = ?, chapter_number = ?, effort = ?, prep_time = ?, cook_time = ?, portions = ?, has_portion_table = ?, ingredients = ?, instructions = ?, notes = ?, source = ?, image_url = ?, printed_at = ?
WHERE id = ?
"""
params = (title, category, chapter_number, effort, prep_time, cook_time, portions, use_portion_table, ingredients, instructions, notes, source, image_url, printed_at, recipe_id)
else:
query = """
UPDATE recipes
SET title = ?, category = ?, chapter_number = ?, effort = ?, prep_time = ?, cook_time = ?, portions = ?, has_portion_table = ?, ingredients = ?, instructions = ?, notes = ?, source = ?, printed_at = ?
WHERE id = ?
"""
params = (title, category, chapter_number, effort, prep_time, cook_time, portions, use_portion_table, ingredients, instructions, notes, source, printed_at, recipe_id)
res = execute_query(query, params, fetch="none", commit=True)
return get_recipe(recipe_id)
def delete_recipe(recipe_id: int) -> bool:
res = execute_query("DELETE FROM recipes WHERE id = ?", (recipe_id,), fetch="none", commit=True)
return res["rowcount"] > 0
def get_recipe(recipe_id: int) -> Optional[Dict[str, Any]]:
row = execute_query("SELECT * FROM recipes WHERE id = ?", (recipe_id,), fetch="one")
if row:
row["printed_at_formatted"] = format_printed_date(row.get("printed_at"))
row["has_portion_table"] = bool(row.get("has_portion_table", 0))
return row
def get_all_recipes(
chapter_number: Optional[int] = None,
category: Optional[str] = None,
search: Optional[str] = None,
printed_status: Optional[str] = None
) -> List[Dict[str, Any]]:
query = "SELECT * FROM recipes WHERE 1=1"
params = []
if chapter_number is not None and chapter_number > 0:
query += " AND chapter_number = ?"
params.append(chapter_number)
if category and category.strip() and category != "alle":
query += " AND category = ?"
params.append(category.strip())
if search and search.strip():
query += " AND (title LIKE ? OR ingredients LIKE ? OR instructions LIKE ?)"
s = f"%{search.strip()}%"
params.extend([s, s, s])
if printed_status == "unprinted":
query += " AND printed_at IS NULL"
elif printed_status == "printed":
query += " AND printed_at IS NOT NULL"
query += " ORDER BY chapter_number ASC, title ASC"
rows = execute_query(query, tuple(params), fetch="all")
for r in rows:
r["printed_at_formatted"] = format_printed_date(r.get("printed_at"))
r["has_portion_table"] = bool(r.get("has_portion_table", 0))
return rows
def mark_recipes_printed(recipe_ids: List[int], print_date: Optional[str] = None) -> int:
"""Markiert die angegebenen Rezepte als gedruckt."""
if not recipe_ids:
return 0
date_val = print_date or datetime.now().strftime("%Y-%m-%d %H:%M:%S")
placeholders = ", ".join(["?"] * len(recipe_ids))
query = f"UPDATE recipes SET printed_at = ? WHERE id IN ({placeholders})"
params = [date_val] + list(recipe_ids)
res = execute_query(query, tuple(params), fetch="none", commit=True)
return res.get("rowcount", 0)
def unmark_recipes_printed(recipe_ids: List[int]) -> int:
"""Entfernt den Gedruckt-Status von den angegebenen Rezepten."""
if not recipe_ids:
return 0
placeholders = ", ".join(["?"] * len(recipe_ids))
query = f"UPDATE recipes SET printed_at = NULL WHERE id IN ({placeholders})"
res = execute_query(query, tuple(recipe_ids), fetch="none", commit=True)
return res.get("rowcount", 0)
def toggle_recipe_printed(recipe_id: int) -> Dict[str, Any]:
"""Schaltet den Druckstatus eines Rezepts um."""
rec = get_recipe(recipe_id)
if not rec:
raise KeyError(f"Rezept #{recipe_id} nicht gefunden.")
if rec.get("printed_at"):
unmark_recipes_printed([recipe_id])
else:
mark_recipes_printed([recipe_id])
return get_recipe(recipe_id)
# ==========================================
# WOCHENPLAN (MEAL PLANNER)
# ==========================================
def get_meal_plan(year: int, week: int) -> List[Dict[str, Any]]:
"""Gibt alle Einträge des Wochenplans für die angegebene KW zurück."""
query = """
SELECT
m.id, m.year, m.week, m.day_of_week, m.meal_type, m.recipe_id, m.custom_text,
r.title AS recipe_title, r.image_url AS recipe_image_url, r.chapter_number AS recipe_chapter_number,
r.category AS recipe_category
FROM meal_plans m
LEFT JOIN recipes r ON m.recipe_id = r.id
WHERE m.year = ? AND m.week = ?
ORDER BY m.day_of_week ASC,
CASE m.meal_type
WHEN 'frueh' THEN 1
WHEN 'mittag' THEN 2
WHEN 'kaffee' THEN 3
WHEN 'abend' THEN 4
ELSE 5
END ASC
"""
return execute_query(query, (year, week), fetch="all")
def set_meal_plan_slot(
year: int,
week: int,
day_of_week: int,
meal_type: str,
recipe_id: Optional[int] = None,
custom_text: Optional[str] = None
) -> Dict[str, Any]:
"""Setzt oder aktualisiert einen Slot im Wochenplan."""
if DB_ENGINE == "mariadb":
query = """
INSERT INTO meal_plans (year, week, day_of_week, meal_type, recipe_id, custom_text)
VALUES (?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
recipe_id = VALUES(recipe_id),
custom_text = VALUES(custom_text),
updated_at = CURRENT_TIMESTAMP
"""
execute_query(query, (year, week, day_of_week, meal_type, recipe_id, custom_text), fetch="none", commit=True)
else:
query = """
INSERT INTO meal_plans (year, week, day_of_week, meal_type, recipe_id, custom_text, updated_at)
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(year, week, day_of_week, meal_type) DO UPDATE SET
recipe_id = excluded.recipe_id,
custom_text = excluded.custom_text,
updated_at = CURRENT_TIMESTAMP
"""
execute_query(query, (year, week, day_of_week, meal_type, recipe_id, custom_text), fetch="none", commit=True)
slot_query = """
SELECT
m.id, m.year, m.week, m.day_of_week, m.meal_type, m.recipe_id, m.custom_text,
r.title AS recipe_title, r.image_url AS recipe_image_url
FROM meal_plans m
LEFT JOIN recipes r ON m.recipe_id = r.id
WHERE m.year = ? AND m.week = ? AND m.day_of_week = ? AND m.meal_type = ?
"""
return execute_query(slot_query, (year, week, day_of_week, meal_type), fetch="one")
def delete_meal_plan_slot(year: int, week: int, day_of_week: int, meal_type: str) -> bool:
"""Löscht einen einzelnen Slot aus dem Wochenplan."""
query = "DELETE FROM meal_plans WHERE year = ? AND week = ? AND day_of_week = ? AND meal_type = ?"
res = execute_query(query, (year, week, day_of_week, meal_type), fetch="none", commit=True)
return res.get("rowcount", 0) > 0
def clear_meal_plan_week(year: int, week: int) -> int:
"""Leert den gesamten Wochenplan für eine Kalenderwoche."""
query = "DELETE FROM meal_plans WHERE year = ? AND week = ?"
res = execute_query(query, (year, week), fetch="none", commit=True)
return res.get("rowcount", 0)
+183
View File
@@ -0,0 +1,183 @@
# -*- coding: utf-8 -*-
"""
Helper module for ingredient parsing, table structuring, and quantity scaling.
"""
import re
from typing import List, Dict, Any, Optional
COMMON_UNITS = [
"kg", "g", "mg", "l", "liter", "ml", "cl", "el", "tl", "esslöffel", "teelöffel",
"pck.", "pck", "päckchen", "pkt.", "pkt", "dose", "dosen", "glas", "gläser",
"prise", "prisen", "msp.", "msp", "messerspitze", "bund", "stk.", "stk",
"stück", "stücke", "zehe", "zehen", "zweig", "zweige", "blatt", "blätter",
"scheibe", "scheiben", "stange", "stangen", "kopf", "nest", "nester", "tropfen", "tr."
]
def parse_fraction(val_str: str) -> Optional[float]:
val_str = val_str.strip()
if "/" in val_str:
parts = val_str.split("/")
if len(parts) == 2:
try:
return float(parts[0]) / float(parts[1])
except ValueError:
pass
try:
return float(val_str.replace(",", "."))
except ValueError:
return None
def parse_ingredient_line(line: str) -> Dict[str, Any]:
raw = line.strip()
if not raw:
return {"type": "empty", "raw": ""}
# Check if section header (ends with colon or keywords)
lower = raw.lower()
if raw.endswith(":") or lower.startswith("für ") or lower in [
"teig", "sauce", "soße", "füllung", "belag", "creme", "glasur", "marinade", "deko",
"schichten & deko", "keksschicht", "heidelbeersoße", "zitronen-zimt-sirup", "zutaten",
"einlage & toppings", "gemüse & toppings", "lemon curd", "mascarponecreme"
]:
hdr_text = raw.rstrip(":")
return {
"type": "header",
"is_header": True,
"text": hdr_text,
"name": hdr_text,
"raw": raw
}
# Regex to match leading amount
match = re.match(r"^(\d+\s+\d+/\d+|\d+/\d+|\d+(?:[.,]\d+)?)(?:\s*-\s*(\d+(?:[.,]\d+)?))?\s*(.*)$", raw)
if match:
amt_str = match.group(1).strip()
range_end = match.group(2)
rest = match.group(3).strip()
if " " in amt_str and "/" in amt_str:
whole, frac = amt_str.split()
val = float(whole) + (parse_fraction(frac) or 0.0)
elif "/" in amt_str:
val = parse_fraction(amt_str) or 0.0
else:
val = parse_fraction(amt_str) or 0.0
display_amt = f"{amt_str}-{range_end}" if range_end else amt_str
rest_parts = rest.split(None, 1)
if rest_parts:
first_word_clean = rest_parts[0].lower().rstrip(".,")
if first_word_clean in COMMON_UNITS or rest_parts[0].lower() in COMMON_UNITS:
unit = rest_parts[0]
ing_name = rest_parts[1] if len(rest_parts) > 1 else ""
return {
"type": "ingredient",
"is_header": False,
"amount_val": val,
"amount": val,
"amount_str": display_amt,
"amount_formatted": display_amt,
"unit": unit,
"name": ing_name,
"raw": raw
}
return {
"type": "ingredient",
"is_header": False,
"amount_val": val,
"amount": val,
"amount_str": display_amt,
"amount_formatted": display_amt,
"unit": "",
"name": rest,
"raw": raw
}
return {
"type": "ingredient",
"is_header": False,
"amount_val": None,
"amount": None,
"amount_str": "",
"amount_formatted": "",
"unit": "",
"name": raw,
"raw": raw
}
def parse_ingredients_block(text: str) -> List[Dict[str, Any]]:
lines = [l.strip() for l in (text or "").split("\n") if l.strip()]
return [parse_ingredient_line(l) for l in lines]
def format_quantity_py(num: Optional[float]) -> str:
"""Formatiert eine Zahl lesbar für Rezepte (z. B. 0.5 -> ½, 1.5 -> 1 ½, 2.0 -> 2)."""
if num is None or num <= 0:
return ""
whole = int(num)
frac = round((num - whole) * 100) / 100.0
frac_str = ""
if 0.20 <= frac <= 0.30:
frac_str = "¼"
elif 0.31 <= frac <= 0.40:
frac_str = "⅓"
elif 0.45 <= frac <= 0.55:
frac_str = "½"
elif 0.63 <= frac <= 0.70:
frac_str = "⅔"
elif 0.72 <= frac <= 0.80:
frac_str = "¾"
if frac_str:
return f"{whole} {frac_str}".strip() if whole > 0 else frac_str
if abs(num - round(num)) < 0.05:
return str(int(round(num)))
# Deutsche Kommaschreibweise
val_str = f"{num:.1f}".rstrip("0").rstrip(".").replace(".", ",")
return val_str
def calculate_portion_matrix(
parsed_ingredients: List[Dict[str, Any]],
base_portions: int = 4,
targets: tuple = (2, 4, 6, 8)
) -> List[Dict[str, Any]]:
"""Berechnet für Waffeln / Pancakes etc. eine vorberechnete Tabelle für 2, 4, 6, 8 Portionen."""
if not base_portions or base_portions <= 0:
base_portions = 4
matrix = []
for item in parsed_ingredients:
if item.get("is_header"):
matrix.append({
"is_header": True,
"text": item.get("text") or item.get("name") or "",
"amounts": {p: "" for p in targets}
})
continue
row_amounts = {}
for p in targets:
amt_val = item.get("amount_val")
unit = item.get("unit") or ""
if amt_val is not None:
scaled_val = amt_val * (p / float(base_portions))
formatted_amt = format_quantity_py(scaled_val)
row_amounts[p] = f"{formatted_amt} {unit}".strip()
else:
row_amounts[p] = "—"
matrix.append({
"is_header": False,
"name": item.get("name") or item.get("raw") or "",
"raw": item.get("raw", ""),
"amounts": row_amounts
})
return matrix
Executable
+856
View File
@@ -0,0 +1,856 @@
import os
import uuid
import random
import shutil
import time
import hmac
import hashlib
from typing import List, Optional, Dict, Any
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException, Request, Query, Form, File, UploadFile
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from datetime import datetime, date, timedelta
from pydantic import BaseModel, Field
from .ingredients import parse_ingredients_block, calculate_portion_matrix
from .database import (
init_db,
get_all_dishes,
add_dish,
update_dish,
delete_dish,
get_stats,
query_candidates,
query_recipe_candidates,
CATEGORIES,
EFFORTS,
CHAPTERS,
CHAPTER_MAP,
get_all_recipes,
get_recipe,
add_recipe,
update_recipe,
delete_recipe,
get_chapter_info_for_dish,
mark_recipes_printed,
unmark_recipes_printed,
toggle_recipe_printed,
get_meal_plan,
set_meal_plan_slot,
delete_meal_plan_slot,
clear_meal_plan_week
)
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
STATIC_DIR = os.path.join(BASE_DIR, "static")
TEMPLATES_DIR = os.path.join(BASE_DIR, "templates")
# --- Authentifizierung & Privater Modus ---
APP_USERNAME = os.getenv("APP_USERNAME", "admin")
APP_PASSWORD = os.getenv("APP_PASSWORD", "wtc2026!")
SECRET_KEY = os.getenv("SECRET_KEY", "wtc_cookbook_secret_salt_2026_supersecure")
COOKIE_NAME = "wtc_auth"
PUBLIC_PATHS = {"/login", "/api/login", "/favicon.ico"}
def create_auth_token(username: str, days: int = 30) -> str:
expiry = int(time.time()) + (days * 86400)
msg = f"{username}:{expiry}"
sig = hmac.new(SECRET_KEY.encode("utf-8"), msg.encode("utf-8"), hashlib.sha256).hexdigest()
return f"{username}:{expiry}:{sig}"
def verify_auth_token(token: Optional[str]) -> bool:
if not token:
return False
parts = token.split(":")
if len(parts) != 3:
return False
user, expiry_str, sig = parts
try:
expiry = int(expiry_str)
except ValueError:
return False
if time.time() > expiry:
return False
msg = f"{user}:{expiry}"
expected_sig = hmac.new(SECRET_KEY.encode("utf-8"), msg.encode("utf-8"), hashlib.sha256).hexdigest()
return hmac.compare_digest(sig, expected_sig)
@asynccontextmanager
async def lifespan(app: FastAPI):
init_db()
os.makedirs(os.path.join(STATIC_DIR, "uploads", "recipes"), exist_ok=True)
yield
app = FastAPI(title="What To Cook? - Zufallsgenerator & Kochbuch", lifespan=lifespan)
# Middleware für Website-weiten Schutz
@app.middleware("http")
async def auth_middleware(request: Request, call_next):
path = request.url.path
if path.startswith("/static/") or path in PUBLIC_PATHS:
return await call_next(request)
token = request.cookies.get(COOKIE_NAME)
if not verify_auth_token(token):
if path.startswith("/api/"):
return JSONResponse(
status_code=401,
content={"status": "unauthorized", "message": "Bitte anmelden."}
)
next_target = request.url.path
if request.url.query:
next_target += f"?{request.url.query}"
return RedirectResponse(url=f"/login?next={next_target}", status_code=303)
return await call_next(request)
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
templates = Jinja2Templates(directory=TEMPLATES_DIR)
# Mapping Kochbuch-Kapitel -> Zufallsgenerator-Kategorien
CHAPTER_TO_DISH_CAT = {
"Suppen & Eintöpfe": "Suppen",
"Traditionell & Basics": "Hausmannskost",
"Pasta & Nudelgerichte": "Nudeln",
"Kartoffeln": "Kartoffeln",
"Reis & Getreide": "Reis",
"Teig & Gebackenes": "Teigig",
"Asiatische Küche": "asiatisch",
"Süßspeisen": "Süßspeisen",
"Desserts": "Süßspeisen",
"Kuchen & Gebäck": "Süßspeisen",
"Plätzchen": "Süßspeisen"
}
# --- Pydantic Models ---
class SlotRollRequest(BaseModel):
slot_number: int = Field(..., ge=1, le=5)
chapter_number: Optional[int] = None
category: Optional[str] = None
efforts: Optional[List[str]] = None
current_dish_id: Optional[int] = None
class MultiRollRequest(BaseModel):
slots: List[SlotRollRequest]
class SingleRollRequest(BaseModel):
slot_number: int = Field(..., ge=1, le=5)
chapter_number: Optional[int] = None
category: Optional[str] = None
efforts: Optional[List[str]] = None
current_dish_id: Optional[int] = None
other_dish_ids: Optional[List[int]] = Field(default_factory=list)
class DishCreate(BaseModel):
name: str
category: Optional[str] = None
effort: str
class DishUpdate(BaseModel):
name: str
category: Optional[str] = None
effort: str
class MarkPrintedRequest(BaseModel):
recipe_ids: List[int]
printed_at: Optional[str] = None
class UnmarkPrintedRequest(BaseModel):
recipe_ids: List[int]
class MealPlanSlotRequest(BaseModel):
year: int
week: int
day_of_week: int
meal_type: str
recipe_id: Optional[int] = None
custom_text: Optional[str] = None
# ==========================================
# AUTH VIEWS
# ==========================================
@app.get("/login", response_class=HTMLResponse)
async def login_get(request: Request, next: str = "/cookbook"):
token = request.cookies.get(COOKIE_NAME)
if verify_auth_token(token):
return RedirectResponse(url=next or "/cookbook", status_code=303)
return templates.TemplateResponse(
request=request,
name="login.html",
context={"next_url": next, "error": None, "username": APP_USERNAME}
)
@app.post("/login", response_class=HTMLResponse)
async def login_post(
request: Request,
username: str = Form("admin"),
password: str = Form(...),
remember: Optional[str] = Form(None),
next: str = Form("/cookbook")
):
valid_user = (username.strip().lower() == APP_USERNAME.lower()) or (username.strip() == "admin")
valid_pass = (password == APP_PASSWORD)
if valid_user and valid_pass:
days = 30 if remember else 1
token = create_auth_token(username.strip(), days=days)
target = next if next and not next.startswith("/login") else "/cookbook"
resp = RedirectResponse(url=target, status_code=303)
max_age = days * 86400
resp.set_cookie(
key=COOKIE_NAME,
value=token,
max_age=max_age,
expires=max_age,
path="/",
httponly=True,
samesite="lax"
)
return resp
else:
return templates.TemplateResponse(
request=request,
name="login.html",
context={
"next_url": next,
"error": "Ungültiges Passwort oder Benutzername.",
"username": username
},
status_code=400
)
@app.get("/logout")
async def logout():
resp = RedirectResponse(url="/login", status_code=303)
resp.delete_cookie(key=COOKIE_NAME, path="/")
return resp
# ==========================================
# VIEWS
# ==========================================
@app.get("/")
async def index_view():
"""Hauptroute leitet direkt auf das Kochbuch um"""
return RedirectResponse(url="/cookbook", status_code=302)
@app.get("/cookbook", response_class=HTMLResponse)
async def cookbook_view(
request: Request,
chapter: Optional[int] = Query(None)
):
"""Kochbuch Übersicht mit Kapiteln, Inhaltsverzeichnis und Rezepten"""
all_recipes = get_all_recipes()
# Berechne Portions-Matrix nur für Waffeln / ausgewählte Rezepte
for r in all_recipes:
is_matrix = bool(r.get("has_portion_table")) or any(k in (r.get("title") or "").lower() for k in ["waffel", "pancake", "pfannkuchen"])
r["has_portion_table"] = is_matrix
if is_matrix:
parsed = parse_ingredients_block(r.get("ingredients", ""))
r["portion_matrix"] = calculate_portion_matrix(parsed, base_portions=r.get("portions") or 4, targets=(2, 4, 6, 8))
# Gruppieren nach Kapitel
recipes_by_chapter: Dict[int, List[Dict[str, Any]]] = {}
for r in all_recipes:
chap_num = r.get("chapter_number", 1)
if chap_num not in recipes_by_chapter:
recipes_by_chapter[chap_num] = []
recipes_by_chapter[chap_num].append(r)
recipe_counts = {c["number"]: len(recipes_by_chapter.get(c["number"], [])) for c in CHAPTERS}
if chapter is not None and chapter > 0:
chapters_to_display = [c for c in CHAPTERS if c["number"] == chapter]
else:
chapters_to_display = CHAPTERS
# --- Seitenzahlen & Inhaltsverzeichnis (TOC) berechnen ---
# Seite 1: Cover
# TOC: 1 oder 2 Seiten (wenn > 20 Rezepte vorhanden sind)
total_display_recipes = len(all_recipes)
toc_page_count = 2 if total_display_recipes > 20 else (1 if total_display_recipes > 0 else 0)
current_page = 1 + toc_page_count + 1
display_chapters_data = []
for chap in chapters_to_display:
chap_copy = dict(chap)
chap_recipes = recipes_by_chapter.get(chap["number"], [])
if not chap_recipes:
display_chapters_data.append(chap_copy)
continue
chap_copy["page_number"] = current_page
current_page += 1
for r in chap_recipes:
r["page_number"] = current_page
current_page += 1
chap_copy["recipes"] = chap_recipes
display_chapters_data.append(chap_copy)
# Inhaltsverzeichnis auf 2 Seiten aufteilen
toc_p1 = []
toc_p2 = []
if toc_page_count == 2:
accum = 0
for c in display_chapters_data:
c_recs = recipes_by_chapter.get(c["number"], [])
if not c_recs:
continue
item_weight = len(c_recs) + 2
if accum < 28 or not toc_p1:
toc_p1.append(c)
accum += item_weight
else:
toc_p2.append(c)
else:
toc_p1 = [c for c in display_chapters_data if recipes_by_chapter.get(c["number"], [])]
# User Agent Check für Apple iOS
user_agent = request.headers.get("user-agent", "").lower()
is_ios = any(d in user_agent for d in ["iphone", "ipad", "ipod"])
return templates.TemplateResponse(
request=request,
name="cookbook.html",
context={
"chapters": CHAPTERS,
"current_chapter": chapter,
"chapters_to_display": display_chapters_data,
"recipes_by_chapter": recipes_by_chapter,
"recipe_counts": recipe_counts,
"total_recipes": len(all_recipes),
"toc_page_count": toc_page_count,
"toc_p1": toc_p1,
"toc_p2": toc_p2,
"is_ios": is_ios
}
)
@app.get("/cookbook/recipe/{recipe_id}", response_class=HTMLResponse)
async def recipe_detail_view(request: Request, recipe_id: int):
"""Einheitliche Rezeptseite im handgezeichneten Stil"""
recipe = get_recipe(recipe_id)
if not recipe:
raise HTTPException(status_code=404, detail="Rezept nicht gefunden.")
is_matrix = bool(recipe.get("has_portion_table")) or any(k in (recipe.get("title") or "").lower() for k in ["waffel", "pancake", "pfannkuchen"])
recipe["has_portion_table"] = is_matrix
if is_matrix:
parsed = parse_ingredients_block(recipe.get("ingredients", ""))
recipe["portion_matrix"] = calculate_portion_matrix(parsed, base_portions=recipe.get("portions") or 4, targets=(2, 4, 6, 8))
return templates.TemplateResponse(
request=request,
name="recipe_detail.html",
context={
"recipe": recipe,
"chapters": CHAPTERS
}
)
@app.get("/cookbook/new", response_class=HTMLResponse)
async def recipe_new_view(request: Request, chapter: Optional[int] = Query(None)):
"""Eingabemaske für neues Rezept"""
return templates.TemplateResponse(
request=request,
name="recipe_form.html",
context={
"recipe": None,
"chapters": CHAPTERS,
"selected_chapter": chapter
}
)
@app.get("/cookbook/edit/{recipe_id}", response_class=HTMLResponse)
async def recipe_edit_view(request: Request, recipe_id: int):
"""Eingabemaske zum Bearbeiten eines Rezepts"""
recipe = get_recipe(recipe_id)
if not recipe:
raise HTTPException(status_code=404, detail="Rezept nicht gefunden.")
return templates.TemplateResponse(
request=request,
name="recipe_form.html",
context={
"recipe": recipe,
"chapters": CHAPTERS,
"selected_chapter": recipe.get("chapter_number")
}
)
# ==========================================
# API: KOCHBUCH (RECIPES)
# ==========================================
def save_uploaded_image(image_file: UploadFile) -> Optional[str]:
if not image_file or not image_file.filename:
return None
ext = os.path.splitext(image_file.filename)[1].lower()
if ext not in [".jpg", ".jpeg", ".png", ".webp", ".gif"]:
return None
upload_dir = os.path.join(STATIC_DIR, "uploads", "recipes")
os.makedirs(upload_dir, exist_ok=True)
safe_name = f"{uuid.uuid4().hex[:10]}_{os.path.basename(image_file.filename)}"
file_path = os.path.join(upload_dir, safe_name)
with open(file_path, "wb") as buffer:
shutil.copyfileobj(image_file.file, buffer)
return f"/static/uploads/recipes/{safe_name}"
@app.get("/api/recipes")
def api_get_recipes(
chapter: Optional[int] = Query(None),
category: Optional[str] = Query(None),
search: Optional[str] = Query(None),
printed: Optional[str] = Query(None)
):
return get_all_recipes(chapter_number=chapter, category=category, search=search, printed_status=printed)
@app.get("/api/recipes/{recipe_id}")
def api_get_recipe(recipe_id: int):
recipe = get_recipe(recipe_id)
if not recipe:
raise HTTPException(status_code=404, detail="Rezept nicht gefunden.")
recipe["parsed_ingredients"] = parse_ingredients_block(recipe.get("ingredients", ""))
return recipe
@app.post("/api/recipes")
async def api_create_recipe(
title: str = Form(...),
chapter_number: int = Form(...),
effort: str = Form("mittel"),
prep_time: str = Form(""),
cook_time: str = Form(""),
portions: int = Form(4),
ingredients: str = Form(...),
instructions: str = Form(...),
notes: str = Form(""),
source: str = Form(""),
printed_at: Optional[str] = Form(None),
has_portion_table: Optional[int] = Form(0),
add_to_dishes: Optional[str] = Form(None),
image_file: Optional[UploadFile] = File(None)
):
chapter_info = next((c for c in CHAPTERS if c["number"] == chapter_number), None)
category_title = chapter_info["title"] if chapter_info else "Traditionell & Basics"
image_url = ""
if image_file and image_file.filename:
image_url = save_uploaded_image(image_file) or ""
try:
recipe = add_recipe(
title=title,
category=category_title,
chapter_number=chapter_number,
effort=effort,
prep_time=prep_time,
cook_time=cook_time,
portions=portions,
ingredients=ingredients,
instructions=instructions,
notes=notes,
source=source,
image_url=image_url,
printed_at=printed_at if printed_at and printed_at.strip() else None,
has_portion_table=1 if (has_portion_table and str(has_portion_table).strip() in ("1", "true", "on", "yes")) else 0
)
should_add = bool(add_to_dishes and str(add_to_dishes).strip().lower() in ("true", "on", "1", "yes"))
if should_add:
dish_cat = CHAPTER_TO_DISH_CAT.get(category_title, "Hausmannskost")
dish_effort = effort if effort in EFFORTS else "mittel"
try:
add_dish(name=title, category=dish_cat, effort=dish_effort)
except Exception:
pass
return {"status": "ok", "recipe": recipe}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@app.put("/api/recipes/{recipe_id}")
async def api_update_recipe(
recipe_id: int,
title: str = Form(...),
chapter_number: int = Form(...),
effort: str = Form("mittel"),
prep_time: str = Form(""),
cook_time: str = Form(""),
portions: int = Form(4),
ingredients: str = Form(...),
instructions: str = Form(...),
notes: str = Form(""),
source: str = Form(""),
printed_at: Optional[str] = Form(None),
has_portion_table: Optional[int] = Form(None),
image_file: Optional[UploadFile] = File(None)
):
chapter_info = next((c for c in CHAPTERS if c["number"] == chapter_number), None)
category_title = chapter_info["title"] if chapter_info else "Traditionell & Basics"
new_image_url = None
if image_file and image_file.filename:
new_image_url = save_uploaded_image(image_file)
try:
table_val = 1 if (has_portion_table and str(has_portion_table).strip() in ("1", "true", "on", "yes")) else (0 if has_portion_table is not None else None)
recipe = update_recipe(
recipe_id=recipe_id,
title=title,
category=category_title,
chapter_number=chapter_number,
effort=effort,
prep_time=prep_time,
cook_time=cook_time,
portions=portions,
ingredients=ingredients,
instructions=instructions,
notes=notes,
source=source,
image_url=new_image_url,
printed_at=printed_at if printed_at and printed_at.strip() else None,
has_portion_table=table_val
)
return {"status": "ok", "recipe": recipe}
except KeyError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@app.delete("/api/recipes/{recipe_id}")
def api_delete_recipe(recipe_id: int):
success = delete_recipe(recipe_id)
if not success:
raise HTTPException(status_code=404, detail="Rezept nicht gefunden.")
return {"status": "ok", "deleted": True}
# --- API: Druckstatus & Druckdatum ---
@app.post("/api/recipes/mark-printed")
def api_mark_recipes_printed(payload: MarkPrintedRequest):
count = mark_recipes_printed(payload.recipe_ids, payload.printed_at)
return {"status": "ok", "marked_count": count}
@app.post("/api/recipes/unmark-printed")
def api_unmark_recipes_printed(payload: UnmarkPrintedRequest):
count = unmark_recipes_printed(payload.recipe_ids)
return {"status": "ok", "unmarked_count": count}
@app.post("/api/recipes/{recipe_id}/toggle-printed")
def api_toggle_recipe_printed(recipe_id: int):
try:
updated = toggle_recipe_printed(recipe_id)
return {"status": "ok", "recipe": updated}
except KeyError as e:
raise HTTPException(status_code=404, detail=str(e))
# ==========================================
# API: ZUFALLSGENERATOR & GERICHTE
# ==========================================
@app.get("/api/categories")
def api_categories():
return {
"categories": CATEGORIES,
"efforts": EFFORTS,
"chapters": CHAPTERS
}
@app.get("/api/stats")
def api_stats():
return get_stats()
@app.get("/api/dishes")
def api_get_dishes(
category: Optional[str] = Query(None),
effort: Optional[str] = Query(None),
search: Optional[str] = Query(None)
):
return get_all_dishes(category=category, effort=effort, search=search)
@app.post("/api/dishes")
def api_create_dish(payload: DishCreate):
try:
cat = payload.category or "Hausmannskost"
dish = add_dish(payload.name, cat, payload.effort)
return {"status": "ok", "dish": dish}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@app.put("/api/dishes/{dish_id}")
def api_update_dish(dish_id: int, payload: DishUpdate):
try:
cat = payload.category or "Hausmannskost"
dish = update_dish(dish_id, payload.name, cat, payload.effort)
return {"status": "ok", "dish": dish}
except KeyError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@app.delete("/api/dishes/{dish_id}")
def api_delete_dish(dish_id: int):
success = delete_dish(dish_id)
if not success:
raise HTTPException(status_code=404, detail="Gericht nicht gefunden.")
return {"status": "ok", "deleted": True}
def resolve_slot_chapter(slot) -> Optional[int]:
if getattr(slot, "chapter_number", None) and slot.chapter_number > 0:
return slot.chapter_number
if slot.category:
s = str(slot.category).strip()
if s.isdigit():
return int(s)
for c in CHAPTERS:
if s.lower() in c["title"].lower():
return c["number"]
return None
@app.post("/api/roll")
def api_roll_dishes(payload: MultiRollRequest):
"""Rolls recipes directly from the cookbook without duplicates in round."""
slots = payload.slots
if not slots:
raise HTTPException(status_code=400, detail="Keine Mahlzeiten zur Auslosung übergeben.")
if len(slots) > 5:
raise HTTPException(status_code=400, detail="Maximal 5 Mahlzeiten können gleichzeitig ausgelost werden.")
results = []
chosen_ids = set()
slot_candidates = []
for slot in slots:
chap_num = resolve_slot_chapter(slot)
candidates = query_recipe_candidates(chap_num, slot.efforts)
slot_candidates.append({
"slot": slot,
"chapter_number": chap_num,
"candidates": candidates
})
indexed_slots = list(enumerate(slot_candidates))
indexed_slots.sort(key=lambda item: len(item[1]["candidates"]))
temp_assignment = {}
for original_idx, item in indexed_slots:
slot = item["slot"]
chap_num = item["chapter_number"]
candidates = item["candidates"]
available = [c for c in candidates if c["id"] not in chosen_ids]
if not available:
available = candidates
if not available:
chap_title = CHAPTERS[chap_num - 1]["title"] if chap_num and 1 <= chap_num <= len(CHAPTERS) else ""
temp_assignment[original_idx] = {
"slot_number": slot.slot_number,
"recipe": None,
"dish": None,
"status": "not_found",
"chapter_number": chap_num,
"chapter_title": chap_title,
"message": f"Noch keine Rezepte in Kapitel {chap_num} ({chap_title}) vorhanden." if chap_num else "Noch keine Rezepte im Kochbuch vorhanden."
}
continue
alternatives = [c for c in available if c["id"] != slot.current_dish_id]
selection_pool = alternatives if alternatives else available
selected = random.choice(selection_pool)
chosen_ids.add(selected["id"])
selected_recipe = dict(selected)
c_num = selected_recipe.get("chapter_number", 1)
chap = CHAPTERS[c_num - 1] if 1 <= c_num <= len(CHAPTERS) else CHAPTERS[0]
selected_recipe["chapter_title"] = chap["title"]
selected_recipe["chapter_icon"] = chap["icon"]
selected_recipe["chapter_illustration"] = chap["illustration_url"]
selected_recipe["name"] = selected_recipe["title"]
selected_recipe["recipe_id"] = selected_recipe["id"]
temp_assignment[original_idx] = {
"slot_number": slot.slot_number,
"dish": selected_recipe,
"recipe": selected_recipe,
"status": "ok"
}
for idx in range(len(slots)):
results.append(temp_assignment[idx])
return {"status": "ok", "results": results}
@app.post("/api/roll-single")
def api_roll_single(payload: SingleRollRequest):
"""Rerolls a single recipe slot directly from cookbook."""
chap_num = resolve_slot_chapter(payload)
candidates = query_recipe_candidates(chap_num, payload.efforts)
if not candidates:
chap_title = CHAPTERS[chap_num - 1]["title"] if chap_num and 1 <= chap_num <= len(CHAPTERS) else ""
return {
"status": "not_found",
"dish": None,
"recipe": None,
"chapter_number": chap_num,
"chapter_title": chap_title,
"message": f"Noch keine Rezepte in Kapitel {chap_num} ({chap_title}) vorhanden." if chap_num else "Noch keine Rezepte im Kochbuch vorhanden."
}
other_ids = set(payload.other_dish_ids or [])
pool = [c for c in candidates if c["id"] not in other_ids]
if not pool:
pool = candidates
alternatives = [c for c in pool if c["id"] != payload.current_dish_id]
selection_pool = alternatives if alternatives else pool
selected = random.choice(selection_pool)
selected_recipe = dict(selected)
c_num = selected_recipe.get("chapter_number", 1)
chap = CHAPTERS[c_num - 1] if 1 <= c_num <= len(CHAPTERS) else CHAPTERS[0]
selected_recipe["chapter_title"] = chap["title"]
selected_recipe["chapter_icon"] = chap["icon"]
selected_recipe["chapter_illustration"] = chap["illustration_url"]
selected_recipe["name"] = selected_recipe["title"]
selected_recipe["recipe_id"] = selected_recipe["id"]
return {
"status": "ok",
"slot_number": payload.slot_number,
"dish": selected_recipe,
"recipe": selected_recipe,
"is_repetition": (selected["id"] == payload.current_dish_id and len(candidates) > 1)
}
# ==========================================
# API: WOCHENMENÜ-PLAN (MEAL PLANNER)
# ==========================================
def get_week_dates(year: int, week: int) -> Dict[str, Any]:
"""Berechnet die Kalenderdaten (Montag bis Sonntag) für eine gegebene KW."""
try:
monday = date.fromisocalendar(year, week, 1)
except AttributeError:
first_day = date(year, 1, 4)
monday = first_day + timedelta(days=((week - 1) * 7) - first_day.weekday())
day_names = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"]
days = []
for i in range(7):
cur_date = monday + timedelta(days=i)
days.append({
"day_index": i,
"name": day_names[i],
"date_formatted": cur_date.strftime("%d.%m."),
"iso_date": cur_date.strftime("%Y-%m-%d"),
"is_today": (cur_date == date.today())
})
sunday = monday + timedelta(days=6)
return {
"year": year,
"week": week,
"range_formatted": f"{monday.strftime('%d.%m.')} – {sunday.strftime('%d.%m.%Y')}",
"days": days
}
@app.get("/api/mealplan")
def api_get_mealplan(year: Optional[int] = Query(None), week: Optional[int] = Query(None)):
today = date.today()
iso_year, iso_week, _ = today.isocalendar()
y = year or iso_year
w = week or iso_week
week_info = get_week_dates(y, w)
slots = get_meal_plan(y, w)
# Slot-Map: "0_frueh", "0_mittag", "0_abend" etc.
slot_map = {}
for s in slots:
k = f"{s['day_of_week']}_{s['meal_type']}"
slot_map[k] = s
# Kompakte Rezeptliste für schnelles Dropdown / Zuweisung
all_recs = get_all_recipes()
recipes_summary = [
{
"id": r["id"],
"title": r["title"],
"category": r.get("category", ""),
"chapter_number": r.get("chapter_number", 1),
"effort": r.get("effort", "mittel")
}
for r in all_recs
]
return {
"status": "ok",
"year": y,
"week": w,
"current_year": iso_year,
"current_week": iso_week,
"is_current_week": (y == iso_year and w == iso_week),
"range_formatted": week_info["range_formatted"],
"days": week_info["days"],
"slots": slot_map,
"recipes": recipes_summary
}
@app.post("/api/mealplan/slot")
def api_save_mealplan_slot(payload: MealPlanSlotRequest):
if payload.day_of_week < 0 or payload.day_of_week > 6:
raise HTTPException(status_code=400, detail="Ungültiger Wochentag (0-6).")
if payload.meal_type not in ["frueh", "mittag", "kaffee", "abend"]:
raise HTTPException(status_code=400, detail="Ungültiger Mahlzeittyp (frueh, mittag, kaffee, abend).")
slot = set_meal_plan_slot(
year=payload.year,
week=payload.week,
day_of_week=payload.day_of_week,
meal_type=payload.meal_type,
recipe_id=payload.recipe_id,
custom_text=payload.custom_text
)
return {"status": "ok", "slot": slot}
@app.delete("/api/mealplan/slot")
def api_delete_mealplan_slot(
year: int = Query(...),
week: int = Query(...),
day_of_week: int = Query(...),
meal_type: str = Query(...)
):
success = delete_meal_plan_slot(year, week, day_of_week, meal_type)
return {"status": "ok", "deleted": success}
@app.delete("/api/mealplan/week")
def api_clear_mealplan_week(year: int = Query(...), week: int = Query(...)):
count = clear_meal_plan_week(year, week)
return {"status": "ok", "cleared_count": count}
+695
View File
@@ -0,0 +1,695 @@
/**
* What To Cook? - Frontend Application
*/
// Category icons mapping
const CATEGORY_ICONS = {
"Reis": "🍚",
"Nudeln": "🍝",
"Kartoffeln": "🥔",
"Teigig": "🍕",
"asiatisch": "🥢",
"Hausmannskost": "🥩",
"Suppen": "🍲",
"Eintöpfe": "🥘",
"Süßspeisen": "🥞",
"Fisch": "🐟"
};
const CATEGORIES = [
"Reis",
"Nudeln",
"Kartoffeln",
"Teigig",
"asiatisch",
"Hausmannskost",
"Suppen",
"Eintöpfe",
"Süßspeisen",
"Fisch"
];
const EFFORTS = ["gering", "mittel", "hoch"];
function getDishCategories(dish) {
if (!dish) return [];
if (Array.isArray(dish.categories) && dish.categories.length > 0) {
return dish.categories;
}
if (dish.category) {
return dish.category.split(",").map(c => c.trim()).filter(Boolean);
}
return [];
}
function renderCategoryBadges(dish) {
const cats = getDishCategories(dish);
if (cats.length === 0) return "";
return cats.map(cat => {
const icon = CATEGORY_ICONS[cat] || "🍲";
return `<span class="badge-tag badge-category"><span>${icon}</span> ${cat}</span>`;
}).join("");
}
// Application State
const state = {
activeTab: "generator",
slots: [
{ slot_number: 1, active: true, category: "", efforts: [], current_dish: null },
{ slot_number: 2, active: true, category: "", efforts: [], current_dish: null },
{ slot_number: 3, active: false, category: "", efforts: [], current_dish: null },
{ slot_number: 4, active: false, category: "", efforts: [], current_dish: null },
{ slot_number: 5, active: false, category: "", efforts: [], current_dish: null },
],
admin: {
dishes: [],
search: "",
category: "alle",
effort: "alle",
deleteTargetId: null
}
};
// --- Initialization ---
document.addEventListener("DOMContentLoaded", () => {
initTabs();
renderSlots();
populateDropdowns();
initGeneratorEvents();
initAdminEvents();
loadAdminDishes();
});
// --- Tab Navigation ---
function initTabs() {
const btnGenerator = document.getElementById("tab-btn-generator");
const btnAdmin = document.getElementById("tab-btn-admin");
const panelGenerator = document.getElementById("tab-generator");
const panelAdmin = document.getElementById("tab-admin");
btnGenerator.addEventListener("click", () => switchTab("generator"));
btnAdmin.addEventListener("click", () => switchTab("admin"));
function switchTab(tabName) {
state.activeTab = tabName;
if (tabName === "generator") {
btnGenerator.classList.add("active");
btnAdmin.classList.remove("active");
panelGenerator.classList.add("active");
panelAdmin.classList.remove("active");
panelAdmin.hidden = true;
panelGenerator.hidden = false;
} else {
btnAdmin.classList.add("active");
btnGenerator.classList.remove("active");
panelAdmin.classList.add("active");
panelGenerator.classList.remove("active");
panelGenerator.hidden = true;
panelAdmin.hidden = false;
loadAdminDishes();
}
}
}
// --- Populate Category Dropdowns ---
function populateDropdowns() {
const adminCatSelect = document.getElementById("admin-filter-category");
const modalCatGroup = document.getElementById("modal-category-group");
if (modalCatGroup) {
modalCatGroup.innerHTML = "";
}
CATEGORIES.forEach(cat => {
const icon = CATEGORY_ICONS[cat] || "🍲";
// Admin filter
if (adminCatSelect) {
const optFilter = document.createElement("option");
optFilter.value = cat;
optFilter.textContent = `${icon} ${cat}`;
adminCatSelect.appendChild(optFilter);
}
// Modal checkbox pill
if (modalCatGroup) {
const label = document.createElement("label");
label.className = "category-checkbox-label";
label.innerHTML = `
<input type="checkbox" name="modal-category" value="${cat}">
<span class="cat-pill">
<span>${icon}</span> ${cat}
</span>
`;
modalCatGroup.appendChild(label);
}
});
}
// --- Generator Slots Rendering ---
function renderSlots() {
const container = document.getElementById("slots-container");
container.innerHTML = "";
state.slots.forEach(slot => {
const card = document.createElement("div");
card.className = `slot-card ${slot.active ? "active" : "inactive"}`;
card.id = `slot-card-${slot.slot_number}`;
card.innerHTML = `
<div class="slot-card-header">
<div class="slot-title-group">
<span class="slot-number-badge">${slot.slot_number}</span>
<span class="slot-name">Gericht ${slot.slot_number}</span>
</div>
<label class="slot-toggle-label">
<span>${slot.active ? "Aktiv" : "Freigelassen"}</span>
<label class="switch">
<input type="checkbox" id="slot-toggle-${slot.slot_number}" ${slot.active ? "checked" : ""}>
<span class="slider"></span>
</label>
</label>
</div>
<div class="slot-body">
<div class="form-group">
<label for="slot-cat-${slot.slot_number}" class="form-label">Kategorie:</label>
<select id="slot-cat-${slot.slot_number}" class="custom-select">
<option value="" ${slot.category === "" ? "selected" : ""}>🎲 Beliebig (Alle Kategorien)</option>
${CATEGORIES.map(cat => {
const icon = CATEGORY_ICONS[cat] || "🍲";
return `<option value="${cat}" ${slot.category === cat ? "selected" : ""}>${icon} ${cat}</option>`;
}).join("")}
</select>
</div>
<div class="form-group">
<label class="form-label">Aufwand (Mehrfachauswahl möglich):</label>
<div class="effort-chips-group" id="effort-chips-${slot.slot_number}">
<button type="button" class="effort-chip ${slot.efforts.includes("gering") ? "selected" : ""}" data-effort="gering">
<span>🟢</span> Gering
</button>
<button type="button" class="effort-chip ${slot.efforts.includes("mittel") ? "selected" : ""}" data-effort="mittel">
<span>🟡</span> Mittel
</button>
<button type="button" class="effort-chip ${slot.efforts.includes("hoch") ? "selected" : ""}" data-effort="hoch">
<span>🔴</span> Hoch
</button>
</div>
</div>
</div>
`;
container.appendChild(card);
// Event: Toggle Active
const toggleInput = card.querySelector(`#slot-toggle-${slot.slot_number}`);
toggleInput.addEventListener("change", (e) => {
slot.active = e.target.checked;
renderSlots();
updateActiveCounter();
});
// Event: Category Change
const catSelect = card.querySelector(`#slot-cat-${slot.slot_number}`);
catSelect.addEventListener("change", (e) => {
slot.category = e.target.value;
});
// Event: Effort Multi-Select Chips
const chips = card.querySelectorAll(`#effort-chips-${slot.slot_number} .effort-chip`);
chips.forEach(chip => {
chip.addEventListener("click", () => {
const effortVal = chip.getAttribute("data-effort");
const idx = slot.efforts.indexOf(effortVal);
if (idx > -1) {
slot.efforts.splice(idx, 1);
chip.classList.remove("selected");
} else {
slot.efforts.push(effortVal);
chip.classList.add("selected");
}
});
});
});
updateActiveCounter();
}
function updateActiveCounter() {
const activeCount = state.slots.filter(s => s.active).length;
const countEl = document.getElementById("active-slots-count");
if (countEl) countEl.textContent = activeCount;
const btnHero = document.getElementById("btn-roll-all");
if (btnHero) {
const btnText = btnHero.querySelector(".btn-text");
if (activeCount === 0) {
btnText.textContent = "Bitte mindestens 1 Gericht aktivieren";
btnHero.disabled = true;
} else if (activeCount === 1) {
btnText.textContent = "1 Gericht auslosen";
btnHero.disabled = false;
} else {
btnText.textContent = `${activeCount} Gerichte auslosen`;
btnHero.disabled = false;
}
}
}
// --- Generator Events (Roll Logic) ---
function initGeneratorEvents() {
const btnRollAll = document.getElementById("btn-roll-all");
const btnRerollAll = document.getElementById("btn-reroll-all");
const btnBack = document.getElementById("btn-back-to-config");
btnRollAll.addEventListener("click", () => executeRoll(false));
btnRerollAll.addEventListener("click", () => executeRoll(true));
btnBack.addEventListener("click", () => {
window.scrollTo({ top: 0, behavior: "smooth" });
});
}
async function executeRoll(isReroll = false) {
const activeSlots = state.slots.filter(s => s.active);
if (activeSlots.length === 0) {
showToast("Bitte aktiviere mindestens 1 Gericht.", "error");
return;
}
const btnRollAll = document.getElementById("btn-roll-all");
const btnRerollAll = document.getElementById("btn-reroll-all");
btnRollAll.disabled = true;
btnRerollAll.disabled = true;
try {
const payload = {
slots: activeSlots.map(s => ({
slot_number: s.slot_number,
category: s.category || null,
efforts: s.efforts.length > 0 ? s.efforts : null,
current_dish_id: isReroll && s.current_dish ? s.current_dish.id : null
}))
};
const res = await fetch("/api/roll", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.detail || "Fehler beim Auslosen.");
}
// Update slots state with rolled dishes
data.results.forEach(resItem => {
const slot = state.slots.find(s => s.slot_number === resItem.slot_number);
if (slot) {
slot.current_dish = resItem.dish;
}
});
renderResults(data.results);
showToast(isReroll ? "Alle Gerichte neu ausgelost!" : "Zufallsauswahl erfolgreich!", "success");
// Scroll smoothly to results
const resultsSection = document.getElementById("results-section");
resultsSection.classList.remove("hidden");
resultsSection.scrollIntoView({ behavior: "smooth", block: "start" });
} catch (err) {
showToast(err.message, "error");
} finally {
btnRollAll.disabled = false;
btnRerollAll.disabled = false;
}
}
// Render Results Grid
function renderResults(results) {
const container = document.getElementById("results-list");
const countBadge = document.getElementById("results-count-badge");
container.innerHTML = "";
const validDishes = results.filter(r => r.dish !== null);
countBadge.textContent = `${validDishes.length} ${validDishes.length === 1 ? "Gericht" : "Gerichte"}`;
results.forEach(item => {
const slot = state.slots.find(s => s.slot_number === item.slot_number);
const dish = item.dish;
const card = document.createElement("div");
card.className = "result-card";
card.id = `result-card-${item.slot_number}`;
if (!dish) {
card.innerHTML = `
<div class="result-card-top">
<span class="result-slot-label">Gericht ${item.slot_number}</span>
</div>
<div class="result-dish-name" style="color: var(--text-dim);">
⚠️ Keine passenden Gerichte gefunden
</div>
<p style="font-size: 0.85rem; color: var(--text-muted);">
Für die gewählte Kombination (Kategorie / Aufwand) gibt es noch keine Einträge.
</p>
`;
container.appendChild(card);
return;
}
const effortLabel = dish.effort.charAt(0).toUpperCase() + dish.effort.slice(1);
card.innerHTML = `
<div class="result-card-top">
<span class="result-slot-label">Gericht ${item.slot_number}</span>
</div>
<div class="result-dish-name" id="dish-name-${item.slot_number}">${dish.name}</div>
<div class="result-tags">
${renderCategoryBadges(dish)}
<span class="badge-tag badge-effort-${dish.effort}">
${effortLabel}
</span>
</div>
<div class="result-card-actions">
<button class="btn-reroll-single" data-slot="${item.slot_number}" title="Nur dieses Gericht neu würfeln">
<span class="dice-icon">🎲</span> Neu würfeln
</button>
</div>
`;
container.appendChild(card);
// Event: Single Reroll Button
const btnRerollSingle = card.querySelector(".btn-reroll-single");
btnRerollSingle.addEventListener("click", () => rerollSingleSlot(item.slot_number, btnRerollSingle, card));
});
}
// --- Single Slot Reroll Logic ---
async function rerollSingleSlot(slotNumber, btnElement, cardElement) {
const slot = state.slots.find(s => s.slot_number === slotNumber);
if (!slot || !slot.current_dish) return;
btnElement.classList.add("spinning");
cardElement.classList.add("rerolling");
// Collect other dishes currently on screen so we don't duplicate
const otherDishIds = state.slots
.filter(s => s.active && s.slot_number !== slotNumber && s.current_dish)
.map(s => s.current_dish.id);
try {
const payload = {
slot_number: slotNumber,
category: slot.category || null,
efforts: slot.efforts.length > 0 ? slot.efforts : null,
current_dish_id: slot.current_dish.id,
other_dish_ids: otherDishIds
};
const res = await fetch("/api/roll-single", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || "Fehler beim Neu-Würfeln.");
if (data.status === "ok" && data.dish) {
slot.current_dish = data.dish;
const effortLabel = data.dish.effort.charAt(0).toUpperCase() + data.dish.effort.slice(1);
// Smoothly update the card content
const nameEl = cardElement.querySelector(`#dish-name-${slotNumber}`);
if (nameEl) nameEl.textContent = data.dish.name;
const tagsEl = cardElement.querySelector(".result-tags");
if (tagsEl) {
tagsEl.innerHTML = `
${renderCategoryBadges(data.dish)}
<span class="badge-tag badge-effort-${data.dish.effort}">
${effortLabel}
</span>
`;
}
showToast(`Gericht ${slotNumber} neu gewürfelt: ${data.dish.name}`, "success");
} else {
showToast("Kein anderes passendes Gericht in dieser Kategorie gefunden.", "error");
}
} catch (err) {
showToast(err.message, "error");
} finally {
setTimeout(() => {
btnElement.classList.remove("spinning");
cardElement.classList.remove("rerolling");
}, 450);
}
}
// --- Admin Section & CRUD ---
function initAdminEvents() {
const searchInput = document.getElementById("admin-search-input");
const clearSearchBtn = document.getElementById("admin-search-clear");
const catFilter = document.getElementById("admin-filter-category");
const effortFilter = document.getElementById("admin-filter-effort");
searchInput.addEventListener("input", (e) => {
state.admin.search = e.target.value.trim();
clearSearchBtn.classList.toggle("hidden", state.admin.search.length === 0);
loadAdminDishes();
});
clearSearchBtn.addEventListener("click", () => {
searchInput.value = "";
state.admin.search = "";
clearSearchBtn.classList.add("hidden");
loadAdminDishes();
});
catFilter.addEventListener("change", (e) => {
state.admin.category = e.target.value;
loadAdminDishes();
});
effortFilter.addEventListener("change", (e) => {
state.admin.effort = e.target.value;
loadAdminDishes();
});
// Modal Open/Close
const dishModal = document.getElementById("dish-modal");
const btnOpenAdd = document.getElementById("btn-open-add-dish");
const btnCloseModal = document.getElementById("btn-close-modal");
const btnCancelModal = document.getElementById("btn-cancel-modal");
const dishForm = document.getElementById("dish-form");
btnOpenAdd.addEventListener("click", () => {
openDishModal();
});
btnCloseModal.addEventListener("click", () => dishModal.close());
btnCancelModal.addEventListener("click", () => dishModal.close());
dishForm.addEventListener("submit", async (e) => {
e.preventDefault();
await saveDishFromModal();
});
// Delete Modal
const deleteModal = document.getElementById("delete-modal");
const btnCancelDelete = document.getElementById("btn-cancel-delete");
const btnConfirmDelete = document.getElementById("btn-confirm-delete");
btnCancelDelete.addEventListener("click", () => deleteModal.close());
btnConfirmDelete.addEventListener("click", async () => {
if (state.admin.deleteTargetId) {
await executeDeleteDish(state.admin.deleteTargetId);
deleteModal.close();
}
});
}
async function loadAdminDishes() {
const listContainer = document.getElementById("admin-dishes-list");
const statsSummary = document.getElementById("admin-stats-summary");
const countLabel = document.getElementById("dishes-count-label");
try {
const params = new URLSearchParams();
if (state.admin.category && state.admin.category !== "alle") {
params.append("category", state.admin.category);
}
if (state.admin.effort && state.admin.effort !== "alle") {
params.append("effort", state.admin.effort);
}
if (state.admin.search) {
params.append("search", state.admin.search);
}
const res = await fetch(`/api/dishes?${params.toString()}`);
const dishes = await res.json();
state.admin.dishes = dishes;
// Load overall stats
const statsRes = await fetch("/api/stats");
const stats = await statsRes.json();
statsSummary.textContent = `${stats.total} Gerichte in der Datenbank`;
countLabel.textContent = `${dishes.length} ${dishes.length === 1 ? "Gericht" : "Gerichte"} angezeigt`;
if (dishes.length === 0) {
listContainer.innerHTML = `
<div class="loading-spinner">Keine Gerichte mit diesen Suchkriterien gefunden.</div>
`;
return;
}
listContainer.innerHTML = "";
dishes.forEach(dish => {
const row = document.createElement("div");
row.className = "admin-dish-row";
const effortLabel = dish.effort.charAt(0).toUpperCase() + dish.effort.slice(1);
row.innerHTML = `
<div class="admin-dish-info">
<div class="admin-dish-name">${dish.name}</div>
<div class="admin-dish-badges">
${renderCategoryBadges(dish)}
<span class="badge-tag badge-effort-${dish.effort}">${effortLabel}</span>
</div>
</div>
<div class="admin-dish-actions">
<button class="btn-action-icon btn-edit" title="Bearbeiten" data-id="${dish.id}">✏️</button>
<button class="btn-action-icon btn-delete" title="Löschen" data-id="${dish.id}">🗑️</button>
</div>
`;
listContainer.appendChild(row);
row.querySelector(".btn-edit").addEventListener("click", () => openDishModal(dish));
row.querySelector(".btn-delete").addEventListener("click", () => confirmDeleteDish(dish));
});
} catch (err) {
listContainer.innerHTML = `<div class="loading-spinner" style="color: var(--danger);">Fehler beim Laden: ${err.message}</div>`;
}
}
function openDishModal(dish = null) {
const modal = document.getElementById("dish-modal");
const modalTitle = document.getElementById("modal-title");
const formId = document.getElementById("dish-form-id");
const nameInput = document.getElementById("dish-name-input");
const catCheckboxes = document.querySelectorAll('input[name="modal-category"]');
if (dish) {
modalTitle.textContent = "Gericht bearbeiten";
formId.value = dish.id;
nameInput.value = dish.name;
const cats = getDishCategories(dish);
catCheckboxes.forEach(cb => {
cb.checked = cats.includes(cb.value);
});
const radio = document.querySelector(`input[name="modal-effort"][value="${dish.effort}"]`);
if (radio) radio.checked = true;
} else {
modalTitle.textContent = "Neues Gericht anlegen";
formId.value = "";
nameInput.value = "";
catCheckboxes.forEach(cb => {
cb.checked = false;
});
const radio = document.querySelector(`input[name="modal-effort"][value="mittel"]`);
if (radio) radio.checked = true;
}
modal.showModal();
}
async function saveDishFromModal() {
const modal = document.getElementById("dish-modal");
const formId = document.getElementById("dish-form-id").value;
const name = document.getElementById("dish-name-input").value.trim();
const selectedCats = Array.from(document.querySelectorAll('input[name="modal-category"]:checked')).map(cb => cb.value);
const effortRadio = document.querySelector('input[name="modal-effort"]:checked');
const effort = effortRadio ? effortRadio.value : "mittel";
if (!name) {
showToast("Bitte gib einen Namen für das Gericht ein.", "error");
return;
}
if (selectedCats.length === 0) {
showToast("Bitte wähle mindestens eine Kategorie aus.", "error");
return;
}
if (!effort) {
showToast("Bitte wähle den Aufwand aus.", "error");
return;
}
const isEdit = !!formId;
const url = isEdit ? `/api/dishes/${formId}` : "/api/dishes";
const method = isEdit ? "PUT" : "POST";
try {
const res = await fetch(url, {
method: method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, categories: selectedCats, effort })
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || "Fehler beim Speichern.");
showToast(isEdit ? "Gericht aktualisiert!" : "Gericht erfolgreich hinzugefügt!", "success");
modal.close();
loadAdminDishes();
} catch (err) {
showToast(err.message, "error");
}
}
function confirmDeleteDish(dish) {
state.admin.deleteTargetId = dish.id;
const deleteModal = document.getElementById("delete-modal");
const textEl = document.getElementById("delete-modal-text");
textEl.innerHTML = `Möchtest du <strong>"${dish.name}"</strong> wirklich löschen?`;
deleteModal.showModal();
}
async function executeDeleteDish(dishId) {
try {
const res = await fetch(`/api/dishes/${dishId}`, { method: "DELETE" });
const data = await res.json();
if (!res.ok) throw new Error(data.detail || "Fehler beim Löschen.");
showToast("Gericht wurde gelöscht.", "success");
loadAdminDishes();
} catch (err) {
showToast(err.message, "error");
}
}
// --- Toast System ---
function showToast(message, type = "info") {
const container = document.getElementById("toast-container");
if (!container) return;
const toast = document.createElement("div");
toast.className = `toast toast-${type}`;
const icon = type === "success" ? "✅" : (type === "error" ? "⚠️" : "ℹ️");
toast.innerHTML = `<span>${icon}</span><span>${message}</span>`;
container.appendChild(toast);
setTimeout(() => {
toast.style.transition = "opacity 0.3s, transform 0.3s";
toast.style.opacity = "0";
toast.style.transform = "translateY(-10px)";
setTimeout(() => toast.remove(), 300);
}, 3500);
}
+2805
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
../style.css
+1
View File
@@ -0,0 +1 @@
../style.css
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

+1
View File
@@ -0,0 +1 @@
../app.js
+1
View File
@@ -0,0 +1 @@
../app.js
+21
View File
@@ -0,0 +1,21 @@
{
"name": "What To Cook? – Essens-Zufallsgenerator",
"short_name": "WhatToCook",
"description": "Zufallsgenerator für Essen mit Kategorien und Aufwandsfilter",
"start_url": "/",
"display": "standalone",
"background_color": "#0f141c",
"theme_color": "#ff6b35",
"icons": [
{
"src": "/static/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/static/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
+1085
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 660 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 291 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 844 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 597 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

+1877
View File
File diff suppressed because it is too large Load Diff
+227
View File
@@ -0,0 +1,227 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<meta name="theme-color" content="#1e222d">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<title>What To Cook? – Essens-Zufallsgenerator</title>
<link rel="manifest" href="/static/manifest.json">
<link rel="apple-touch-icon" href="/static/icon-192.png">
<link rel="stylesheet" href="/static/style.css">
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🍽️</text></svg>">
</head>
<body>
<div class="app-container">
<!-- Top Header -->
<header class="app-header">
<div class="brand">
<span class="brand-icon">🍽️</span>
<div>
<h1 class="brand-title">What To Cook?</h1>
<p class="brand-subtitle">Zufallsgenerator für Essen</p>
</div>
</div>
<!-- Tab Navigation -->
<nav class="nav-tabs" role="tablist">
<button id="tab-btn-generator" class="nav-tab active" data-tab="generator" role="tab" aria-selected="true">
<span class="icon">🎲</span> Generator
</button>
<a href="/cookbook" class="nav-tab" style="text-decoration: none;">
<span class="icon">📖</span> Kochbuch
</a>
<a href="/cookbook/new" class="nav-tab" style="text-decoration: none;">
<span class="icon">➕</span> Rezept
</a>
<button id="tab-btn-admin" class="nav-tab" data-tab="admin" role="tab" aria-selected="false">
<span class="icon">⚙️</span> Gerichte
</button>
</nav>
</header>
<!-- Main Content Area -->
<main class="app-main">
<!-- ========================================== -->
<!-- TAB 1: GENERATOR -->
<!-- ========================================== -->
<section id="tab-generator" class="tab-panel active" role="tabpanel">
<!-- Banner / Hint -->
<div class="info-card">
<div class="info-content">
<span class="info-icon">💡</span>
<div>
<strong>Bis zu 5 Gerichte konfigurieren:</strong>
Wähle Kategorie & Aufwand pro Gericht. Nicht benötigte Gerichte einfach frei lassen.
</div>
</div>
<div class="active-counter-badge" id="active-slots-counter">
<span id="active-slots-count">2</span> von 5 aktiv
</div>
</div>
<!-- 5 Dish Configuration Slots -->
<div class="slots-container" id="slots-container">
<!-- Rendered dynamically by app.js (1 to 5) -->
</div>
<!-- Floating / Sticky Action Bar -->
<div class="action-bar-wrapper">
<button id="btn-roll-all" class="btn btn-primary btn-hero">
<span class="btn-icon">🎲</span>
<span class="btn-text">Zufallsgenerator starten</span>
</button>
</div>
<!-- Results Section (Anchor / Modal or Inline display) -->
<section id="results-section" class="results-section hidden">
<div class="results-header">
<div class="results-title-group">
<h2 class="results-title">Ausgeloste Gerichte</h2>
<span class="results-badge" id="results-count-badge">0 Gerichte</span>
</div>
<button id="btn-reroll-all" class="btn btn-outline btn-sm">
<span class="icon">🔄</span> Alle neu würfeln
</button>
</div>
<div id="results-list" class="results-grid">
<!-- Dynamic Result Cards -->
</div>
<div class="results-footer">
<button id="btn-back-to-config" class="btn btn-secondary">
✏️ Filter anpassen
</button>
</div>
</section>
</section>
<!-- ========================================== -->
<!-- TAB 2: ADMIN / DATABASE -->
<!-- ========================================== -->
<section id="tab-admin" class="tab-panel" role="tabpanel" hidden>
<div class="admin-topbar">
<div>
<h2 class="section-title">Gerichte verwalten</h2>
<p class="section-desc" id="admin-stats-summary">Lade Datenbank...</p>
</div>
<button id="btn-open-add-dish" class="btn btn-primary">
<span>➕</span> Neues Gericht
</button>
</div>
<!-- Admin Filters -->
<div class="admin-filters-card">
<div class="search-input-wrap">
<span class="search-icon">🔍</span>
<input type="text" id="admin-search-input" placeholder="Gericht nach Namen suchen..." autocomplete="off">
<button id="admin-search-clear" class="btn-clear hidden" title="Suche leeren">✕</button>
</div>
<div class="filter-row">
<div class="form-group flex-1">
<label for="admin-filter-category" class="filter-label">Kategorie:</label>
<select id="admin-filter-category" class="custom-select">
<option value="alle">Alle Kategorien</option>
<!-- Filled via JS -->
</select>
</div>
<div class="form-group flex-1">
<label for="admin-filter-effort" class="filter-label">Aufwand:</label>
<select id="admin-filter-effort" class="custom-select">
<option value="alle">Jeder Aufwand</option>
<option value="gering">Gering 🟢</option>
<option value="mittel">Mittel 🟡</option>
<option value="hoch">Hoch 🔴</option>
</select>
</div>
</div>
</div>
<!-- Dishes Table / Cards for Mobile -->
<div class="dishes-list-wrap">
<div class="dishes-list-header">
<span id="dishes-count-label">Gerichte</span>
</div>
<div id="admin-dishes-list" class="admin-dishes-list">
<div class="loading-spinner">Lade Gerichte...</div>
</div>
</div>
</section>
</main>
<!-- Dialog: Add / Edit Dish -->
<dialog id="dish-modal" class="modal">
<div class="modal-box">
<div class="modal-header">
<h3 id="modal-title" class="modal-title">Neues Gericht anlegen</h3>
<button id="btn-close-modal" class="btn-icon-close" aria-label="Schließen">✕</button>
</div>
<form id="dish-form" method="dialog">
<input type="hidden" id="dish-form-id" value="">
<div class="form-group">
<label for="dish-name-input" class="form-label">Name des Gerichts *</label>
<input type="text" id="dish-name-input" class="text-input" placeholder="z. B. Spaghetti Carbonara" required autocomplete="off">
</div>
<div class="form-group">
<label class="form-label">Kategorien * <span class="form-label-hint">(Mehrfachauswahl möglich)</span></label>
<div class="category-checkbox-group" id="modal-category-group">
<!-- Filled via JS as checkbox pills -->
</div>
</div>
<div class="form-group">
<label class="form-label">Arbeitsaufwand *</label>
<div class="effort-radio-group">
<label class="effort-radio-label">
<input type="radio" name="modal-effort" value="gering" required>
<span class="effort-pill effort-gering">Gering 🟢 (≤ 20 Min)</span>
</label>
<label class="effort-radio-label">
<input type="radio" name="modal-effort" value="mittel" checked required>
<span class="effort-pill effort-mittel">Mittel 🟡 (~ 45 Min)</span>
</label>
<label class="effort-radio-label">
<input type="radio" name="modal-effort" value="hoch" required>
<span class="effort-pill effort-hoch">Hoch 🔴 (> 60 Min)</span>
</label>
</div>
</div>
<div class="modal-actions">
<button type="button" id="btn-cancel-modal" class="btn btn-secondary">Abbrechen</button>
<button type="submit" id="btn-save-modal" class="btn btn-primary">Speichern</button>
</div>
</form>
</div>
</dialog>
<!-- Dialog: Delete Confirm -->
<dialog id="delete-modal" class="modal">
<div class="modal-box modal-box-sm">
<h3 class="modal-title">Gericht löschen?</h3>
<p id="delete-modal-text" class="modal-text">Möchtest du dieses Gericht wirklich aus der Datenbank entfernen?</p>
<div class="modal-actions">
<button id="btn-cancel-delete" class="btn btn-secondary">Abbrechen</button>
<button id="btn-confirm-delete" class="btn btn-danger">Unwiderruflich löschen</button>
</div>
</div>
</dialog>
<!-- Toast Notifications -->
<div id="toast-container" class="toast-container" aria-live="polite"></div>
</div>
<script src="/static/app.js"></script>
</body>
</html>
+187
View File
@@ -0,0 +1,187 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>Anmeldung — WTC Kochbuch</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Caveat:wght@600;700&family=Lora:ital,wght@0,400;0,600;1,400&family=Playfair+Display:ital,wght@0,600;0,700;0,800;1,400&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/cookbook.css?v=4.0">
<style>
body.login-body {
background-color: var(--creme, #faf6ee);
background-image:
radial-gradient(circle at 100% 0%, rgba(212, 175, 55, 0.08) 0%, transparent 40%),
radial-gradient(circle at 0% 100%, rgba(30, 61, 47, 0.08) 0%, transparent 40%),
repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(0, 0, 0, 0.015) 2px, rgba(0, 0, 0, 0.015) 4px);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
margin: 0;
font-family: var(--font-body, 'Lora', Georgia, serif);
color: var(--schwarz, #1a1a1a);
}
.login-card {
background: #ffffff;
border: 3px solid var(--tannengruen, #1e3d2f);
border-radius: 16px;
box-shadow: 6px 8px 0px rgba(30, 61, 47, 0.25);
width: 100%;
max-width: 420px;
padding: 36px 30px;
text-align: center;
position: relative;
}
.login-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 64px;
height: 64px;
background: var(--creme, #faf6ee);
border: 2px solid var(--gold, #d4af37);
border-radius: 50%;
font-size: 2rem;
margin-bottom: 12px;
box-shadow: 0 4px 10px rgba(0,0,0,0.06);
}
.login-title {
font-family: var(--font-hand, 'Caveat', cursive);
font-size: 2.2rem;
color: var(--tannengruen, #1e3d2f);
margin: 0 0 4px 0;
line-height: 1.1;
}
.login-subtitle {
font-size: 0.95rem;
color: #666;
margin: 0 0 24px 0;
}
.login-form {
display: flex;
flex-direction: column;
gap: 16px;
text-align: left;
}
.form-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.form-label {
font-weight: 600;
font-size: 0.9rem;
color: var(--tannengruen, #1e3d2f);
}
.form-input {
padding: 10px 14px;
border: 2px solid #ddd;
border-radius: 8px;
font-size: 1rem;
font-family: inherit;
background: #fafafa;
transition: border-color 0.2s, background-color 0.2s;
}
.form-input:focus {
outline: none;
border-color: var(--tannengruen, #1e3d2f);
background: #ffffff;
box-shadow: 0 0 0 3px rgba(30, 61, 47, 0.12);
}
.form-checkbox-row {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.88rem;
color: #555;
margin-top: 2px;
}
.form-checkbox-row input {
accent-color: var(--tannengruen, #1e3d2f);
width: 16px;
height: 16px;
cursor: pointer;
}
.login-btn {
background: var(--tannengruen, #1e3d2f);
color: var(--creme, #faf6ee);
border: 2px solid var(--tannengruen, #1e3d2f);
border-radius: 8px;
font-family: var(--font-hand, 'Caveat', cursive);
font-size: 1.25rem;
font-weight: 700;
padding: 10px 16px;
cursor: pointer;
box-shadow: 3px 4px 0px rgba(0, 0, 0, 0.15);
transition: all 0.18s ease;
margin-top: 8px;
}
.login-btn:hover {
background: #274f3d;
border-color: #274f3d;
transform: translateY(-1px);
box-shadow: 3px 5px 0px rgba(0, 0, 0, 0.22);
}
.login-btn:active {
transform: translateY(0);
box-shadow: 1px 2px 0px rgba(0, 0, 0, 0.15);
}
.login-error {
background: #fdf2f2;
border: 1px solid #f8b4b4;
color: #9b1c1c;
padding: 10px 14px;
border-radius: 8px;
font-size: 0.88rem;
margin-bottom: 16px;
text-align: left;
}
.login-footer {
margin-top: 24px;
font-size: 0.82rem;
color: #888;
}
</style>
</head>
<body class="login-body">
<div class="login-card">
<div class="login-badge">📖</div>
<h1 class="login-title">WTC Kochbuch</h1>
<p class="login-subtitle">Privater Bereich • Bitte anmelden</p>
{% if error %}
<div class="login-error">
⚠️ {{ error }}
</div>
{% endif %}
<form method="POST" action="/login" class="login-form">
<input type="hidden" name="next" value="{{ next_url or '/cookbook' }}">
<div class="form-group">
<label class="form-label" for="username">Benutzername</label>
<input class="form-input" type="text" id="username" name="username" value="{{ username or 'admin' }}" required autofocus autocomplete="username">
</div>
<div class="form-group">
<label class="form-label" for="password">Passwort</label>
<input class="form-input" type="password" id="password" name="password" required autocomplete="current-password" placeholder="Passwort eingeben">
</div>
<div class="form-checkbox-row">
<input type="checkbox" id="remember" name="remember" value="true" checked>
<label for="remember">Angemeldet bleiben (30 Tage)</label>
</div>
<button type="submit" class="login-btn">Anmelden ✨</button>
</form>
<div class="login-footer">
What To Cook? • Privates Rezeptarchiv & Kochbuch
</div>
</div>
</body>
</html>
+230
View File
@@ -0,0 +1,230 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ recipe.title }} – Mein Kochbuch</title>
<link rel="stylesheet" href="/static/cookbook.css?v=4.4">
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🍽️</text></svg>">
</head>
<body class="cookbook-body">
<!-- Navigation Bar -->
<header class="cb-nav">
<a href="/cookbook" class="cb-nav-brand">
<span class="cb-brand-icon">📖</span>
<h1 class="cb-nav-title">Kochbuch</h1>
</a>
<!-- Desktop Navigation -->
<div class="cb-nav-actions desktop-nav-actions">
<a href="/cookbook" class="cb-btn cb-btn-outline">
<span>📖</span> <span>Alle Rezepte</span>
</a>
<a href="/cookbook/edit/{{ recipe.id }}" class="cb-btn cb-btn-green">
<span>✏️</span> <span>Bearbeiten</span>
</a>
<button onclick="window.print()" class="cb-btn cb-btn-outline" title="Rezeptseite drucken">
<span>🖨️</span> <span>Drucken</span>
</button>
<a href="/logout" class="cb-nav-btn cb-nav-btn-logout" title="Abmelden">
<span>🚪</span> <span>Abmelden</span>
</a>
</div>
<!-- Mobile Kebab (3-Punkte) Menü -->
<div class="mobile-nav-wrapper">
<button type="button" class="cb-kebab-btn" onclick="toggleMobileMenu(event)" aria-label="Menü öffnen" title="Menü">
<span>⋮</span>
</button>
<div id="cb-mobile-menu" class="cb-mobile-dropdown">
<a href="/cookbook" class="cb-mobile-item">
<span>📖</span> <span>Zum Kochbuch</span>
</a>
<a href="/cookbook/edit/{{ recipe.id }}" class="cb-mobile-item">
<span>✏️</span> <span>Rezept bearbeiten</span>
</a>
<button type="button" onclick="window.print()" class="cb-mobile-item">
<span>🖨️</span> <span>Drucken & PDF</span>
</button>
<hr class="cb-mobile-divider">
<a href="/logout" class="cb-mobile-item cb-mobile-logout">
<span>🚪</span> <span>Abmelden</span>
</a>
</div>
</div>
</header>
<main class="book-wrapper">
<!-- EINHEITLICHE REZEPTSEITE -->
<article class="page recipe-page">
<!-- Header (Ohne Kapitelangabe & ohne Quelle im Titel) -->
<header class="recipe-header">
<h2 class="recipe-title">{{ recipe.title }}</h2>
<div class="recipe-meta-row">
{% if recipe.effort %}
<div class="recipe-meta-item">
<span>⏱️</span>
<span>Aufwand:
{% if recipe.effort == 'gering' %}Gering 🟢{% elif recipe.effort == 'hoch' %}Hoch 🔴{% else %}Mittel 🟡{% endif %}
</span>
</div>
{% endif %}
{% if recipe.prep_time %}
<div class="recipe-meta-item">
<span>🔪</span>
<span>Zubereitung: {{ recipe.prep_time }}</span>
</div>
{% endif %}
{% if recipe.cook_time %}
<div class="recipe-meta-item">
<span>🍳</span>
<span>Kochen/Backen: {{ recipe.cook_time }}</span>
</div>
{% endif %}
{% if recipe.portions %}
<div class="recipe-meta-item">
<span>👥</span>
<span>{{ recipe.portions }} Portionen</span>
</div>
{% endif %}
{% if recipe.source and recipe.source.strip() %}
<div class="recipe-meta-item">
<span>📖</span>
<span>Quelle: {{ recipe.source.strip() }}</span>
</div>
{% endif %}
</div>
</header>
<!-- 2-Spalten Layout -->
<div class="recipe-grid">
<!-- Linke Spalte: Foto + Zutaten -->
<aside class="recipe-side">
<div class="recipe-photo-box">
{% if recipe.image_url %}
<img src="{{ recipe.image_url }}" alt="{{ recipe.title }}">
{% else %}
<div class="recipe-photo-empty">
<span>📸</span>
<span>Kein Foto hinterlegt</span>
</div>
{% endif %}
</div>
<!-- Zutatenleiste ohne Portionsangabe (da schon in Kopfzeile) -->
<div class="ingredients-box {% if recipe.has_portion_table %}has-portion-matrix{% endif %}">
<h4 class="section-hand-title">Zutaten</h4>
{% if recipe.has_portion_table and recipe.portion_matrix %}
<!-- Tabelle für 2/4/6/8 Portionen (z. B. Waffeln / Pancakes) -->
<table class="portion-matrix-table">
<thead>
<tr>
<th class="matrix-th-ing">Zutat</th>
<th class="matrix-th-p">2 P.</th>
<th class="matrix-th-p">4 P.</th>
<th class="matrix-th-p">6 P.</th>
<th class="matrix-th-p">8 P.</th>
</tr>
</thead>
<tbody>
{% for row in recipe.portion_matrix %}
{% if row.is_header %}
<tr class="matrix-header-row">
<th colspan="5">{{ row.text }}</th>
</tr>
{% else %}
<tr class="matrix-row">
<td class="matrix-td-name">{{ row.name }}</td>
<td class="matrix-td-amt">{{ row.amounts[2] }}</td>
<td class="matrix-td-amt highlight">{{ row.amounts[4] }}</td>
<td class="matrix-td-amt">{{ row.amounts[6] }}</td>
<td class="matrix-td-amt">{{ row.amounts[8] }}</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
{% else %}
<!-- Normale kompakte Zutatenliste (kein Abschneiden im Druck) -->
<ul class="ingredients-list">
{% for line in recipe.ingredients.split('\n') %}
{% if line.strip() %}
{% set raw_line = line.strip() %}
{% if raw_line.endswith(':') or raw_line.lower().startswith('für ') %}
<li class="ing-subheading">{{ raw_line }}</li>
{% else %}
<li>{{ raw_line }}</li>
{% endif %}
{% endif %}
{% endfor %}
</ul>
{% endif %}
</div>
</aside>
<!-- Rechte Spalte: Zubereitung + Notizen + Quelle -->
<section class="recipe-main">
<div>
<h4 class="section-hand-title">Zubereitung</h4>
<div class="instructions-steps">
{% for step in recipe.instructions.split('\n') %}
{% if step.strip() %}
<div class="step-card">
<span class="step-badge">{{ loop.index }}</span>
<div class="step-text">{{ step.strip() }}</div>
</div>
{% endif %}
{% endfor %}
</div>
</div>
{% if recipe.notes and recipe.notes.strip() %}
<div class="notes-box">
<div class="notes-box-title">💡 Tipp & Notiz</div>
<div>{{ recipe.notes.strip() }}</div>
</div>
{% else %}
<div class="notes-box notes-box-empty print-only">
<div class="notes-box-title">💡 Eigene Notizen & Tipps</div>
<div class="notes-blank-lines">
<div class="notes-blank-line"></div>
<div class="notes-blank-line"></div>
</div>
</div>
</div>
{% endif %}
</section>
</div>
</article>
</main>
<script>
function toggleMobileMenu(e) {
if (e) e.stopPropagation();
const menu = document.getElementById('cb-mobile-menu');
if (menu) menu.classList.toggle('is-open');
}
document.addEventListener('click', (e) => {
if (!e.target.closest('.mobile-nav-wrapper')) {
const menu = document.getElementById('cb-mobile-menu');
if (menu) menu.classList.remove('is-open');
}
});
</script>
</body>
</html>
+357
View File
@@ -0,0 +1,357 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% if recipe %}Rezept bearbeiten{% else %}Neues Rezept eintragen{% endif %} – Mein Kochbuch</title>
<link rel="stylesheet" href="/static/cookbook.css?v=4.4">
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>📝</text></svg>">
</head>
<body class="cookbook-body">
<!-- Header -->
<header class="cb-nav">
<a href="/cookbook" class="cb-nav-brand">
<span class="cb-brand-icon">📖</span>
<h1 class="cb-nav-title">Kochbuch</h1>
</a>
<!-- Desktop Actions -->
<div class="cb-nav-actions desktop-nav-actions">
<a href="/cookbook" class="cb-btn cb-btn-creme" style="background: var(--tannengruen); color: var(--creme);">
<span>📖</span> <span>Zum Kochbuch</span>
</a>
<a href="/logout" class="cb-nav-btn cb-nav-btn-logout" title="Abmelden">
<span>🚪</span> <span>Abmelden</span>
</a>
</div>
<!-- Mobile 3-Dots Kebab Menu -->
<div class="mobile-nav-wrapper">
<button type="button" class="cb-kebab-btn" id="mobileMenuBtn" aria-label="Menü öffnen" onclick="toggleMobileMenu()">
⋮
</button>
<div class="cb-mobile-dropdown" id="mobileDropdown">
<a href="/cookbook" class="cb-mobile-item">
<span>📖</span> Zum Kochbuch
</a>
<div class="cb-mobile-divider"></div>
<a href="/logout" class="cb-mobile-item" style="color: #c53030;">
<span>🚪</span> Abmelden
</a>
</div>
</div>
</header>
<main class="book-wrapper" style="max-width: 780px;">
<div class="form-card">
<h2 class="form-title">
{% if recipe %}✏️ Rezept bearbeiten{% else %}➕ Neues Rezept eintragen{% endif %}
</h2>
<p class="form-subtitle">
Trage dein Lieblingsrezept ein – mit Foto, Zutaten und Zubereitungsschritten.
</p>
<form id="recipe-form" enctype="multipart/form-data">
{% if recipe %}
<input type="hidden" id="recipe-id" value="{{ recipe.id }}">
{% endif %}
<!-- Titel -->
<div class="cb-form-group">
<label for="title" class="cb-label">Name des Rezepts *</label>
<input type="text" id="title" name="title" class="cb-input"
value="{{ recipe.title if recipe else '' }}"
placeholder="z. B. Klassische Spaghetti Carbonara" required autocomplete="off">
</div>
<!-- Kategorie / Kapitel -->
<div class="cb-form-group">
<label for="chapter_number" class="cb-label">Kategorie / Kapitel *</label>
<select id="chapter_number" name="chapter_number" class="cb-select" required>
<option value="" disabled {% if not recipe and not selected_chapter %}selected{% endif %}>Kapitel wählen...</option>
{% for chap in chapters %}
<option value="{{ chap.number }}"
{% if (recipe and recipe.chapter_number == chap.number) or (not recipe and selected_chapter == chap.number) %}selected{% endif %}>
Kapitel {{ chap.number }}: {{ chap.title }}
</option>
{% endfor %}
</select>
</div>
<!-- Metadaten: Aufwand, Zeiten, Portionen -->
<div class="cb-row">
<div class="cb-col">
<div class="cb-form-group">
<label for="effort" class="cb-label">Arbeitsaufwand *</label>
<select id="effort" name="effort" class="cb-select" required>
<option value="gering" {% if recipe and recipe.effort == 'gering' %}selected{% endif %}>Gering 🟢 (≤ 20 Min)</option>
<option value="mittel" {% if not recipe or recipe.effort == 'mittel' %}selected{% endif %}>Mittel 🟡 (~ 45 Min)</option>
<option value="hoch" {% if recipe and recipe.effort == 'hoch' %}selected{% endif %}>Hoch 🔴 (> 60 Min)</option>
</select>
</div>
</div>
<div class="cb-col">
<div class="cb-form-group">
<label for="portions" class="cb-label">Portionen</label>
<input type="number" id="portions" name="portions" class="cb-input"
value="{{ recipe.portions if recipe else 4 }}" min="1" max="50">
</div>
</div>
</div>
<div class="cb-row">
<div class="cb-col">
<div class="cb-form-group">
<label for="prep_time" class="cb-label">Zubereitungszeit</label>
<input type="text" id="prep_time" name="prep_time" class="cb-input"
value="{{ recipe.prep_time if recipe else '' }}" placeholder="z. B. 15 Min">
</div>
</div>
<div class="cb-col">
<div class="cb-form-group">
<label for="cook_time" class="cb-label">Koch- / Backzeit</label>
<input type="text" id="cook_time" name="cook_time" class="cb-input"
value="{{ recipe.cook_time if recipe else '' }}" placeholder="z. B. 30 Min">
</div>
</div>
</div>
<!-- Checkbox: Multi-Portionen-Tabelle -->
<div class="cb-form-group" style="margin-top: 4px; margin-bottom: 20px; background: rgba(44, 74, 52, 0.05); padding: 10px 14px; border-radius: 8px; border: 1px dashed var(--tannengruen);">
<label style="display: flex; align-items: center; gap: 10px; cursor: pointer; font-size: 0.95rem; font-weight: 600; color: var(--dunkelgrau); margin-bottom: 0;">
<input type="checkbox" id="has_portion_table" name="has_portion_table" value="1"
{% if recipe and recipe.has_portion_table %}checked{% endif %}
style="width: 18px; height: 18px; accent-color: var(--tannengruen);">
<span>🔢 Zutatentabelle für 2 / 4 / 6 / 8 Portionen berechnen (z. B. für Waffeln, Pfannkuchen, Teige)</span>
</label>
</div>
<!-- Foto-Upload -->
<div class="cb-form-group">
<label class="cb-label">Foto des Gerichts 📸</label>
<div class="upload-dropzone" id="dropzone" onclick="document.getElementById('image-upload').click();">
<span style="font-size: 2.2rem;">📷</span>
<p style="font-family: var(--font-hand); font-size: 1.15rem; margin: 8px 0 4px 0; color: var(--tannengruen);">
Hier klicken, um ein Foto auszuwählen oder mit dem Smartphone aufzunehmen
</p>
<span style="font-size: 0.85rem; color: #777;">(JPG, PNG, WebP)</span>
<input type="file" id="image-upload" name="image_file" accept="image/*" style="display: none;">
</div>
<div class="preview-container" id="preview-box" {% if recipe and recipe.image_url %}style="display: block;"{% endif %}>
<img id="image-preview" src="{{ recipe.image_url if recipe and recipe.image_url else '' }}" alt="Vorschau">
</div>
</div>
<!-- Zutaten -->
<div class="cb-form-group">
<label for="ingredients" class="cb-label">Zutaten * (eine Zutat pro Zeile)</label>
<textarea id="ingredients" name="ingredients" class="cb-textarea" rows="8"
placeholder="500g Pasta&#10;150g Schinken oder Speck&#10;4 Eigelb&#10;50g Parmesan gerieben&#10;Salz und frisch gemahlener Pfeffer" required>{{ recipe.ingredients if recipe else '' }}</textarea>
</div>
<!-- Zubereitungsschritte -->
<div class="cb-form-group">
<label for="instructions" class="cb-label">Zubereitung * (Absätze werden nummeriert)</label>
<textarea id="instructions" name="instructions" class="cb-textarea" rows="10"
placeholder="Reichlich Salzwasser in einem großen Topf zum Kochen bringen.&#10;Den Schinken in Streifen schneiden und knusprig anbraten.&#10;Die Eigelbe mit dem geriebenen Käse und Pfeffer cremig rühren.&#10;Pasta al dente kochen und mit etwas Nudelwasser vermengen..." required>{{ recipe.instructions if recipe else '' }}</textarea>
</div>
<!-- Notizen & Tipps -->
<div class="cb-form-group">
<label for="notes" class="cb-label">💡 Tipps, Notizen & Kniffe (optional)</label>
<textarea id="notes" name="notes" class="cb-textarea" rows="3"
placeholder="Wichtig: Den Herd vor dem Zugeben der Eier-Käse-Mischung ausschalten, damit das Ei nicht stockt!">{{ recipe.notes if recipe else '' }}</textarea>
</div>
<!-- Credits / Copyright / Quelle -->
<div class="cb-form-group">
<label for="source" class="cb-label">© Quelle / Credits / Copyright (optional)</label>
<input type="text" id="source" name="source" class="cb-input"
value="{{ recipe.source if recipe and recipe.source else '' }}"
placeholder="z. B. Von Oma Anna, Chefkoch.de, Jamie Oliver, Familienrezept">
</div>
<!-- Checkbox: Auch als Zufallsgenerator-Gericht anlegen -->
{% if not recipe %}
<div class="cb-form-group" style="margin-top: 10px;">
<label style="display: flex; align-items: center; gap: 10px; cursor: pointer; font-family: var(--font-hand); font-size: 1.1rem;">
<input type="checkbox" id="add_to_dishes" name="add_to_dishes" value="true" checked style="width: 20px; height: 20px; accent-color: var(--tannengruen);">
<span>Auch als Gericht für den Zufallsgenerator hinzufügen</span>
</label>
</div>
{% endif %}
<!-- Error Feedback Banner -->
<div id="form-error-banner" style="display: none; background: #fff0f0; border: 2px solid #e53e3e; color: #9b1c1c; padding: 12px 16px; border-radius: 8px; margin-top: 15px; font-weight: 600;"></div>
<!-- Submit Button -->
<div style="display: flex; justify-content: flex-end; gap: 12px; margin-top: 24px;">
<a href="/cookbook" class="cb-btn cb-btn-outline" style="color: var(--schwarz); border-color: rgba(34,34,34,0.3);">
Abbrechen
</a>
<button type="submit" id="btn-submit" class="cb-btn cb-btn-creme" style="background: var(--tannengruen); color: var(--creme); font-size: 1.15rem; padding: 12px 24px;">
<span>💾</span> <span>Rezept speichern</span>
</button>
</div>
</form>
</div>
</main>
<script>
// Live Image Preview & Compression Helper
const fileInput = document.getElementById('image-upload');
const previewBox = document.getElementById('preview-box');
const previewImg = document.getElementById('image-preview');
const errorBanner = document.getElementById('form-error-banner');
let preparedImageFile = null;
fileInput.addEventListener('change', async function() {
const file = this.files[0];
if (!file) return;
// Vorschau anzeigen
const reader = new FileReader();
reader.onload = function(e) {
previewImg.src = e.target.result;
previewBox.style.display = 'block';
};
reader.readAsDataURL(file);
// Foto für schnellen Upload komprimieren (max 1600px Breite/Höhe, JPEG 85%)
try {
preparedImageFile = await compressImageFile(file, 1600, 1600, 0.85);
} catch (err) {
console.warn('Komprimierung fehlgeschlagen, Originaldatei wird verwendet:', err);
preparedImageFile = file;
}
});
function compressImageFile(file, maxWidth, maxHeight, quality) {
return new Promise((resolve) => {
// Bei SVG oder WebP/GIF direkt original belassen
if (file.type === 'image/svg+xml' || file.type === 'image/gif') {
return resolve(file);
}
const img = new Image();
const url = URL.createObjectURL(file);
img.onload = () => {
URL.revokeObjectURL(url);
let w = img.width;
let h = img.height;
if (w > maxWidth || h > maxHeight) {
if (w > h) {
h = Math.round((h * maxWidth) / w);
w = maxWidth;
} else {
w = Math.round((w * maxHeight) / h);
h = maxHeight;
}
}
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, w, h);
canvas.toBlob((blob) => {
if (!blob) return resolve(file);
const safeName = file.name.replace(/\.[^/.]+$/, "") + ".jpg";
resolve(new File([blob], safeName, { type: "image/jpeg" }));
}, 'image/jpeg', quality);
};
img.onerror = () => {
URL.revokeObjectURL(url);
resolve(file);
};
img.src = url;
});
}
// Form Submit (AJAX multipart)
const form = document.getElementById('recipe-form');
const btnSubmit = document.getElementById('btn-submit');
const recipeIdEl = document.getElementById('recipe-id');
form.addEventListener('submit', async function(e) {
e.preventDefault();
errorBanner.style.display = 'none';
errorBanner.textContent = '';
btnSubmit.disabled = true;
btnSubmit.innerHTML = '<span>⏳</span> Speichern...';
const formData = new FormData(form);
// Wenn ein Bild komprimiert vorliegt, dieses im FormData setzen
if (preparedImageFile) {
formData.set('image_file', preparedImageFile);
} else if (fileInput.files.length === 0) {
formData.delete('image_file');
}
// Checkbox Wert explizit absichern
const addDishCb = document.getElementById('add_to_dishes');
if (addDishCb) {
formData.set('add_to_dishes', addDishCb.checked ? 'true' : 'false');
}
const hasPortionTableCb = document.getElementById('has_portion_table');
if (hasPortionTableCb) {
formData.set('has_portion_table', hasPortionTableCb.checked ? '1' : '0');
}
const isEdit = !!recipeIdEl;
const url = isEdit ? `/api/recipes/${recipeIdEl.value}` : '/api/recipes';
const method = isEdit ? 'PUT' : 'POST';
try {
const res = await fetch(url, {
method: method,
body: formData
});
let data;
const textResponse = await res.text();
try {
data = JSON.parse(textResponse);
} catch (e) {
throw new Error(`Serverfehler (${res.status}): ${textResponse.slice(0, 150)}`);
}
if (!res.ok) {
const detailMsg = (typeof data.detail === 'string')
? data.detail
: (Array.isArray(data.detail) ? data.detail.map(d => d.msg || d).join(', ') : 'Fehler beim Speichern');
throw new Error(detailMsg);
}
const recipeId = data.recipe ? data.recipe.id : (recipeIdEl ? recipeIdEl.value : '');
window.location.href = `/cookbook#rezept-${recipeId}`;
} catch (err) {
console.error('Fehler beim Speichern:', err);
errorBanner.textContent = `❌ ${err.message}`;
errorBanner.style.display = 'block';
btnSubmit.disabled = false;
btnSubmit.innerHTML = '<span>💾</span> Rezept speichern';
}
});
// Mobile Kebab Menu Functions
function toggleMobileMenu() {
const dd = document.getElementById('mobileDropdown');
if (dd) dd.classList.toggle('show');
}
document.addEventListener('click', function(e) {
const wrap = document.querySelector('.mobile-nav-wrapper');
if (wrap && !wrap.contains(e.target)) {
const dd = document.getElementById('mobileDropdown');
if (dd) dd.classList.remove('show');
}
});
</script>
</body>
</html>