Initial commit: What To Cook (WTC) v4.4 - Digitales Kochbuch, Wochenmenueplan & Buchdruck
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
../style.css
|
||||
@@ -0,0 +1 @@
|
||||
../style.css
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 233 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 422 KiB |
|
After Width: | Height: | Size: 191 KiB |
@@ -0,0 +1 @@
|
||||
../app.js
|
||||
@@ -0,0 +1 @@
|
||||
../app.js
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 203 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 254 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 660 KiB |
|
After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 183 KiB |
|
After Width: | Height: | Size: 200 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 167 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 158 KiB |
|
After Width: | Height: | Size: 291 KiB |
|
After Width: | Height: | Size: 230 KiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 544 KiB |
|
After Width: | Height: | Size: 844 KiB |
|
After Width: | Height: | Size: 597 KiB |
|
After Width: | Height: | Size: 280 KiB |
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 150g Schinken oder Speck 4 Eigelb 50g Parmesan gerieben 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. Den Schinken in Streifen schneiden und knusprig anbraten. Die Eigelbe mit dem geriebenen Käse und Pfeffer cremig rühren. 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>
|
||||