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

This commit is contained in:
2026-10-14 20:24:37 +02:00
commit c01d2d7639
171 changed files with 14073 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
# Database Configuration (mariadb oder sqlite)
DB_ENGINE=mariadb
MARIADB_HOST=localhost
MARIADB_PORT=3306
MARIADB_USER=wtc_user
MARIADB_PASS=dein_sicheres_passwort
MARIADB_NAME=wtc_cookbook
# Authentication & Session Security
APP_USERNAME=admin
APP_PASSWORD=aendern_beim_ersten_start!
SECRET_KEY=generiere_einen_langen_zufaelligen_hex_key_hier
# Optional: Host & Port (falls abweichend von Standard 127.0.0.1:8003)
# PORT=8003
+65
View File
@@ -0,0 +1,65 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
*.so
# C extensions
*.obj
*.o
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# Virtual environments
venv/
.venv/
env/
ENV/
# Environment and secrets
.env
.env.local
*.pem
*.key
# SQLite and local databases
*.sqlite
*.sqlite3
*.db
!data/*.placeholder
# Temporary files and test dumps
dump_out.txt
*.log
.pytest_cache/
.coverage
htmlcov/
# Editor and OS metadata
.idea/
.vscode/
*.swp
*.swo
*~
.DS_Store
Thumbs.db
desktop.ini
recipes_details.txt
+198
View File
@@ -0,0 +1,198 @@
# 📖 What To Cook? (WTC) – Digitales Kochbuch, Wochenmenüplan & Buchdruck-Engine
Eine moderne, elegante Web-Applikation zur Verwaltung von Lieblingsrezepten, zur spielerischen Menüauslosung (Shuffle-Generator), zur flexiblen Wochenmenü-Planung und zum professionellen Duplex-Buchdruck mit Inhaltsverzeichnis und abwechselnden Seitenzahlen.
Live-Instanz: [wtc.dev.waiswuaschd.de](https://wtc.dev.waiswuaschd.de)
---
## ✨ Features im Überblick
### 🍽️ Digitales Kochbuch
* **11 liebevoll kuratierte Kapitel:**
1. 🍲 Suppen & Eintöpfe
2. 🥨 Traditionell & Basics
3. 🍝 Pasta & Nudelgerichte
4. 🥔 Kartoffeln
5. 🍚 Reis & Getreide
6. 🥐 Teig & Gebackenes
7. 🥢 Asiatische Küche
8. 🥞 Süßspeisen
9. 🍮 Desserts
10. 🥧 Kuchen & Gebäck
11. 🍪 Plätzchen
* **Handgezeichnete Kapitel-Illustrationen:** Hochauflösende Doodles auf jeder Kapitel-Trennseite (sowohl auf dem Bildschirm als auch im Druck).
* **2-Spalten-Layout:** Links Bild und Zutaten, rechts nummerierte Zubereitungsschritte und persönliche Notizen.
* **Intelligente Portionstabelle:** Automatische 2 / 4 / 6 / 8 Portionen-Berechnung (z. B. für Waffeln, Pfannkuchen und Teige).
* **Rezept-Management:** Schnelles Hinzufügen, Bearbeiten, Löschen und Foto-Upload mit automatischer Bildoptimierung und Komprimierung (WebP/JPEG).
### 🎲 Shuffle-Generator (Essensauslosung)
* Würfelt 1 bis 5 Gerichte gleichzeitig ohne doppelte Vorschläge in einer Runde.
* Filterbar nach **Kapitel** und **Aufwand** (Mehrfachauswahl: Gering, Mittel, Hoch).
* Direkte Schaltfläche zum Öffnen oder Tauschen eines Gerichts.
### 📅 Interaktiver Wochenmenü-Plan
* **4 Mahlzeiten pro Tag:** 🌅 Früh, ☀️ Mittag, ☕ Kaffee & Kuchen, 🌙 Abend.
* **Nur belegte Mahlzeiten sichtbar:** Schlanke, aufgeräumte Wochenansicht ohne überflüssige leere Blöcke.
* **Flexible Zuordnung:** Rezepte direkt aus dem Kochbuch wählen, Freitext eingeben (z. B. *Kantine*, *Pizzaabend*, *Brotzeit*) oder Zufallsgericht würfeln.
* **Wochennavigation:** Beliebiges Blättern durch vergangene und zukünftige Kalenderwochen mit Anzeige der aktuellen KW.
* **Persistente Speicherung:** Dauerhafte Speicherung aller Einträge in der Datenbank.
### 🖨️ A4-Buchdruck & PDF-Export (Duplex-Buchbindung)
* **Reine Druck-Elemente:** Deckblatt (*Meine Lieblingsrezepte*), 2-seitiges Inhaltsverzeichnis mit Punktlinien und Seitenzahlen erscheinen ausschließlich im Ausdruck.
* **Abwechselnde Seitenzahlen:**
- Gerade Seiten (links im Buch): Seitenzahl links unten
- Ungerade Seiten (rechts im Buch): Seitenzahl rechts unten
* **Millimetergenaue 1-Seiten-Garantie:** Jedes Rezept und jede Kapitelseite ist so optimiert (282mm Druckhöhe), dass auch unter Apple AirPrint (iOS Safari) und Android Chrome keine ungewollten 2. Seiten entstehen.
---
## 🛠️ Systemvoraussetzungen
* **Betriebssystem:** Linux (Debian 12 / Ubuntu 22.04+ empfohlen) oder Windows
* **Python:** 3.10 oder neuer
* **Datenbank:** MariaDB 10.6+ / MySQL 8.0+ (oder SQLite als Fallback)
* **Webserver:** Nginx (als Reverse Proxy mit SSL via Let's Encrypt)
---
## 🚀 Installation & Einrichtung
### 1. Repository klonen
```bash
git clone https://gitea.sp13l.de/kobold-ai/wtc.waiswuaschd.de.git /home/maxi/Projekt/wtc.waiswuaschd.de
cd /home/maxi/Projekt/wtc.waiswuaschd.de
```
### 2. Python Virtual Environment anlegen
```bash
python3 -m venv venv
source venv/bin/activate
pip install --upgrade pip
pip install -r requirements.txt
```
### 3. Konfiguration (`.env`)
Erstelle eine `.env`-Datei auf Basis der Vorlage:
```bash
cp .env.example .env
nano .env
```
Beispiel-Konfiguration:
```ini
DB_ENGINE=mariadb
MARIADB_HOST=localhost
MARIADB_PORT=3306
MARIADB_USER=wtc_user
MARIADB_PASS=dein_sicheres_passwort
MARIADB_NAME=wtc_cookbook
# Login Zugangsdaten für das Kochbuch
APP_USERNAME=admin
APP_PASSWORD=dein_super_geheimes_passwort!
SECRET_KEY=wtc_hmac_secret_key_zufaelliger_string_hier
```
### 4. MariaDB Datenbank & Benutzer anlegen
Verbinde dich mit deiner MySQL/MariaDB-Konsole:
```sql
CREATE DATABASE IF NOT EXISTS `wtc_cookbook` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER IF NOT EXISTS 'wtc_user'@'localhost' IDENTIFIED BY 'dein_sicheres_passwort';
GRANT ALL PRIVILEGES ON `wtc_cookbook`.* TO 'wtc_user'@'localhost';
FLUSH PRIVILEGES;
```
*(Tabellen für Rezepte und den Wochenplan werden beim ersten Start der Applikation automatisch migriert und angelegt).*
---
## ⚙️ Produktion: Systemd Service einrichten
Erstelle die Service-Datei `/etc/systemd/system/wtc.service`:
```ini
[Unit]
Description=What To Cook (WTC) Kochbuch & Menüplaner (FastAPI)
After=network.target mariadb.service
[Service]
Type=simple
User=maxi
Group=maxi
WorkingDirectory=/home/maxi/Projekt/wtc.waiswuaschd.de
Environment="PATH=/home/maxi/Projekt/wtc.waiswuaschd.de/venv/bin:/usr/local/bin:/usr/bin:/bin"
ExecStart=/home/maxi/Projekt/wtc.waiswuaschd.de/venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8003 --workers 2
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
```
Dienst aktivieren und starten:
```bash
sudo systemctl daemon-reload
sudo systemctl enable wtc.service
sudo systemctl start wtc.service
sudo systemctl status wtc.service
```
---
## 🌐 Nginx Reverse Proxy Konfiguration
Erstelle `/etc/nginx/sites-available/wtc.dev.waiswuaschd.de`:
```nginx
server {
server_name wtc.dev.waiswuaschd.de;
client_max_body_size 25M;
location / {
proxy_pass http://127.0.0.1:8003;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
Aktivieren und SSL-Zertifikat via Certbot einbinden:
```bash
sudo ln -s /etc/nginx/sites-available/wtc.dev.waiswuaschd.de /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
sudo certbot --nginx -d wtc.dev.waiswuaschd.de
```
---
## 📂 Projektstruktur
```text
wtc.waiswuaschd.de/
├── app/
│ ├── static/
│ │ ├── cookbook.css # Haupt-Stylesheet & A4-Druckregeln
│ │ └── uploads/ # Hochgeladene Rezeptfotos & Illustrationen
│ ├── templates/
│ │ ├── cookbook.html # Hauptansicht, Buch-Wrapper, Modals & Wochenplan
│ │ ├── recipe_detail.html # Einzelrezeptansicht
│ │ ├── recipe_form.html # Standalone Formular
│ │ └── login.html # Login-Maske
│ ├── database.py # Datenbank-Abstraktion (MariaDB / SQLite CRUD)
│ ├── ingredients.py # Portions-Skalierung & Zutaten-Parser
│ └── main.py # FastAPI Routen, Session-Auth & Endpunkte
├── requirements.txt # Python Dependencies
├── wtc.service # Systemd Service Vorlage
├── .env.example # Konfigurationsvorlage
└── README.md # Projektdokumentation
```
---
## 📄 Lizenz & Credits
Entwickelt für den privaten Gebrauch und die Erstellung gebundener Familien-Kochbücher. Alle Rechte vorbehalten.
+754
View File
@@ -0,0 +1,754 @@
import os
import sqlite3
from datetime import datetime
from typing import List, Dict, Any, Optional
try:
from dotenv import load_dotenv
load_dotenv()
except ImportError:
pass
try:
import pymysql
import pymysql.cursors
except ImportError:
pymysql = None
DB_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "data", "wtc.db")
DB_ENGINE = os.getenv("DB_ENGINE", "mariadb").lower()
MARIADB_HOST = os.getenv("MARIADB_HOST", "db.waiswuaschd.de")
MARIADB_PORT = int(os.getenv("MARIADB_PORT", "3306"))
MARIADB_USER = os.getenv("MARIADB_USER", "v2_usr_wtc_cookbook")
MARIADB_PASS = os.getenv("MARIADB_PASS", "XKikHf&j6Wj!rs5%A8#ZzyvE#*A1hT")
MARIADB_NAME = os.getenv("MARIADB_NAME", "v2_db_wtc_cookbook")
LEGACY_CATEGORIES = [
"Reis",
"Nudeln",
"Kartoffeln",
"Teigig",
"asiatisch",
"Hausmannskost",
"Suppen",
"Eintöpfe",
"Süßspeisen",
"Fisch"
]
# 11 Kochbuch-Kapitel aus dem Benutzerentwurf mit handgezeichneten Doodles & Icons
CHAPTERS = [
{"number": 1, "title": "Suppen & Eintöpfe", "icon": "🍲", "doodle_hint": "Dampfende Suppenschale", "illustration_url": "/static/img/chapters/chapter_1.png"},
{"number": 2, "title": "Traditionell & Basics", "icon": "🥟", "doodle_hint": "Klassiker & Brotzeit", "illustration_url": "/static/img/chapters/chapter_2.png"},
{"number": 3, "title": "Pasta & Nudelgerichte", "icon": "🍝", "doodle_hint": "Spaghetti & Pasta", "illustration_url": "/static/img/chapters/chapter_3.png"},
{"number": 4, "title": "Kartoffeln", "icon": "🥔", "doodle_hint": "Kartoffelauflauf & Klöße", "illustration_url": "/static/img/chapters/chapter_4.png"},
{"number": 5, "title": "Reis & Getreide", "icon": "🍚", "doodle_hint": "Reisschale & Körner", "illustration_url": "/static/img/chapters/chapter_5.png"},
{"number": 6, "title": "Teig & Gebackenes", "icon": "🍕", "doodle_hint": "Pizza, Flammkuchen & Brot", "illustration_url": "/static/img/chapters/chapter_6.png"},
{"number": 7, "title": "Asiatische Küche", "icon": "🥢", "doodle_hint": "Wok, Ramen & Thai-Curry", "illustration_url": "/static/img/chapters/chapter_7.png"},
{"number": 8, "title": "Süßspeisen", "icon": "🥞", "doodle_hint": "Kaiserschmarrn & Knödel", "illustration_url": "/static/img/chapters/chapter_8.png"},
{"number": 9, "title": "Desserts", "icon": "🍰", "doodle_hint": "Tiramisu & Mousse", "illustration_url": "/static/img/chapters/chapter_9.png"},
{"number": 10, "title": "Kuchen & Gebäck", "icon": "🎂", "doodle_hint": "Torten & Feingebäck", "illustration_url": "/static/img/chapters/chapter_10.png"},
{"number": 11, "title": "Plätzchen", "icon": "🍪", "doodle_hint": "Weihnachtsplätzchen & Kekse", "illustration_url": "/static/img/chapters/chapter_11.png"}
]
CATEGORIES = [c["title"] for c in CHAPTERS] + [c for c in LEGACY_CATEGORIES if c not in [ch["title"] for ch in CHAPTERS]]
EFFORTS = ["gering", "mittel", "hoch"]
CHAPTER_MAP = {c["title"]: c["number"] for c in CHAPTERS}
for c in CHAPTERS:
CHAPTER_MAP[str(c["number"])] = c["number"]
def get_connection():
if DB_ENGINE == "mariadb":
if pymysql is None:
raise RuntimeError("PyMySQL ist nicht installiert, wird aber für MariaDB benötigt.")
return pymysql.connect(
host=MARIADB_HOST,
port=MARIADB_PORT,
user=MARIADB_USER,
password=MARIADB_PASS,
database=MARIADB_NAME,
charset="utf8mb4",
cursorclass=pymysql.cursors.DictCursor,
autocommit=True
)
else:
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def execute_query(query: str, params: tuple = (), fetch: str = "all", commit: bool = False):
conn = get_connection()
try:
if DB_ENGINE == "mariadb":
# MariaDB uses %s instead of ?
maria_query = query.replace("?", "%s")
with conn.cursor() as cursor:
cursor.execute(maria_query, params)
last_id = cursor.lastrowid
rowcount = cursor.rowcount
if fetch == "all":
rows = cursor.fetchall()
return rows
elif fetch == "one":
return cursor.fetchone()
else:
return {"lastrowid": last_id, "rowcount": rowcount}
else:
cursor = conn.cursor()
cursor.execute(query, params)
if commit:
conn.commit()
last_id = cursor.lastrowid
rowcount = cursor.rowcount
if fetch == "all":
rows = [dict(r) for r in cursor.fetchall()]
return rows
elif fetch == "one":
row = cursor.fetchone()
return dict(row) if row else None
else:
return {"lastrowid": last_id, "rowcount": rowcount}
finally:
conn.close()
def init_db():
conn = get_connection()
try:
if DB_ENGINE == "mariadb":
with conn.cursor() as cursor:
cursor.execute("""
CREATE TABLE IF NOT EXISTS dishes (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL UNIQUE,
category VARCHAR(100) NOT NULL,
effort VARCHAR(20) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_dish_cat (category),
INDEX idx_dish_eff (effort)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS recipes (
id INT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(255) NOT NULL,
category VARCHAR(100) NOT NULL,
chapter_number INT NOT NULL,
effort VARCHAR(20) NOT NULL DEFAULT 'mittel',
prep_time VARCHAR(50) DEFAULT '',
cook_time VARCHAR(50) DEFAULT '',
portions INT DEFAULT 4,
ingredients LONGTEXT NOT NULL,
instructions LONGTEXT NOT NULL,
notes TEXT,
source VARCHAR(255) DEFAULT '',
image_url VARCHAR(500) DEFAULT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_recipe_chapter (chapter_number),
INDEX idx_recipe_cat (category)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
""")
# Migration check for MariaDB
cursor.execute("""
SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = %s AND TABLE_NAME = 'recipes' AND COLUMN_NAME = 'source'
""", (MARIADB_NAME,))
if not cursor.fetchone():
cursor.execute("ALTER TABLE recipes ADD COLUMN source VARCHAR(255) DEFAULT ''")
cursor.execute("""
SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = %s AND TABLE_NAME = 'recipes' AND COLUMN_NAME = 'printed_at'
""", (MARIADB_NAME,))
if not cursor.fetchone():
cursor.execute("ALTER TABLE recipes ADD COLUMN printed_at DATETIME NULL DEFAULT NULL AFTER image_url")
cursor.execute("""
SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = %s AND TABLE_NAME = 'recipes' AND COLUMN_NAME = 'has_portion_table'
""", (MARIADB_NAME,))
if not cursor.fetchone():
cursor.execute("ALTER TABLE recipes ADD COLUMN has_portion_table TINYINT(1) NOT NULL DEFAULT 0 AFTER portions")
cursor.execute("""
CREATE TABLE IF NOT EXISTS meal_plans (
id INT AUTO_INCREMENT PRIMARY KEY,
year INT NOT NULL,
week INT NOT NULL,
day_of_week INT NOT NULL,
meal_type VARCHAR(20) NOT NULL,
recipe_id INT NULL,
custom_text VARCHAR(255) NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_meal_slot (year, week, day_of_week, meal_type),
INDEX idx_plan_week (year, week)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
""")
cursor.execute("DELETE FROM dishes")
else:
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS dishes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE COLLATE NOCASE,
category TEXT NOT NULL,
effort TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS recipes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
category TEXT NOT NULL,
chapter_number INTEGER NOT NULL,
effort TEXT NOT NULL DEFAULT 'mittel',
prep_time TEXT DEFAULT '',
cook_time TEXT DEFAULT '',
portions INTEGER DEFAULT 4,
has_portion_table INTEGER DEFAULT 0,
ingredients TEXT NOT NULL,
instructions TEXT NOT NULL,
notes TEXT DEFAULT '',
source TEXT DEFAULT '',
image_url TEXT DEFAULT '',
printed_at TIMESTAMP NULL DEFAULT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
# Migration check for SQLite
cursor.execute("PRAGMA table_info(recipes)")
existing_cols = [c[1] for c in cursor.fetchall()]
if "source" not in existing_cols:
cursor.execute("ALTER TABLE recipes ADD COLUMN source TEXT DEFAULT ''")
if "printed_at" not in existing_cols:
cursor.execute("ALTER TABLE recipes ADD COLUMN printed_at TIMESTAMP NULL DEFAULT NULL")
if "has_portion_table" not in existing_cols:
cursor.execute("ALTER TABLE recipes ADD COLUMN has_portion_table INTEGER DEFAULT 0")
cursor.execute("""
CREATE TABLE IF NOT EXISTS meal_plans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
year INTEGER NOT NULL,
week INTEGER NOT NULL,
day_of_week INTEGER NOT NULL,
meal_type TEXT NOT NULL,
recipe_id INTEGER NULL,
custom_text TEXT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE (year, week, day_of_week, meal_type)
)
""")
cursor.execute("DELETE FROM dishes")
conn.commit()
finally:
conn.close()
# --- Dishes (Zufallsgenerator) ---
def get_all_dishes(category: Optional[str] = None, effort: Optional[str] = None, search: Optional[str] = None) -> List[Dict[str, Any]]:
query = "SELECT id, name, category, effort, created_at FROM dishes WHERE 1=1"
params = []
if category and category.strip() and category != "alle":
query += " AND category = ?"
params.append(category.strip())
if effort and effort.strip() and effort != "alle":
query += " AND effort = ?"
params.append(effort.strip())
if search and search.strip():
query += " AND name LIKE ?"
params.append(f"%{search.strip()}%")
query += " ORDER BY category ASC, name ASC"
return execute_query(query, tuple(params), fetch="all")
def add_dish(name: str, category: str, effort: str) -> Dict[str, Any]:
name = name.strip()
category = category.strip()
effort = effort.strip()
if not name:
raise ValueError("Name darf nicht leer sein.")
if category not in CATEGORIES:
raise ValueError(f"Ungültige Kategorie '{category}'. Gültig sind: {', '.join(CATEGORIES)}")
if effort not in EFFORTS:
raise ValueError(f"Ungültiger Aufwand '{effort}'. Gültig sind: {', '.join(EFFORTS)}")
try:
res = execute_query(
"INSERT INTO dishes (name, category, effort) VALUES (?, ?, ?)",
(name, category, effort),
fetch="none",
commit=True
)
dish_id = res["lastrowid"]
return execute_query("SELECT id, name, category, effort, created_at FROM dishes WHERE id = ?", (dish_id,), fetch="one")
except Exception as e:
if "UNIQUE" in str(e).upper() or "DUPLICATE" in str(e).upper():
raise ValueError(f"Ein Gericht mit dem Namen '{name}' existiert bereits.")
raise
def update_dish(dish_id: int, name: str, category: str, effort: str) -> Dict[str, Any]:
name = name.strip()
category = category.strip()
effort = effort.strip()
if not name:
raise ValueError("Name darf nicht leer sein.")
if category not in CATEGORIES:
raise ValueError(f"Ungültige Kategorie '{category}'.")
if effort not in EFFORTS:
raise ValueError(f"Ungültiger Aufwand '{effort}'.")
try:
res = execute_query(
"UPDATE dishes SET name = ?, category = ?, effort = ? WHERE id = ?",
(name, category, effort, dish_id),
fetch="none",
commit=True
)
if res["rowcount"] == 0:
raise KeyError(f"Gericht #{dish_id} nicht gefunden.")
return execute_query("SELECT id, name, category, effort, created_at FROM dishes WHERE id = ?", (dish_id,), fetch="one")
except Exception as e:
if "UNIQUE" in str(e).upper() or "DUPLICATE" in str(e).upper():
raise ValueError(f"Ein Gericht mit dem Namen '{name}' existiert bereits.")
raise
def delete_dish(dish_id: int) -> bool:
res = execute_query("DELETE FROM dishes WHERE id = ?", (dish_id,), fetch="none", commit=True)
return res["rowcount"] > 0
def clear_all_dishes():
execute_query("DELETE FROM dishes", fetch="none", commit=True)
if DB_ENGINE == "sqlite":
try:
execute_query("DELETE FROM sqlite_sequence WHERE name = 'dishes'", fetch="none", commit=True)
except Exception:
pass
def get_stats() -> Dict[str, Any]:
total_res = execute_query("SELECT COUNT(*) as total FROM dishes", fetch="one")
total = total_res["total"] if total_res else 0
cat_rows = execute_query("SELECT category, COUNT(*) as cnt FROM dishes GROUP BY category", fetch="all")
by_category = {row["category"]: row["cnt"] for row in cat_rows}
eff_rows = execute_query("SELECT effort, COUNT(*) as cnt FROM dishes GROUP BY effort", fetch="all")
by_effort = {row["effort"]: row["cnt"] for row in eff_rows}
recipe_count_res = execute_query("SELECT COUNT(*) as total FROM recipes", fetch="one")
total_recipes = recipe_count_res["total"] if recipe_count_res else 0
return {
"total": total,
"by_category": by_category,
"by_effort": by_effort,
"total_recipes": total_recipes
}
def resolve_category_terms(cat_input: Optional[str]) -> List[str]:
if not cat_input or str(cat_input).strip().lower() in ["", "alle", "beliebig", "none"]:
return []
s = str(cat_input).strip()
chap_num = None
if s.isdigit():
chap_num = int(s)
else:
for c in CHAPTERS:
if s.lower() == c["title"].lower() or s.lower() in c["title"].lower():
chap_num = c["number"]
break
chapter_terms = {
1: ["Suppen", "Eintöpfe", "Suppe", "Eintopf", "Suppen & Eintöpfe", "Chili", "Gulasch"],
2: ["Traditionell & Basics", "Hausmannskost", "Traditionell", "Basics", "Fisch", "Brotzeit", "Klassiker", "Schnitzel", "Rouladen", "Klopse", "Forelle", "Lachs"],
3: ["Pasta & Nudelgerichte", "Nudeln", "Pasta", "Spaghetti", "Penne", "Tagliatelle", "Carbonara", "Lasagne", "Spätzle"],
4: ["Kartoffeln", "Kartoffel", "Gratin", "Pommes", "Püree"],
5: ["Reis & Getreide", "Reis", "Getreide", "Risotto", "Paella"],
6: ["Teig & Gebackenes", "Teigig", "Teig", "Pizza", "Flammkuchen", "Quiche", "Calzone"],
7: ["Asiatische Küche", "asiatisch", "Asiatisch", "Curry", "Pad Thai", "Bami", "Wok", "Ramen", "Sushi"],
8: ["Süßspeisen", "Süßspeise", "Kaiserschmarrn", "Germknödel", "Dampfnudel", "Apfelküchle", "Milchreis"],
9: ["Desserts", "Dessert", "Tiramisu", "Mousse", "Pudding"],
10: ["Kuchen & Gebäck", "Kuchen", "Gebäck", "Torte"],
11: ["Plätzchen", "Kekse", "Keks"]
}
if chap_num and chap_num in chapter_terms:
return chapter_terms[chap_num]
# Legacy category names mapping
legacy_map = {
"nudeln": chapter_terms[3],
"pasta": chapter_terms[3],
"reis": chapter_terms[5],
"kartoffeln": chapter_terms[4],
"teigig": chapter_terms[6],
"suppen": chapter_terms[1],
"eintöpfe": chapter_terms[1],
"eintoepfe": chapter_terms[1],
"hausmannskost": chapter_terms[2],
"asiatisch": chapter_terms[7],
"süßspeisen": chapter_terms[8],
"suessspeisen": chapter_terms[8],
"fisch": ["Fisch", "Forelle", "Lachs", "Kabeljau", "Hausmannskost"]
}
if s.lower() in legacy_map:
return legacy_map[s.lower()]
return [s]
def get_chapter_info_for_dish(dish_category: str = "", dish_name: str = "", recipe_chapter: Optional[int] = None) -> Dict[str, Any]:
if recipe_chapter and 1 <= recipe_chapter <= len(CHAPTERS):
return CHAPTERS[recipe_chapter - 1]
combo = f"{dish_category or ''} {dish_name or ''}".lower()
if any(k in combo for k in ["suppe", "eintopf", "chili", "brühe"]):
return CHAPTERS[0] # 1: Suppen & Eintöpfe
if any(k in combo for k in ["nudel", "pasta", "spaghetti", "penne", "tagliatelle", "carbonara", "bolognese", "spätzle", "lasagne"]):
return CHAPTERS[2] # 3: Pasta & Nudelgerichte
if any(k in combo for k in ["kartoffel", "pommes", "gratin", "püree"]):
return CHAPTERS[3] # 4: Kartoffeln
if any(k in combo for k in ["curry", "pad thai", "bami", "wok", "ramen", "sushi", "asiatisch"]):
return CHAPTERS[6] # 7: Asiatische Küche
if any(k in combo for k in ["reis", "risotto", "paella", "djuvec"]):
return CHAPTERS[4] # 5: Reis & Getreide
if any(k in combo for k in ["pizza", "flammkuchen", "quiche", "calzone", "teig", "brot"]):
return CHAPTERS[5] # 6: Teig & Gebackenes
if any(k in combo for k in ["kuchen", "torte", "muffin", "gebäck"]):
return CHAPTERS[9] # 10: Kuchen & Gebäck
if any(k in combo for k in ["keks", "plätzchen"]):
return CHAPTERS[10] # 11: Plätzchen
if any(k in combo for k in ["tiramisu", "mousse", "pudding", "dessert"]):
return CHAPTERS[8] # 9: Desserts
if any(k in combo for k in ["kaiserschmarrn", "knödel", "apfelküchle", "dampfnudel", "milchreis", "süßspeis"]):
return CHAPTERS[7] # 8: Süßspeisen
return CHAPTERS[1] # 2: Traditionell & Basics
def query_recipe_candidates(chapter_number: Optional[int] = None, efforts: Optional[List[str]] = None) -> List[Dict[str, Any]]:
"""Gibt alle Rezepte aus der recipes-Tabelle zurück, die zu den Shuffle-Kriterien passen."""
all_recipes = get_all_recipes()
if not all_recipes:
return []
# 1. Nach Kapitel filtern (wenn spezifiziert)
if chapter_number is not None and chapter_number > 0:
pool = [r for r in all_recipes if r.get("chapter_number") == chapter_number]
else:
pool = list(all_recipes)
if not pool:
return []
# 2. Nach Aufwand filtern (nur, wenn eine Teilmenge gewählt wurde)
if efforts and len(efforts) > 0 and len(efforts) < len(EFFORTS):
clean_efforts = [e.strip().lower() for e in efforts if e.strip() in EFFORTS]
effort_pool = [r for r in pool if (r.get("effort") or "").lower() in clean_efforts]
if effort_pool:
return effort_pool
return pool
def query_candidates(category: Optional[str], efforts: Optional[List[str]]) -> List[Dict[str, Any]]:
"""Fallback-Funktion für alte Dish-Abfragen"""
return query_recipe_candidates()
# --- Recipes (Kochbuch) ---
def format_printed_date(p_at: Any) -> str:
if not p_at:
return ""
if hasattr(p_at, "strftime"):
return p_at.strftime("%d.%m.%Y")
try:
parts = str(p_at).split(" ")[0].split("-")
if len(parts) == 3:
return f"{parts[2]}.{parts[1]}.{parts[0]}"
except Exception:
pass
return str(p_at)
def add_recipe(
title: str,
category: str,
chapter_number: int,
effort: str = "mittel",
prep_time: str = "",
cook_time: str = "",
portions: int = 4,
ingredients: str = "",
instructions: str = "",
notes: str = "",
source: str = "",
image_url: str = "",
printed_at: Optional[str] = None,
has_portion_table: int = 0
) -> Dict[str, Any]:
title = title.strip()
category = category.strip()
effort = effort.strip().lower()
source = source.strip()
if not title:
raise ValueError("Rezepttitel darf nicht leer sein.")
if not category:
raise ValueError("Kategorie darf nicht leer sein.")
if chapter_number not in [c["number"] for c in CHAPTERS]:
# Fallback to mapped chapter number if possible
chapter_number = CHAPTER_MAP.get(category, 1)
res = execute_query(
"""
INSERT INTO recipes (title, category, chapter_number, effort, prep_time, cook_time, portions, has_portion_table, ingredients, instructions, notes, source, image_url, printed_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(title, category, chapter_number, effort, prep_time, cook_time, portions, int(bool(has_portion_table)), ingredients, instructions, notes, source, image_url, printed_at),
fetch="none",
commit=True
)
recipe_id = res["lastrowid"]
return get_recipe(recipe_id)
def update_recipe(
recipe_id: int,
title: str,
category: str,
chapter_number: int,
effort: str = "mittel",
prep_time: str = "",
cook_time: str = "",
portions: int = 4,
ingredients: str = "",
instructions: str = "",
notes: str = "",
source: str = "",
image_url: Optional[str] = None,
printed_at: Optional[str] = None,
has_portion_table: Optional[int] = None
) -> Dict[str, Any]:
title = title.strip()
category = category.strip()
effort = effort.strip().lower()
source = source.strip()
if not title:
raise ValueError("Rezepttitel darf nicht leer sein.")
existing = get_recipe(recipe_id)
if not existing:
raise KeyError(f"Rezept #{recipe_id} nicht gefunden.")
use_portion_table = int(bool(has_portion_table)) if has_portion_table is not None else existing.get("has_portion_table", 0)
if image_url is not None:
query = """
UPDATE recipes
SET title = ?, category = ?, chapter_number = ?, effort = ?, prep_time = ?, cook_time = ?, portions = ?, has_portion_table = ?, ingredients = ?, instructions = ?, notes = ?, source = ?, image_url = ?, printed_at = ?
WHERE id = ?
"""
params = (title, category, chapter_number, effort, prep_time, cook_time, portions, use_portion_table, ingredients, instructions, notes, source, image_url, printed_at, recipe_id)
else:
query = """
UPDATE recipes
SET title = ?, category = ?, chapter_number = ?, effort = ?, prep_time = ?, cook_time = ?, portions = ?, has_portion_table = ?, ingredients = ?, instructions = ?, notes = ?, source = ?, printed_at = ?
WHERE id = ?
"""
params = (title, category, chapter_number, effort, prep_time, cook_time, portions, use_portion_table, ingredients, instructions, notes, source, printed_at, recipe_id)
res = execute_query(query, params, fetch="none", commit=True)
return get_recipe(recipe_id)
def delete_recipe(recipe_id: int) -> bool:
res = execute_query("DELETE FROM recipes WHERE id = ?", (recipe_id,), fetch="none", commit=True)
return res["rowcount"] > 0
def get_recipe(recipe_id: int) -> Optional[Dict[str, Any]]:
row = execute_query("SELECT * FROM recipes WHERE id = ?", (recipe_id,), fetch="one")
if row:
row["printed_at_formatted"] = format_printed_date(row.get("printed_at"))
row["has_portion_table"] = bool(row.get("has_portion_table", 0))
return row
def get_all_recipes(
chapter_number: Optional[int] = None,
category: Optional[str] = None,
search: Optional[str] = None,
printed_status: Optional[str] = None
) -> List[Dict[str, Any]]:
query = "SELECT * FROM recipes WHERE 1=1"
params = []
if chapter_number is not None and chapter_number > 0:
query += " AND chapter_number = ?"
params.append(chapter_number)
if category and category.strip() and category != "alle":
query += " AND category = ?"
params.append(category.strip())
if search and search.strip():
query += " AND (title LIKE ? OR ingredients LIKE ? OR instructions LIKE ?)"
s = f"%{search.strip()}%"
params.extend([s, s, s])
if printed_status == "unprinted":
query += " AND printed_at IS NULL"
elif printed_status == "printed":
query += " AND printed_at IS NOT NULL"
query += " ORDER BY chapter_number ASC, title ASC"
rows = execute_query(query, tuple(params), fetch="all")
for r in rows:
r["printed_at_formatted"] = format_printed_date(r.get("printed_at"))
r["has_portion_table"] = bool(r.get("has_portion_table", 0))
return rows
def mark_recipes_printed(recipe_ids: List[int], print_date: Optional[str] = None) -> int:
"""Markiert die angegebenen Rezepte als gedruckt."""
if not recipe_ids:
return 0
date_val = print_date or datetime.now().strftime("%Y-%m-%d %H:%M:%S")
placeholders = ", ".join(["?"] * len(recipe_ids))
query = f"UPDATE recipes SET printed_at = ? WHERE id IN ({placeholders})"
params = [date_val] + list(recipe_ids)
res = execute_query(query, tuple(params), fetch="none", commit=True)
return res.get("rowcount", 0)
def unmark_recipes_printed(recipe_ids: List[int]) -> int:
"""Entfernt den Gedruckt-Status von den angegebenen Rezepten."""
if not recipe_ids:
return 0
placeholders = ", ".join(["?"] * len(recipe_ids))
query = f"UPDATE recipes SET printed_at = NULL WHERE id IN ({placeholders})"
res = execute_query(query, tuple(recipe_ids), fetch="none", commit=True)
return res.get("rowcount", 0)
def toggle_recipe_printed(recipe_id: int) -> Dict[str, Any]:
"""Schaltet den Druckstatus eines Rezepts um."""
rec = get_recipe(recipe_id)
if not rec:
raise KeyError(f"Rezept #{recipe_id} nicht gefunden.")
if rec.get("printed_at"):
unmark_recipes_printed([recipe_id])
else:
mark_recipes_printed([recipe_id])
return get_recipe(recipe_id)
# ==========================================
# WOCHENPLAN (MEAL PLANNER)
# ==========================================
def get_meal_plan(year: int, week: int) -> List[Dict[str, Any]]:
"""Gibt alle Einträge des Wochenplans für die angegebene KW zurück."""
query = """
SELECT
m.id, m.year, m.week, m.day_of_week, m.meal_type, m.recipe_id, m.custom_text,
r.title AS recipe_title, r.image_url AS recipe_image_url, r.chapter_number AS recipe_chapter_number,
r.category AS recipe_category
FROM meal_plans m
LEFT JOIN recipes r ON m.recipe_id = r.id
WHERE m.year = ? AND m.week = ?
ORDER BY m.day_of_week ASC,
CASE m.meal_type
WHEN 'frueh' THEN 1
WHEN 'mittag' THEN 2
WHEN 'kaffee' THEN 3
WHEN 'abend' THEN 4
ELSE 5
END ASC
"""
return execute_query(query, (year, week), fetch="all")
def set_meal_plan_slot(
year: int,
week: int,
day_of_week: int,
meal_type: str,
recipe_id: Optional[int] = None,
custom_text: Optional[str] = None
) -> Dict[str, Any]:
"""Setzt oder aktualisiert einen Slot im Wochenplan."""
if DB_ENGINE == "mariadb":
query = """
INSERT INTO meal_plans (year, week, day_of_week, meal_type, recipe_id, custom_text)
VALUES (?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
recipe_id = VALUES(recipe_id),
custom_text = VALUES(custom_text),
updated_at = CURRENT_TIMESTAMP
"""
execute_query(query, (year, week, day_of_week, meal_type, recipe_id, custom_text), fetch="none", commit=True)
else:
query = """
INSERT INTO meal_plans (year, week, day_of_week, meal_type, recipe_id, custom_text, updated_at)
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(year, week, day_of_week, meal_type) DO UPDATE SET
recipe_id = excluded.recipe_id,
custom_text = excluded.custom_text,
updated_at = CURRENT_TIMESTAMP
"""
execute_query(query, (year, week, day_of_week, meal_type, recipe_id, custom_text), fetch="none", commit=True)
slot_query = """
SELECT
m.id, m.year, m.week, m.day_of_week, m.meal_type, m.recipe_id, m.custom_text,
r.title AS recipe_title, r.image_url AS recipe_image_url
FROM meal_plans m
LEFT JOIN recipes r ON m.recipe_id = r.id
WHERE m.year = ? AND m.week = ? AND m.day_of_week = ? AND m.meal_type = ?
"""
return execute_query(slot_query, (year, week, day_of_week, meal_type), fetch="one")
def delete_meal_plan_slot(year: int, week: int, day_of_week: int, meal_type: str) -> bool:
"""Löscht einen einzelnen Slot aus dem Wochenplan."""
query = "DELETE FROM meal_plans WHERE year = ? AND week = ? AND day_of_week = ? AND meal_type = ?"
res = execute_query(query, (year, week, day_of_week, meal_type), fetch="none", commit=True)
return res.get("rowcount", 0) > 0
def clear_meal_plan_week(year: int, week: int) -> int:
"""Leert den gesamten Wochenplan für eine Kalenderwoche."""
query = "DELETE FROM meal_plans WHERE year = ? AND week = ?"
res = execute_query(query, (year, week), fetch="none", commit=True)
return res.get("rowcount", 0)
+183
View File
@@ -0,0 +1,183 @@
# -*- coding: utf-8 -*-
"""
Helper module for ingredient parsing, table structuring, and quantity scaling.
"""
import re
from typing import List, Dict, Any, Optional
COMMON_UNITS = [
"kg", "g", "mg", "l", "liter", "ml", "cl", "el", "tl", "esslöffel", "teelöffel",
"pck.", "pck", "päckchen", "pkt.", "pkt", "dose", "dosen", "glas", "gläser",
"prise", "prisen", "msp.", "msp", "messerspitze", "bund", "stk.", "stk",
"stück", "stücke", "zehe", "zehen", "zweig", "zweige", "blatt", "blätter",
"scheibe", "scheiben", "stange", "stangen", "kopf", "nest", "nester", "tropfen", "tr."
]
def parse_fraction(val_str: str) -> Optional[float]:
val_str = val_str.strip()
if "/" in val_str:
parts = val_str.split("/")
if len(parts) == 2:
try:
return float(parts[0]) / float(parts[1])
except ValueError:
pass
try:
return float(val_str.replace(",", "."))
except ValueError:
return None
def parse_ingredient_line(line: str) -> Dict[str, Any]:
raw = line.strip()
if not raw:
return {"type": "empty", "raw": ""}
# Check if section header (ends with colon or keywords)
lower = raw.lower()
if raw.endswith(":") or lower.startswith("für ") or lower in [
"teig", "sauce", "soße", "füllung", "belag", "creme", "glasur", "marinade", "deko",
"schichten & deko", "keksschicht", "heidelbeersoße", "zitronen-zimt-sirup", "zutaten",
"einlage & toppings", "gemüse & toppings", "lemon curd", "mascarponecreme"
]:
hdr_text = raw.rstrip(":")
return {
"type": "header",
"is_header": True,
"text": hdr_text,
"name": hdr_text,
"raw": raw
}
# Regex to match leading amount
match = re.match(r"^(\d+\s+\d+/\d+|\d+/\d+|\d+(?:[.,]\d+)?)(?:\s*-\s*(\d+(?:[.,]\d+)?))?\s*(.*)$", raw)
if match:
amt_str = match.group(1).strip()
range_end = match.group(2)
rest = match.group(3).strip()
if " " in amt_str and "/" in amt_str:
whole, frac = amt_str.split()
val = float(whole) + (parse_fraction(frac) or 0.0)
elif "/" in amt_str:
val = parse_fraction(amt_str) or 0.0
else:
val = parse_fraction(amt_str) or 0.0
display_amt = f"{amt_str}-{range_end}" if range_end else amt_str
rest_parts = rest.split(None, 1)
if rest_parts:
first_word_clean = rest_parts[0].lower().rstrip(".,")
if first_word_clean in COMMON_UNITS or rest_parts[0].lower() in COMMON_UNITS:
unit = rest_parts[0]
ing_name = rest_parts[1] if len(rest_parts) > 1 else ""
return {
"type": "ingredient",
"is_header": False,
"amount_val": val,
"amount": val,
"amount_str": display_amt,
"amount_formatted": display_amt,
"unit": unit,
"name": ing_name,
"raw": raw
}
return {
"type": "ingredient",
"is_header": False,
"amount_val": val,
"amount": val,
"amount_str": display_amt,
"amount_formatted": display_amt,
"unit": "",
"name": rest,
"raw": raw
}
return {
"type": "ingredient",
"is_header": False,
"amount_val": None,
"amount": None,
"amount_str": "",
"amount_formatted": "",
"unit": "",
"name": raw,
"raw": raw
}
def parse_ingredients_block(text: str) -> List[Dict[str, Any]]:
lines = [l.strip() for l in (text or "").split("\n") if l.strip()]
return [parse_ingredient_line(l) for l in lines]
def format_quantity_py(num: Optional[float]) -> str:
"""Formatiert eine Zahl lesbar für Rezepte (z. B. 0.5 -> ½, 1.5 -> 1 ½, 2.0 -> 2)."""
if num is None or num <= 0:
return ""
whole = int(num)
frac = round((num - whole) * 100) / 100.0
frac_str = ""
if 0.20 <= frac <= 0.30:
frac_str = "¼"
elif 0.31 <= frac <= 0.40:
frac_str = "⅓"
elif 0.45 <= frac <= 0.55:
frac_str = "½"
elif 0.63 <= frac <= 0.70:
frac_str = "⅔"
elif 0.72 <= frac <= 0.80:
frac_str = "¾"
if frac_str:
return f"{whole} {frac_str}".strip() if whole > 0 else frac_str
if abs(num - round(num)) < 0.05:
return str(int(round(num)))
# Deutsche Kommaschreibweise
val_str = f"{num:.1f}".rstrip("0").rstrip(".").replace(".", ",")
return val_str
def calculate_portion_matrix(
parsed_ingredients: List[Dict[str, Any]],
base_portions: int = 4,
targets: tuple = (2, 4, 6, 8)
) -> List[Dict[str, Any]]:
"""Berechnet für Waffeln / Pancakes etc. eine vorberechnete Tabelle für 2, 4, 6, 8 Portionen."""
if not base_portions or base_portions <= 0:
base_portions = 4
matrix = []
for item in parsed_ingredients:
if item.get("is_header"):
matrix.append({
"is_header": True,
"text": item.get("text") or item.get("name") or "",
"amounts": {p: "" for p in targets}
})
continue
row_amounts = {}
for p in targets:
amt_val = item.get("amount_val")
unit = item.get("unit") or ""
if amt_val is not None:
scaled_val = amt_val * (p / float(base_portions))
formatted_amt = format_quantity_py(scaled_val)
row_amounts[p] = f"{formatted_amt} {unit}".strip()
else:
row_amounts[p] = "—"
matrix.append({
"is_header": False,
"name": item.get("name") or item.get("raw") or "",
"raw": item.get("raw", ""),
"amounts": row_amounts
})
return matrix
Executable
+856
View File
@@ -0,0 +1,856 @@
import os
import uuid
import random
import shutil
import time
import hmac
import hashlib
from typing import List, Optional, Dict, Any
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException, Request, Query, Form, File, UploadFile
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from datetime import datetime, date, timedelta
from pydantic import BaseModel, Field
from .ingredients import parse_ingredients_block, calculate_portion_matrix
from .database import (
init_db,
get_all_dishes,
add_dish,
update_dish,
delete_dish,
get_stats,
query_candidates,
query_recipe_candidates,
CATEGORIES,
EFFORTS,
CHAPTERS,
CHAPTER_MAP,
get_all_recipes,
get_recipe,
add_recipe,
update_recipe,
delete_recipe,
get_chapter_info_for_dish,
mark_recipes_printed,
unmark_recipes_printed,
toggle_recipe_printed,
get_meal_plan,
set_meal_plan_slot,
delete_meal_plan_slot,
clear_meal_plan_week
)
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
STATIC_DIR = os.path.join(BASE_DIR, "static")
TEMPLATES_DIR = os.path.join(BASE_DIR, "templates")
# --- Authentifizierung & Privater Modus ---
APP_USERNAME = os.getenv("APP_USERNAME", "admin")
APP_PASSWORD = os.getenv("APP_PASSWORD", "wtc2026!")
SECRET_KEY = os.getenv("SECRET_KEY", "wtc_cookbook_secret_salt_2026_supersecure")
COOKIE_NAME = "wtc_auth"
PUBLIC_PATHS = {"/login", "/api/login", "/favicon.ico"}
def create_auth_token(username: str, days: int = 30) -> str:
expiry = int(time.time()) + (days * 86400)
msg = f"{username}:{expiry}"
sig = hmac.new(SECRET_KEY.encode("utf-8"), msg.encode("utf-8"), hashlib.sha256).hexdigest()
return f"{username}:{expiry}:{sig}"
def verify_auth_token(token: Optional[str]) -> bool:
if not token:
return False
parts = token.split(":")
if len(parts) != 3:
return False
user, expiry_str, sig = parts
try:
expiry = int(expiry_str)
except ValueError:
return False
if time.time() > expiry:
return False
msg = f"{user}:{expiry}"
expected_sig = hmac.new(SECRET_KEY.encode("utf-8"), msg.encode("utf-8"), hashlib.sha256).hexdigest()
return hmac.compare_digest(sig, expected_sig)
@asynccontextmanager
async def lifespan(app: FastAPI):
init_db()
os.makedirs(os.path.join(STATIC_DIR, "uploads", "recipes"), exist_ok=True)
yield
app = FastAPI(title="What To Cook? - Zufallsgenerator & Kochbuch", lifespan=lifespan)
# Middleware für Website-weiten Schutz
@app.middleware("http")
async def auth_middleware(request: Request, call_next):
path = request.url.path
if path.startswith("/static/") or path in PUBLIC_PATHS:
return await call_next(request)
token = request.cookies.get(COOKIE_NAME)
if not verify_auth_token(token):
if path.startswith("/api/"):
return JSONResponse(
status_code=401,
content={"status": "unauthorized", "message": "Bitte anmelden."}
)
next_target = request.url.path
if request.url.query:
next_target += f"?{request.url.query}"
return RedirectResponse(url=f"/login?next={next_target}", status_code=303)
return await call_next(request)
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
templates = Jinja2Templates(directory=TEMPLATES_DIR)
# Mapping Kochbuch-Kapitel -> Zufallsgenerator-Kategorien
CHAPTER_TO_DISH_CAT = {
"Suppen & Eintöpfe": "Suppen",
"Traditionell & Basics": "Hausmannskost",
"Pasta & Nudelgerichte": "Nudeln",
"Kartoffeln": "Kartoffeln",
"Reis & Getreide": "Reis",
"Teig & Gebackenes": "Teigig",
"Asiatische Küche": "asiatisch",
"Süßspeisen": "Süßspeisen",
"Desserts": "Süßspeisen",
"Kuchen & Gebäck": "Süßspeisen",
"Plätzchen": "Süßspeisen"
}
# --- Pydantic Models ---
class SlotRollRequest(BaseModel):
slot_number: int = Field(..., ge=1, le=5)
chapter_number: Optional[int] = None
category: Optional[str] = None
efforts: Optional[List[str]] = None
current_dish_id: Optional[int] = None
class MultiRollRequest(BaseModel):
slots: List[SlotRollRequest]
class SingleRollRequest(BaseModel):
slot_number: int = Field(..., ge=1, le=5)
chapter_number: Optional[int] = None
category: Optional[str] = None
efforts: Optional[List[str]] = None
current_dish_id: Optional[int] = None
other_dish_ids: Optional[List[int]] = Field(default_factory=list)
class DishCreate(BaseModel):
name: str
category: Optional[str] = None
effort: str
class DishUpdate(BaseModel):
name: str
category: Optional[str] = None
effort: str
class MarkPrintedRequest(BaseModel):
recipe_ids: List[int]
printed_at: Optional[str] = None
class UnmarkPrintedRequest(BaseModel):
recipe_ids: List[int]
class MealPlanSlotRequest(BaseModel):
year: int
week: int
day_of_week: int
meal_type: str
recipe_id: Optional[int] = None
custom_text: Optional[str] = None
# ==========================================
# AUTH VIEWS
# ==========================================
@app.get("/login", response_class=HTMLResponse)
async def login_get(request: Request, next: str = "/cookbook"):
token = request.cookies.get(COOKIE_NAME)
if verify_auth_token(token):
return RedirectResponse(url=next or "/cookbook", status_code=303)
return templates.TemplateResponse(
request=request,
name="login.html",
context={"next_url": next, "error": None, "username": APP_USERNAME}
)
@app.post("/login", response_class=HTMLResponse)
async def login_post(
request: Request,
username: str = Form("admin"),
password: str = Form(...),
remember: Optional[str] = Form(None),
next: str = Form("/cookbook")
):
valid_user = (username.strip().lower() == APP_USERNAME.lower()) or (username.strip() == "admin")
valid_pass = (password == APP_PASSWORD)
if valid_user and valid_pass:
days = 30 if remember else 1
token = create_auth_token(username.strip(), days=days)
target = next if next and not next.startswith("/login") else "/cookbook"
resp = RedirectResponse(url=target, status_code=303)
max_age = days * 86400
resp.set_cookie(
key=COOKIE_NAME,
value=token,
max_age=max_age,
expires=max_age,
path="/",
httponly=True,
samesite="lax"
)
return resp
else:
return templates.TemplateResponse(
request=request,
name="login.html",
context={
"next_url": next,
"error": "Ungültiges Passwort oder Benutzername.",
"username": username
},
status_code=400
)
@app.get("/logout")
async def logout():
resp = RedirectResponse(url="/login", status_code=303)
resp.delete_cookie(key=COOKIE_NAME, path="/")
return resp
# ==========================================
# VIEWS
# ==========================================
@app.get("/")
async def index_view():
"""Hauptroute leitet direkt auf das Kochbuch um"""
return RedirectResponse(url="/cookbook", status_code=302)
@app.get("/cookbook", response_class=HTMLResponse)
async def cookbook_view(
request: Request,
chapter: Optional[int] = Query(None)
):
"""Kochbuch Übersicht mit Kapiteln, Inhaltsverzeichnis und Rezepten"""
all_recipes = get_all_recipes()
# Berechne Portions-Matrix nur für Waffeln / ausgewählte Rezepte
for r in all_recipes:
is_matrix = bool(r.get("has_portion_table")) or any(k in (r.get("title") or "").lower() for k in ["waffel", "pancake", "pfannkuchen"])
r["has_portion_table"] = is_matrix
if is_matrix:
parsed = parse_ingredients_block(r.get("ingredients", ""))
r["portion_matrix"] = calculate_portion_matrix(parsed, base_portions=r.get("portions") or 4, targets=(2, 4, 6, 8))
# Gruppieren nach Kapitel
recipes_by_chapter: Dict[int, List[Dict[str, Any]]] = {}
for r in all_recipes:
chap_num = r.get("chapter_number", 1)
if chap_num not in recipes_by_chapter:
recipes_by_chapter[chap_num] = []
recipes_by_chapter[chap_num].append(r)
recipe_counts = {c["number"]: len(recipes_by_chapter.get(c["number"], [])) for c in CHAPTERS}
if chapter is not None and chapter > 0:
chapters_to_display = [c for c in CHAPTERS if c["number"] == chapter]
else:
chapters_to_display = CHAPTERS
# --- Seitenzahlen & Inhaltsverzeichnis (TOC) berechnen ---
# Seite 1: Cover
# TOC: 1 oder 2 Seiten (wenn > 20 Rezepte vorhanden sind)
total_display_recipes = len(all_recipes)
toc_page_count = 2 if total_display_recipes > 20 else (1 if total_display_recipes > 0 else 0)
current_page = 1 + toc_page_count + 1
display_chapters_data = []
for chap in chapters_to_display:
chap_copy = dict(chap)
chap_recipes = recipes_by_chapter.get(chap["number"], [])
if not chap_recipes:
display_chapters_data.append(chap_copy)
continue
chap_copy["page_number"] = current_page
current_page += 1
for r in chap_recipes:
r["page_number"] = current_page
current_page += 1
chap_copy["recipes"] = chap_recipes
display_chapters_data.append(chap_copy)
# Inhaltsverzeichnis auf 2 Seiten aufteilen
toc_p1 = []
toc_p2 = []
if toc_page_count == 2:
accum = 0
for c in display_chapters_data:
c_recs = recipes_by_chapter.get(c["number"], [])
if not c_recs:
continue
item_weight = len(c_recs) + 2
if accum < 28 or not toc_p1:
toc_p1.append(c)
accum += item_weight
else:
toc_p2.append(c)
else:
toc_p1 = [c for c in display_chapters_data if recipes_by_chapter.get(c["number"], [])]
# User Agent Check für Apple iOS
user_agent = request.headers.get("user-agent", "").lower()
is_ios = any(d in user_agent for d in ["iphone", "ipad", "ipod"])
return templates.TemplateResponse(
request=request,
name="cookbook.html",
context={
"chapters": CHAPTERS,
"current_chapter": chapter,
"chapters_to_display": display_chapters_data,
"recipes_by_chapter": recipes_by_chapter,
"recipe_counts": recipe_counts,
"total_recipes": len(all_recipes),
"toc_page_count": toc_page_count,
"toc_p1": toc_p1,
"toc_p2": toc_p2,
"is_ios": is_ios
}
)
@app.get("/cookbook/recipe/{recipe_id}", response_class=HTMLResponse)
async def recipe_detail_view(request: Request, recipe_id: int):
"""Einheitliche Rezeptseite im handgezeichneten Stil"""
recipe = get_recipe(recipe_id)
if not recipe:
raise HTTPException(status_code=404, detail="Rezept nicht gefunden.")
is_matrix = bool(recipe.get("has_portion_table")) or any(k in (recipe.get("title") or "").lower() for k in ["waffel", "pancake", "pfannkuchen"])
recipe["has_portion_table"] = is_matrix
if is_matrix:
parsed = parse_ingredients_block(recipe.get("ingredients", ""))
recipe["portion_matrix"] = calculate_portion_matrix(parsed, base_portions=recipe.get("portions") or 4, targets=(2, 4, 6, 8))
return templates.TemplateResponse(
request=request,
name="recipe_detail.html",
context={
"recipe": recipe,
"chapters": CHAPTERS
}
)
@app.get("/cookbook/new", response_class=HTMLResponse)
async def recipe_new_view(request: Request, chapter: Optional[int] = Query(None)):
"""Eingabemaske für neues Rezept"""
return templates.TemplateResponse(
request=request,
name="recipe_form.html",
context={
"recipe": None,
"chapters": CHAPTERS,
"selected_chapter": chapter
}
)
@app.get("/cookbook/edit/{recipe_id}", response_class=HTMLResponse)
async def recipe_edit_view(request: Request, recipe_id: int):
"""Eingabemaske zum Bearbeiten eines Rezepts"""
recipe = get_recipe(recipe_id)
if not recipe:
raise HTTPException(status_code=404, detail="Rezept nicht gefunden.")
return templates.TemplateResponse(
request=request,
name="recipe_form.html",
context={
"recipe": recipe,
"chapters": CHAPTERS,
"selected_chapter": recipe.get("chapter_number")
}
)
# ==========================================
# API: KOCHBUCH (RECIPES)
# ==========================================
def save_uploaded_image(image_file: UploadFile) -> Optional[str]:
if not image_file or not image_file.filename:
return None
ext = os.path.splitext(image_file.filename)[1].lower()
if ext not in [".jpg", ".jpeg", ".png", ".webp", ".gif"]:
return None
upload_dir = os.path.join(STATIC_DIR, "uploads", "recipes")
os.makedirs(upload_dir, exist_ok=True)
safe_name = f"{uuid.uuid4().hex[:10]}_{os.path.basename(image_file.filename)}"
file_path = os.path.join(upload_dir, safe_name)
with open(file_path, "wb") as buffer:
shutil.copyfileobj(image_file.file, buffer)
return f"/static/uploads/recipes/{safe_name}"
@app.get("/api/recipes")
def api_get_recipes(
chapter: Optional[int] = Query(None),
category: Optional[str] = Query(None),
search: Optional[str] = Query(None),
printed: Optional[str] = Query(None)
):
return get_all_recipes(chapter_number=chapter, category=category, search=search, printed_status=printed)
@app.get("/api/recipes/{recipe_id}")
def api_get_recipe(recipe_id: int):
recipe = get_recipe(recipe_id)
if not recipe:
raise HTTPException(status_code=404, detail="Rezept nicht gefunden.")
recipe["parsed_ingredients"] = parse_ingredients_block(recipe.get("ingredients", ""))
return recipe
@app.post("/api/recipes")
async def api_create_recipe(
title: str = Form(...),
chapter_number: int = Form(...),
effort: str = Form("mittel"),
prep_time: str = Form(""),
cook_time: str = Form(""),
portions: int = Form(4),
ingredients: str = Form(...),
instructions: str = Form(...),
notes: str = Form(""),
source: str = Form(""),
printed_at: Optional[str] = Form(None),
has_portion_table: Optional[int] = Form(0),
add_to_dishes: Optional[str] = Form(None),
image_file: Optional[UploadFile] = File(None)
):
chapter_info = next((c for c in CHAPTERS if c["number"] == chapter_number), None)
category_title = chapter_info["title"] if chapter_info else "Traditionell & Basics"
image_url = ""
if image_file and image_file.filename:
image_url = save_uploaded_image(image_file) or ""
try:
recipe = add_recipe(
title=title,
category=category_title,
chapter_number=chapter_number,
effort=effort,
prep_time=prep_time,
cook_time=cook_time,
portions=portions,
ingredients=ingredients,
instructions=instructions,
notes=notes,
source=source,
image_url=image_url,
printed_at=printed_at if printed_at and printed_at.strip() else None,
has_portion_table=1 if (has_portion_table and str(has_portion_table).strip() in ("1", "true", "on", "yes")) else 0
)
should_add = bool(add_to_dishes and str(add_to_dishes).strip().lower() in ("true", "on", "1", "yes"))
if should_add:
dish_cat = CHAPTER_TO_DISH_CAT.get(category_title, "Hausmannskost")
dish_effort = effort if effort in EFFORTS else "mittel"
try:
add_dish(name=title, category=dish_cat, effort=dish_effort)
except Exception:
pass
return {"status": "ok", "recipe": recipe}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@app.put("/api/recipes/{recipe_id}")
async def api_update_recipe(
recipe_id: int,
title: str = Form(...),
chapter_number: int = Form(...),
effort: str = Form("mittel"),
prep_time: str = Form(""),
cook_time: str = Form(""),
portions: int = Form(4),
ingredients: str = Form(...),
instructions: str = Form(...),
notes: str = Form(""),
source: str = Form(""),
printed_at: Optional[str] = Form(None),
has_portion_table: Optional[int] = Form(None),
image_file: Optional[UploadFile] = File(None)
):
chapter_info = next((c for c in CHAPTERS if c["number"] == chapter_number), None)
category_title = chapter_info["title"] if chapter_info else "Traditionell & Basics"
new_image_url = None
if image_file and image_file.filename:
new_image_url = save_uploaded_image(image_file)
try:
table_val = 1 if (has_portion_table and str(has_portion_table).strip() in ("1", "true", "on", "yes")) else (0 if has_portion_table is not None else None)
recipe = update_recipe(
recipe_id=recipe_id,
title=title,
category=category_title,
chapter_number=chapter_number,
effort=effort,
prep_time=prep_time,
cook_time=cook_time,
portions=portions,
ingredients=ingredients,
instructions=instructions,
notes=notes,
source=source,
image_url=new_image_url,
printed_at=printed_at if printed_at and printed_at.strip() else None,
has_portion_table=table_val
)
return {"status": "ok", "recipe": recipe}
except KeyError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@app.delete("/api/recipes/{recipe_id}")
def api_delete_recipe(recipe_id: int):
success = delete_recipe(recipe_id)
if not success:
raise HTTPException(status_code=404, detail="Rezept nicht gefunden.")
return {"status": "ok", "deleted": True}
# --- API: Druckstatus & Druckdatum ---
@app.post("/api/recipes/mark-printed")
def api_mark_recipes_printed(payload: MarkPrintedRequest):
count = mark_recipes_printed(payload.recipe_ids, payload.printed_at)
return {"status": "ok", "marked_count": count}
@app.post("/api/recipes/unmark-printed")
def api_unmark_recipes_printed(payload: UnmarkPrintedRequest):
count = unmark_recipes_printed(payload.recipe_ids)
return {"status": "ok", "unmarked_count": count}
@app.post("/api/recipes/{recipe_id}/toggle-printed")
def api_toggle_recipe_printed(recipe_id: int):
try:
updated = toggle_recipe_printed(recipe_id)
return {"status": "ok", "recipe": updated}
except KeyError as e:
raise HTTPException(status_code=404, detail=str(e))
# ==========================================
# API: ZUFALLSGENERATOR & GERICHTE
# ==========================================
@app.get("/api/categories")
def api_categories():
return {
"categories": CATEGORIES,
"efforts": EFFORTS,
"chapters": CHAPTERS
}
@app.get("/api/stats")
def api_stats():
return get_stats()
@app.get("/api/dishes")
def api_get_dishes(
category: Optional[str] = Query(None),
effort: Optional[str] = Query(None),
search: Optional[str] = Query(None)
):
return get_all_dishes(category=category, effort=effort, search=search)
@app.post("/api/dishes")
def api_create_dish(payload: DishCreate):
try:
cat = payload.category or "Hausmannskost"
dish = add_dish(payload.name, cat, payload.effort)
return {"status": "ok", "dish": dish}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@app.put("/api/dishes/{dish_id}")
def api_update_dish(dish_id: int, payload: DishUpdate):
try:
cat = payload.category or "Hausmannskost"
dish = update_dish(dish_id, payload.name, cat, payload.effort)
return {"status": "ok", "dish": dish}
except KeyError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@app.delete("/api/dishes/{dish_id}")
def api_delete_dish(dish_id: int):
success = delete_dish(dish_id)
if not success:
raise HTTPException(status_code=404, detail="Gericht nicht gefunden.")
return {"status": "ok", "deleted": True}
def resolve_slot_chapter(slot) -> Optional[int]:
if getattr(slot, "chapter_number", None) and slot.chapter_number > 0:
return slot.chapter_number
if slot.category:
s = str(slot.category).strip()
if s.isdigit():
return int(s)
for c in CHAPTERS:
if s.lower() in c["title"].lower():
return c["number"]
return None
@app.post("/api/roll")
def api_roll_dishes(payload: MultiRollRequest):
"""Rolls recipes directly from the cookbook without duplicates in round."""
slots = payload.slots
if not slots:
raise HTTPException(status_code=400, detail="Keine Mahlzeiten zur Auslosung übergeben.")
if len(slots) > 5:
raise HTTPException(status_code=400, detail="Maximal 5 Mahlzeiten können gleichzeitig ausgelost werden.")
results = []
chosen_ids = set()
slot_candidates = []
for slot in slots:
chap_num = resolve_slot_chapter(slot)
candidates = query_recipe_candidates(chap_num, slot.efforts)
slot_candidates.append({
"slot": slot,
"chapter_number": chap_num,
"candidates": candidates
})
indexed_slots = list(enumerate(slot_candidates))
indexed_slots.sort(key=lambda item: len(item[1]["candidates"]))
temp_assignment = {}
for original_idx, item in indexed_slots:
slot = item["slot"]
chap_num = item["chapter_number"]
candidates = item["candidates"]
available = [c for c in candidates if c["id"] not in chosen_ids]
if not available:
available = candidates
if not available:
chap_title = CHAPTERS[chap_num - 1]["title"] if chap_num and 1 <= chap_num <= len(CHAPTERS) else ""
temp_assignment[original_idx] = {
"slot_number": slot.slot_number,
"recipe": None,
"dish": None,
"status": "not_found",
"chapter_number": chap_num,
"chapter_title": chap_title,
"message": f"Noch keine Rezepte in Kapitel {chap_num} ({chap_title}) vorhanden." if chap_num else "Noch keine Rezepte im Kochbuch vorhanden."
}
continue
alternatives = [c for c in available if c["id"] != slot.current_dish_id]
selection_pool = alternatives if alternatives else available
selected = random.choice(selection_pool)
chosen_ids.add(selected["id"])
selected_recipe = dict(selected)
c_num = selected_recipe.get("chapter_number", 1)
chap = CHAPTERS[c_num - 1] if 1 <= c_num <= len(CHAPTERS) else CHAPTERS[0]
selected_recipe["chapter_title"] = chap["title"]
selected_recipe["chapter_icon"] = chap["icon"]
selected_recipe["chapter_illustration"] = chap["illustration_url"]
selected_recipe["name"] = selected_recipe["title"]
selected_recipe["recipe_id"] = selected_recipe["id"]
temp_assignment[original_idx] = {
"slot_number": slot.slot_number,
"dish": selected_recipe,
"recipe": selected_recipe,
"status": "ok"
}
for idx in range(len(slots)):
results.append(temp_assignment[idx])
return {"status": "ok", "results": results}
@app.post("/api/roll-single")
def api_roll_single(payload: SingleRollRequest):
"""Rerolls a single recipe slot directly from cookbook."""
chap_num = resolve_slot_chapter(payload)
candidates = query_recipe_candidates(chap_num, payload.efforts)
if not candidates:
chap_title = CHAPTERS[chap_num - 1]["title"] if chap_num and 1 <= chap_num <= len(CHAPTERS) else ""
return {
"status": "not_found",
"dish": None,
"recipe": None,
"chapter_number": chap_num,
"chapter_title": chap_title,
"message": f"Noch keine Rezepte in Kapitel {chap_num} ({chap_title}) vorhanden." if chap_num else "Noch keine Rezepte im Kochbuch vorhanden."
}
other_ids = set(payload.other_dish_ids or [])
pool = [c for c in candidates if c["id"] not in other_ids]
if not pool:
pool = candidates
alternatives = [c for c in pool if c["id"] != payload.current_dish_id]
selection_pool = alternatives if alternatives else pool
selected = random.choice(selection_pool)
selected_recipe = dict(selected)
c_num = selected_recipe.get("chapter_number", 1)
chap = CHAPTERS[c_num - 1] if 1 <= c_num <= len(CHAPTERS) else CHAPTERS[0]
selected_recipe["chapter_title"] = chap["title"]
selected_recipe["chapter_icon"] = chap["icon"]
selected_recipe["chapter_illustration"] = chap["illustration_url"]
selected_recipe["name"] = selected_recipe["title"]
selected_recipe["recipe_id"] = selected_recipe["id"]
return {
"status": "ok",
"slot_number": payload.slot_number,
"dish": selected_recipe,
"recipe": selected_recipe,
"is_repetition": (selected["id"] == payload.current_dish_id and len(candidates) > 1)
}
# ==========================================
# API: WOCHENMENÜ-PLAN (MEAL PLANNER)
# ==========================================
def get_week_dates(year: int, week: int) -> Dict[str, Any]:
"""Berechnet die Kalenderdaten (Montag bis Sonntag) für eine gegebene KW."""
try:
monday = date.fromisocalendar(year, week, 1)
except AttributeError:
first_day = date(year, 1, 4)
monday = first_day + timedelta(days=((week - 1) * 7) - first_day.weekday())
day_names = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"]
days = []
for i in range(7):
cur_date = monday + timedelta(days=i)
days.append({
"day_index": i,
"name": day_names[i],
"date_formatted": cur_date.strftime("%d.%m."),
"iso_date": cur_date.strftime("%Y-%m-%d"),
"is_today": (cur_date == date.today())
})
sunday = monday + timedelta(days=6)
return {
"year": year,
"week": week,
"range_formatted": f"{monday.strftime('%d.%m.')} – {sunday.strftime('%d.%m.%Y')}",
"days": days
}
@app.get("/api/mealplan")
def api_get_mealplan(year: Optional[int] = Query(None), week: Optional[int] = Query(None)):
today = date.today()
iso_year, iso_week, _ = today.isocalendar()
y = year or iso_year
w = week or iso_week
week_info = get_week_dates(y, w)
slots = get_meal_plan(y, w)
# Slot-Map: "0_frueh", "0_mittag", "0_abend" etc.
slot_map = {}
for s in slots:
k = f"{s['day_of_week']}_{s['meal_type']}"
slot_map[k] = s
# Kompakte Rezeptliste für schnelles Dropdown / Zuweisung
all_recs = get_all_recipes()
recipes_summary = [
{
"id": r["id"],
"title": r["title"],
"category": r.get("category", ""),
"chapter_number": r.get("chapter_number", 1),
"effort": r.get("effort", "mittel")
}
for r in all_recs
]
return {
"status": "ok",
"year": y,
"week": w,
"current_year": iso_year,
"current_week": iso_week,
"is_current_week": (y == iso_year and w == iso_week),
"range_formatted": week_info["range_formatted"],
"days": week_info["days"],
"slots": slot_map,
"recipes": recipes_summary
}
@app.post("/api/mealplan/slot")
def api_save_mealplan_slot(payload: MealPlanSlotRequest):
if payload.day_of_week < 0 or payload.day_of_week > 6:
raise HTTPException(status_code=400, detail="Ungültiger Wochentag (0-6).")
if payload.meal_type not in ["frueh", "mittag", "kaffee", "abend"]:
raise HTTPException(status_code=400, detail="Ungültiger Mahlzeittyp (frueh, mittag, kaffee, abend).")
slot = set_meal_plan_slot(
year=payload.year,
week=payload.week,
day_of_week=payload.day_of_week,
meal_type=payload.meal_type,
recipe_id=payload.recipe_id,
custom_text=payload.custom_text
)
return {"status": "ok", "slot": slot}
@app.delete("/api/mealplan/slot")
def api_delete_mealplan_slot(
year: int = Query(...),
week: int = Query(...),
day_of_week: int = Query(...),
meal_type: str = Query(...)
):
success = delete_meal_plan_slot(year, week, day_of_week, meal_type)
return {"status": "ok", "deleted": success}
@app.delete("/api/mealplan/week")
def api_clear_mealplan_week(year: int = Query(...), week: int = Query(...)):
count = clear_meal_plan_week(year, week)
return {"status": "ok", "cleared_count": count}
+695
View File
@@ -0,0 +1,695 @@
/**
* What To Cook? - Frontend Application
*/
// Category icons mapping
const CATEGORY_ICONS = {
"Reis": "🍚",
"Nudeln": "🍝",
"Kartoffeln": "🥔",
"Teigig": "🍕",
"asiatisch": "🥢",
"Hausmannskost": "🥩",
"Suppen": "🍲",
"Eintöpfe": "🥘",
"Süßspeisen": "🥞",
"Fisch": "🐟"
};
const CATEGORIES = [
"Reis",
"Nudeln",
"Kartoffeln",
"Teigig",
"asiatisch",
"Hausmannskost",
"Suppen",
"Eintöpfe",
"Süßspeisen",
"Fisch"
];
const EFFORTS = ["gering", "mittel", "hoch"];
function getDishCategories(dish) {
if (!dish) return [];
if (Array.isArray(dish.categories) && dish.categories.length > 0) {
return dish.categories;
}
if (dish.category) {
return dish.category.split(",").map(c => c.trim()).filter(Boolean);
}
return [];
}
function renderCategoryBadges(dish) {
const cats = getDishCategories(dish);
if (cats.length === 0) return "";
return cats.map(cat => {
const icon = CATEGORY_ICONS[cat] || "🍲";
return `<span class="badge-tag badge-category"><span>${icon}</span> ${cat}</span>`;
}).join("");
}
// Application State
const state = {
activeTab: "generator",
slots: [
{ slot_number: 1, active: true, category: "", efforts: [], current_dish: null },
{ slot_number: 2, active: true, category: "", efforts: [], current_dish: null },
{ slot_number: 3, active: false, category: "", efforts: [], current_dish: null },
{ slot_number: 4, active: false, category: "", efforts: [], current_dish: null },
{ slot_number: 5, active: false, category: "", efforts: [], current_dish: null },
],
admin: {
dishes: [],
search: "",
category: "alle",
effort: "alle",
deleteTargetId: null
}
};
// --- Initialization ---
document.addEventListener("DOMContentLoaded", () => {
initTabs();
renderSlots();
populateDropdowns();
initGeneratorEvents();
initAdminEvents();
loadAdminDishes();
});
// --- Tab Navigation ---
function initTabs() {
const btnGenerator = document.getElementById("tab-btn-generator");
const btnAdmin = document.getElementById("tab-btn-admin");
const panelGenerator = document.getElementById("tab-generator");
const panelAdmin = document.getElementById("tab-admin");
btnGenerator.addEventListener("click", () => switchTab("generator"));
btnAdmin.addEventListener("click", () => switchTab("admin"));
function switchTab(tabName) {
state.activeTab = tabName;
if (tabName === "generator") {
btnGenerator.classList.add("active");
btnAdmin.classList.remove("active");
panelGenerator.classList.add("active");
panelAdmin.classList.remove("active");
panelAdmin.hidden = true;
panelGenerator.hidden = false;
} else {
btnAdmin.classList.add("active");
btnGenerator.classList.remove("active");
panelAdmin.classList.add("active");
panelGenerator.classList.remove("active");
panelGenerator.hidden = true;
panelAdmin.hidden = false;
loadAdminDishes();
}
}
}
// --- Populate Category Dropdowns ---
function populateDropdowns() {
const adminCatSelect = document.getElementById("admin-filter-category");
const modalCatGroup = document.getElementById("modal-category-group");
if (modalCatGroup) {
modalCatGroup.innerHTML = "";
}
CATEGORIES.forEach(cat => {
const icon = CATEGORY_ICONS[cat] || "🍲";
// Admin filter
if (adminCatSelect) {
const optFilter = document.createElement("option");
optFilter.value = cat;
optFilter.textContent = `${icon} ${cat}`;
adminCatSelect.appendChild(optFilter);
}
// Modal checkbox pill
if (modalCatGroup) {
const label = document.createElement("label");
label.className = "category-checkbox-label";
label.innerHTML = `
<input type="checkbox" name="modal-category" value="${cat}">
<span class="cat-pill">
<span>${icon}</span> ${cat}
</span>
`;
modalCatGroup.appendChild(label);
}
});
}
// --- Generator Slots Rendering ---
function renderSlots() {
const container = document.getElementById("slots-container");
container.innerHTML = "";
state.slots.forEach(slot => {
const card = document.createElement("div");
card.className = `slot-card ${slot.active ? "active" : "inactive"}`;
card.id = `slot-card-${slot.slot_number}`;
card.innerHTML = `
<div class="slot-card-header">
<div class="slot-title-group">
<span class="slot-number-badge">${slot.slot_number}</span>
<span class="slot-name">Gericht ${slot.slot_number}</span>
</div>
<label class="slot-toggle-label">
<span>${slot.active ? "Aktiv" : "Freigelassen"}</span>
<label class="switch">
<input type="checkbox" id="slot-toggle-${slot.slot_number}" ${slot.active ? "checked" : ""}>
<span class="slider"></span>
</label>
</label>
</div>
<div class="slot-body">
<div class="form-group">
<label for="slot-cat-${slot.slot_number}" class="form-label">Kategorie:</label>
<select id="slot-cat-${slot.slot_number}" class="custom-select">
<option value="" ${slot.category === "" ? "selected" : ""}>🎲 Beliebig (Alle Kategorien)</option>
${CATEGORIES.map(cat => {
const icon = CATEGORY_ICONS[cat] || "🍲";
return `<option value="${cat}" ${slot.category === cat ? "selected" : ""}>${icon} ${cat}</option>`;
}).join("")}
</select>
</div>
<div class="form-group">
<label class="form-label">Aufwand (Mehrfachauswahl möglich):</label>
<div class="effort-chips-group" id="effort-chips-${slot.slot_number}">
<button type="button" class="effort-chip ${slot.efforts.includes("gering") ? "selected" : ""}" data-effort="gering">
<span>🟢</span> Gering
</button>
<button type="button" class="effort-chip ${slot.efforts.includes("mittel") ? "selected" : ""}" data-effort="mittel">
<span>🟡</span> Mittel
</button>
<button type="button" class="effort-chip ${slot.efforts.includes("hoch") ? "selected" : ""}" data-effort="hoch">
<span>🔴</span> Hoch
</button>
</div>
</div>
</div>
`;
container.appendChild(card);
// Event: Toggle Active
const toggleInput = card.querySelector(`#slot-toggle-${slot.slot_number}`);
toggleInput.addEventListener("change", (e) => {
slot.active = e.target.checked;
renderSlots();
updateActiveCounter();
});
// Event: Category Change
const catSelect = card.querySelector(`#slot-cat-${slot.slot_number}`);
catSelect.addEventListener("change", (e) => {
slot.category = e.target.value;
});
// Event: Effort Multi-Select Chips
const chips = card.querySelectorAll(`#effort-chips-${slot.slot_number} .effort-chip`);
chips.forEach(chip => {
chip.addEventListener("click", () => {
const effortVal = chip.getAttribute("data-effort");
const idx = slot.efforts.indexOf(effortVal);
if (idx > -1) {
slot.efforts.splice(idx, 1);
chip.classList.remove("selected");
} else {
slot.efforts.push(effortVal);
chip.classList.add("selected");
}
});
});
});
updateActiveCounter();
}
function updateActiveCounter() {
const activeCount = state.slots.filter(s => s.active).length;
const countEl = document.getElementById("active-slots-count");
if (countEl) countEl.textContent = activeCount;
const btnHero = document.getElementById("btn-roll-all");
if (btnHero) {
const btnText = btnHero.querySelector(".btn-text");
if (activeCount === 0) {
btnText.textContent = "Bitte mindestens 1 Gericht aktivieren";
btnHero.disabled = true;
} else if (activeCount === 1) {
btnText.textContent = "1 Gericht auslosen";
btnHero.disabled = false;
} else {
btnText.textContent = `${activeCount} Gerichte auslosen`;
btnHero.disabled = false;
}
}
}
// --- Generator Events (Roll Logic) ---
function initGeneratorEvents() {
const btnRollAll = document.getElementById("btn-roll-all");
const btnRerollAll = document.getElementById("btn-reroll-all");
const btnBack = document.getElementById("btn-back-to-config");
btnRollAll.addEventListener("click", () => executeRoll(false));
btnRerollAll.addEventListener("click", () => executeRoll(true));
btnBack.addEventListener("click", () => {
window.scrollTo({ top: 0, behavior: "smooth" });
});
}
async function executeRoll(isReroll = false) {
const activeSlots = state.slots.filter(s => s.active);
if (activeSlots.length === 0) {
showToast("Bitte aktiviere mindestens 1 Gericht.", "error");
return;
}
const btnRollAll = document.getElementById("btn-roll-all");
const btnRerollAll = document.getElementById("btn-reroll-all");
btnRollAll.disabled = true;
btnRerollAll.disabled = true;
try {
const payload = {
slots: activeSlots.map(s => ({
slot_number: s.slot_number,
category: s.category || null,
efforts: s.efforts.length > 0 ? s.efforts : null,
current_dish_id: isReroll && s.current_dish ? s.current_dish.id : null
}))
};
const res = await fetch("/api/roll", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.detail || "Fehler beim Auslosen.");
}
// Update slots state with rolled dishes
data.results.forEach(resItem => {
const slot = state.slots.find(s => s.slot_number === resItem.slot_number);
if (slot) {
slot.current_dish = resItem.dish;
}
});
renderResults(data.results);
showToast(isReroll ? "Alle Gerichte neu ausgelost!" : "Zufallsauswahl erfolgreich!", "success");
// Scroll smoothly to results
const resultsSection = document.getElementById("results-section");
resultsSection.classList.remove("hidden");
resultsSection.scrollIntoView({ behavior: "smooth", block: "start" });
} catch (err) {
showToast(err.message, "error");
} finally {
btnRollAll.disabled = false;
btnRerollAll.disabled = false;
}
}
// Render Results Grid
function renderResults(results) {
const container = document.getElementById("results-list");
const countBadge = document.getElementById("results-count-badge");
container.innerHTML = "";
const validDishes = results.filter(r => r.dish !== null);
countBadge.textContent = `${validDishes.length} ${validDishes.length === 1 ? "Gericht" : "Gerichte"}`;
results.forEach(item => {
const slot = state.slots.find(s => s.slot_number === item.slot_number);
const dish = item.dish;
const card = document.createElement("div");
card.className = "result-card";
card.id = `result-card-${item.slot_number}`;
if (!dish) {
card.innerHTML = `
<div class="result-card-top">
<span class="result-slot-label">Gericht ${item.slot_number}</span>
</div>
<div class="result-dish-name" style="color: var(--text-dim);">
⚠️ Keine passenden Gerichte gefunden
</div>
<p style="font-size: 0.85rem; color: var(--text-muted);">
Für die gewählte Kombination (Kategorie / Aufwand) gibt es noch keine Einträge.
</p>
`;
container.appendChild(card);
return;
}
const effortLabel = dish.effort.charAt(0).toUpperCase() + dish.effort.slice(1);
card.innerHTML = `
<div class="result-card-top">
<span class="result-slot-label">Gericht ${item.slot_number}</span>
</div>
<div class="result-dish-name" id="dish-name-${item.slot_number}">${dish.name}</div>
<div class="result-tags">
${renderCategoryBadges(dish)}
<span class="badge-tag badge-effort-${dish.effort}">
${effortLabel}
</span>
</div>
<div class="result-card-actions">
<button class="btn-reroll-single" data-slot="${item.slot_number}" title="Nur dieses Gericht neu würfeln">
<span class="dice-icon">🎲</span> Neu würfeln
</button>
</div>
`;
container.appendChild(card);
// Event: Single Reroll Button
const btnRerollSingle = card.querySelector(".btn-reroll-single");
btnRerollSingle.addEventListener("click", () => rerollSingleSlot(item.slot_number, btnRerollSingle, card));
});
}
// --- Single Slot Reroll Logic ---
async function rerollSingleSlot(slotNumber, btnElement, cardElement) {
const slot = state.slots.find(s => s.slot_number === slotNumber);
if (!slot || !slot.current_dish) return;
btnElement.classList.add("spinning");
cardElement.classList.add("rerolling");
// Collect other dishes currently on screen so we don't duplicate
const otherDishIds = state.slots
.filter(s => s.active && s.slot_number !== slotNumber && s.current_dish)
.map(s => s.current_dish.id);
try {
const payload = {
slot_number: slotNumber,
category: slot.category || null,
efforts: slot.efforts.length > 0 ? slot.efforts : null,
current_dish_id: slot.current_dish.id,
other_dish_ids: otherDishIds
};
const res = await fetch("/api/roll-single", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || "Fehler beim Neu-Würfeln.");
if (data.status === "ok" && data.dish) {
slot.current_dish = data.dish;
const effortLabel = data.dish.effort.charAt(0).toUpperCase() + data.dish.effort.slice(1);
// Smoothly update the card content
const nameEl = cardElement.querySelector(`#dish-name-${slotNumber}`);
if (nameEl) nameEl.textContent = data.dish.name;
const tagsEl = cardElement.querySelector(".result-tags");
if (tagsEl) {
tagsEl.innerHTML = `
${renderCategoryBadges(data.dish)}
<span class="badge-tag badge-effort-${data.dish.effort}">
${effortLabel}
</span>
`;
}
showToast(`Gericht ${slotNumber} neu gewürfelt: ${data.dish.name}`, "success");
} else {
showToast("Kein anderes passendes Gericht in dieser Kategorie gefunden.", "error");
}
} catch (err) {
showToast(err.message, "error");
} finally {
setTimeout(() => {
btnElement.classList.remove("spinning");
cardElement.classList.remove("rerolling");
}, 450);
}
}
// --- Admin Section & CRUD ---
function initAdminEvents() {
const searchInput = document.getElementById("admin-search-input");
const clearSearchBtn = document.getElementById("admin-search-clear");
const catFilter = document.getElementById("admin-filter-category");
const effortFilter = document.getElementById("admin-filter-effort");
searchInput.addEventListener("input", (e) => {
state.admin.search = e.target.value.trim();
clearSearchBtn.classList.toggle("hidden", state.admin.search.length === 0);
loadAdminDishes();
});
clearSearchBtn.addEventListener("click", () => {
searchInput.value = "";
state.admin.search = "";
clearSearchBtn.classList.add("hidden");
loadAdminDishes();
});
catFilter.addEventListener("change", (e) => {
state.admin.category = e.target.value;
loadAdminDishes();
});
effortFilter.addEventListener("change", (e) => {
state.admin.effort = e.target.value;
loadAdminDishes();
});
// Modal Open/Close
const dishModal = document.getElementById("dish-modal");
const btnOpenAdd = document.getElementById("btn-open-add-dish");
const btnCloseModal = document.getElementById("btn-close-modal");
const btnCancelModal = document.getElementById("btn-cancel-modal");
const dishForm = document.getElementById("dish-form");
btnOpenAdd.addEventListener("click", () => {
openDishModal();
});
btnCloseModal.addEventListener("click", () => dishModal.close());
btnCancelModal.addEventListener("click", () => dishModal.close());
dishForm.addEventListener("submit", async (e) => {
e.preventDefault();
await saveDishFromModal();
});
// Delete Modal
const deleteModal = document.getElementById("delete-modal");
const btnCancelDelete = document.getElementById("btn-cancel-delete");
const btnConfirmDelete = document.getElementById("btn-confirm-delete");
btnCancelDelete.addEventListener("click", () => deleteModal.close());
btnConfirmDelete.addEventListener("click", async () => {
if (state.admin.deleteTargetId) {
await executeDeleteDish(state.admin.deleteTargetId);
deleteModal.close();
}
});
}
async function loadAdminDishes() {
const listContainer = document.getElementById("admin-dishes-list");
const statsSummary = document.getElementById("admin-stats-summary");
const countLabel = document.getElementById("dishes-count-label");
try {
const params = new URLSearchParams();
if (state.admin.category && state.admin.category !== "alle") {
params.append("category", state.admin.category);
}
if (state.admin.effort && state.admin.effort !== "alle") {
params.append("effort", state.admin.effort);
}
if (state.admin.search) {
params.append("search", state.admin.search);
}
const res = await fetch(`/api/dishes?${params.toString()}`);
const dishes = await res.json();
state.admin.dishes = dishes;
// Load overall stats
const statsRes = await fetch("/api/stats");
const stats = await statsRes.json();
statsSummary.textContent = `${stats.total} Gerichte in der Datenbank`;
countLabel.textContent = `${dishes.length} ${dishes.length === 1 ? "Gericht" : "Gerichte"} angezeigt`;
if (dishes.length === 0) {
listContainer.innerHTML = `
<div class="loading-spinner">Keine Gerichte mit diesen Suchkriterien gefunden.</div>
`;
return;
}
listContainer.innerHTML = "";
dishes.forEach(dish => {
const row = document.createElement("div");
row.className = "admin-dish-row";
const effortLabel = dish.effort.charAt(0).toUpperCase() + dish.effort.slice(1);
row.innerHTML = `
<div class="admin-dish-info">
<div class="admin-dish-name">${dish.name}</div>
<div class="admin-dish-badges">
${renderCategoryBadges(dish)}
<span class="badge-tag badge-effort-${dish.effort}">${effortLabel}</span>
</div>
</div>
<div class="admin-dish-actions">
<button class="btn-action-icon btn-edit" title="Bearbeiten" data-id="${dish.id}">✏️</button>
<button class="btn-action-icon btn-delete" title="Löschen" data-id="${dish.id}">🗑️</button>
</div>
`;
listContainer.appendChild(row);
row.querySelector(".btn-edit").addEventListener("click", () => openDishModal(dish));
row.querySelector(".btn-delete").addEventListener("click", () => confirmDeleteDish(dish));
});
} catch (err) {
listContainer.innerHTML = `<div class="loading-spinner" style="color: var(--danger);">Fehler beim Laden: ${err.message}</div>`;
}
}
function openDishModal(dish = null) {
const modal = document.getElementById("dish-modal");
const modalTitle = document.getElementById("modal-title");
const formId = document.getElementById("dish-form-id");
const nameInput = document.getElementById("dish-name-input");
const catCheckboxes = document.querySelectorAll('input[name="modal-category"]');
if (dish) {
modalTitle.textContent = "Gericht bearbeiten";
formId.value = dish.id;
nameInput.value = dish.name;
const cats = getDishCategories(dish);
catCheckboxes.forEach(cb => {
cb.checked = cats.includes(cb.value);
});
const radio = document.querySelector(`input[name="modal-effort"][value="${dish.effort}"]`);
if (radio) radio.checked = true;
} else {
modalTitle.textContent = "Neues Gericht anlegen";
formId.value = "";
nameInput.value = "";
catCheckboxes.forEach(cb => {
cb.checked = false;
});
const radio = document.querySelector(`input[name="modal-effort"][value="mittel"]`);
if (radio) radio.checked = true;
}
modal.showModal();
}
async function saveDishFromModal() {
const modal = document.getElementById("dish-modal");
const formId = document.getElementById("dish-form-id").value;
const name = document.getElementById("dish-name-input").value.trim();
const selectedCats = Array.from(document.querySelectorAll('input[name="modal-category"]:checked')).map(cb => cb.value);
const effortRadio = document.querySelector('input[name="modal-effort"]:checked');
const effort = effortRadio ? effortRadio.value : "mittel";
if (!name) {
showToast("Bitte gib einen Namen für das Gericht ein.", "error");
return;
}
if (selectedCats.length === 0) {
showToast("Bitte wähle mindestens eine Kategorie aus.", "error");
return;
}
if (!effort) {
showToast("Bitte wähle den Aufwand aus.", "error");
return;
}
const isEdit = !!formId;
const url = isEdit ? `/api/dishes/${formId}` : "/api/dishes";
const method = isEdit ? "PUT" : "POST";
try {
const res = await fetch(url, {
method: method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, categories: selectedCats, effort })
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || "Fehler beim Speichern.");
showToast(isEdit ? "Gericht aktualisiert!" : "Gericht erfolgreich hinzugefügt!", "success");
modal.close();
loadAdminDishes();
} catch (err) {
showToast(err.message, "error");
}
}
function confirmDeleteDish(dish) {
state.admin.deleteTargetId = dish.id;
const deleteModal = document.getElementById("delete-modal");
const textEl = document.getElementById("delete-modal-text");
textEl.innerHTML = `Möchtest du <strong>"${dish.name}"</strong> wirklich löschen?`;
deleteModal.showModal();
}
async function executeDeleteDish(dishId) {
try {
const res = await fetch(`/api/dishes/${dishId}`, { method: "DELETE" });
const data = await res.json();
if (!res.ok) throw new Error(data.detail || "Fehler beim Löschen.");
showToast("Gericht wurde gelöscht.", "success");
loadAdminDishes();
} catch (err) {
showToast(err.message, "error");
}
}
// --- Toast System ---
function showToast(message, type = "info") {
const container = document.getElementById("toast-container");
if (!container) return;
const toast = document.createElement("div");
toast.className = `toast toast-${type}`;
const icon = type === "success" ? "✅" : (type === "error" ? "⚠️" : "ℹ️");
toast.innerHTML = `<span>${icon}</span><span>${message}</span>`;
container.appendChild(toast);
setTimeout(() => {
toast.style.transition = "opacity 0.3s, transform 0.3s";
toast.style.opacity = "0";
toast.style.transform = "translateY(-10px)";
setTimeout(() => toast.remove(), 300);
}, 3500);
}
+2805
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
../style.css
+1
View File
@@ -0,0 +1 @@
../style.css
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

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

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 660 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 291 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 844 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 597 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

+1877
View File
File diff suppressed because it is too large Load Diff
+227
View File
@@ -0,0 +1,227 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<meta name="theme-color" content="#1e222d">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<title>What To Cook? – Essens-Zufallsgenerator</title>
<link rel="manifest" href="/static/manifest.json">
<link rel="apple-touch-icon" href="/static/icon-192.png">
<link rel="stylesheet" href="/static/style.css">
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🍽️</text></svg>">
</head>
<body>
<div class="app-container">
<!-- Top Header -->
<header class="app-header">
<div class="brand">
<span class="brand-icon">🍽️</span>
<div>
<h1 class="brand-title">What To Cook?</h1>
<p class="brand-subtitle">Zufallsgenerator für Essen</p>
</div>
</div>
<!-- Tab Navigation -->
<nav class="nav-tabs" role="tablist">
<button id="tab-btn-generator" class="nav-tab active" data-tab="generator" role="tab" aria-selected="true">
<span class="icon">🎲</span> Generator
</button>
<a href="/cookbook" class="nav-tab" style="text-decoration: none;">
<span class="icon">📖</span> Kochbuch
</a>
<a href="/cookbook/new" class="nav-tab" style="text-decoration: none;">
<span class="icon">➕</span> Rezept
</a>
<button id="tab-btn-admin" class="nav-tab" data-tab="admin" role="tab" aria-selected="false">
<span class="icon">⚙️</span> Gerichte
</button>
</nav>
</header>
<!-- Main Content Area -->
<main class="app-main">
<!-- ========================================== -->
<!-- TAB 1: GENERATOR -->
<!-- ========================================== -->
<section id="tab-generator" class="tab-panel active" role="tabpanel">
<!-- Banner / Hint -->
<div class="info-card">
<div class="info-content">
<span class="info-icon">💡</span>
<div>
<strong>Bis zu 5 Gerichte konfigurieren:</strong>
Wähle Kategorie & Aufwand pro Gericht. Nicht benötigte Gerichte einfach frei lassen.
</div>
</div>
<div class="active-counter-badge" id="active-slots-counter">
<span id="active-slots-count">2</span> von 5 aktiv
</div>
</div>
<!-- 5 Dish Configuration Slots -->
<div class="slots-container" id="slots-container">
<!-- Rendered dynamically by app.js (1 to 5) -->
</div>
<!-- Floating / Sticky Action Bar -->
<div class="action-bar-wrapper">
<button id="btn-roll-all" class="btn btn-primary btn-hero">
<span class="btn-icon">🎲</span>
<span class="btn-text">Zufallsgenerator starten</span>
</button>
</div>
<!-- Results Section (Anchor / Modal or Inline display) -->
<section id="results-section" class="results-section hidden">
<div class="results-header">
<div class="results-title-group">
<h2 class="results-title">Ausgeloste Gerichte</h2>
<span class="results-badge" id="results-count-badge">0 Gerichte</span>
</div>
<button id="btn-reroll-all" class="btn btn-outline btn-sm">
<span class="icon">🔄</span> Alle neu würfeln
</button>
</div>
<div id="results-list" class="results-grid">
<!-- Dynamic Result Cards -->
</div>
<div class="results-footer">
<button id="btn-back-to-config" class="btn btn-secondary">
✏️ Filter anpassen
</button>
</div>
</section>
</section>
<!-- ========================================== -->
<!-- TAB 2: ADMIN / DATABASE -->
<!-- ========================================== -->
<section id="tab-admin" class="tab-panel" role="tabpanel" hidden>
<div class="admin-topbar">
<div>
<h2 class="section-title">Gerichte verwalten</h2>
<p class="section-desc" id="admin-stats-summary">Lade Datenbank...</p>
</div>
<button id="btn-open-add-dish" class="btn btn-primary">
<span>➕</span> Neues Gericht
</button>
</div>
<!-- Admin Filters -->
<div class="admin-filters-card">
<div class="search-input-wrap">
<span class="search-icon">🔍</span>
<input type="text" id="admin-search-input" placeholder="Gericht nach Namen suchen..." autocomplete="off">
<button id="admin-search-clear" class="btn-clear hidden" title="Suche leeren">✕</button>
</div>
<div class="filter-row">
<div class="form-group flex-1">
<label for="admin-filter-category" class="filter-label">Kategorie:</label>
<select id="admin-filter-category" class="custom-select">
<option value="alle">Alle Kategorien</option>
<!-- Filled via JS -->
</select>
</div>
<div class="form-group flex-1">
<label for="admin-filter-effort" class="filter-label">Aufwand:</label>
<select id="admin-filter-effort" class="custom-select">
<option value="alle">Jeder Aufwand</option>
<option value="gering">Gering 🟢</option>
<option value="mittel">Mittel 🟡</option>
<option value="hoch">Hoch 🔴</option>
</select>
</div>
</div>
</div>
<!-- Dishes Table / Cards for Mobile -->
<div class="dishes-list-wrap">
<div class="dishes-list-header">
<span id="dishes-count-label">Gerichte</span>
</div>
<div id="admin-dishes-list" class="admin-dishes-list">
<div class="loading-spinner">Lade Gerichte...</div>
</div>
</div>
</section>
</main>
<!-- Dialog: Add / Edit Dish -->
<dialog id="dish-modal" class="modal">
<div class="modal-box">
<div class="modal-header">
<h3 id="modal-title" class="modal-title">Neues Gericht anlegen</h3>
<button id="btn-close-modal" class="btn-icon-close" aria-label="Schließen">✕</button>
</div>
<form id="dish-form" method="dialog">
<input type="hidden" id="dish-form-id" value="">
<div class="form-group">
<label for="dish-name-input" class="form-label">Name des Gerichts *</label>
<input type="text" id="dish-name-input" class="text-input" placeholder="z. B. Spaghetti Carbonara" required autocomplete="off">
</div>
<div class="form-group">
<label class="form-label">Kategorien * <span class="form-label-hint">(Mehrfachauswahl möglich)</span></label>
<div class="category-checkbox-group" id="modal-category-group">
<!-- Filled via JS as checkbox pills -->
</div>
</div>
<div class="form-group">
<label class="form-label">Arbeitsaufwand *</label>
<div class="effort-radio-group">
<label class="effort-radio-label">
<input type="radio" name="modal-effort" value="gering" required>
<span class="effort-pill effort-gering">Gering 🟢 (≤ 20 Min)</span>
</label>
<label class="effort-radio-label">
<input type="radio" name="modal-effort" value="mittel" checked required>
<span class="effort-pill effort-mittel">Mittel 🟡 (~ 45 Min)</span>
</label>
<label class="effort-radio-label">
<input type="radio" name="modal-effort" value="hoch" required>
<span class="effort-pill effort-hoch">Hoch 🔴 (> 60 Min)</span>
</label>
</div>
</div>
<div class="modal-actions">
<button type="button" id="btn-cancel-modal" class="btn btn-secondary">Abbrechen</button>
<button type="submit" id="btn-save-modal" class="btn btn-primary">Speichern</button>
</div>
</form>
</div>
</dialog>
<!-- Dialog: Delete Confirm -->
<dialog id="delete-modal" class="modal">
<div class="modal-box modal-box-sm">
<h3 class="modal-title">Gericht löschen?</h3>
<p id="delete-modal-text" class="modal-text">Möchtest du dieses Gericht wirklich aus der Datenbank entfernen?</p>
<div class="modal-actions">
<button id="btn-cancel-delete" class="btn btn-secondary">Abbrechen</button>
<button id="btn-confirm-delete" class="btn btn-danger">Unwiderruflich löschen</button>
</div>
</div>
</dialog>
<!-- Toast Notifications -->
<div id="toast-container" class="toast-container" aria-live="polite"></div>
</div>
<script src="/static/app.js"></script>
</body>
</html>
+187
View File
@@ -0,0 +1,187 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>Anmeldung — WTC Kochbuch</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Caveat:wght@600;700&family=Lora:ital,wght@0,400;0,600;1,400&family=Playfair+Display:ital,wght@0,600;0,700;0,800;1,400&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/cookbook.css?v=4.0">
<style>
body.login-body {
background-color: var(--creme, #faf6ee);
background-image:
radial-gradient(circle at 100% 0%, rgba(212, 175, 55, 0.08) 0%, transparent 40%),
radial-gradient(circle at 0% 100%, rgba(30, 61, 47, 0.08) 0%, transparent 40%),
repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(0, 0, 0, 0.015) 2px, rgba(0, 0, 0, 0.015) 4px);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
margin: 0;
font-family: var(--font-body, 'Lora', Georgia, serif);
color: var(--schwarz, #1a1a1a);
}
.login-card {
background: #ffffff;
border: 3px solid var(--tannengruen, #1e3d2f);
border-radius: 16px;
box-shadow: 6px 8px 0px rgba(30, 61, 47, 0.25);
width: 100%;
max-width: 420px;
padding: 36px 30px;
text-align: center;
position: relative;
}
.login-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 64px;
height: 64px;
background: var(--creme, #faf6ee);
border: 2px solid var(--gold, #d4af37);
border-radius: 50%;
font-size: 2rem;
margin-bottom: 12px;
box-shadow: 0 4px 10px rgba(0,0,0,0.06);
}
.login-title {
font-family: var(--font-hand, 'Caveat', cursive);
font-size: 2.2rem;
color: var(--tannengruen, #1e3d2f);
margin: 0 0 4px 0;
line-height: 1.1;
}
.login-subtitle {
font-size: 0.95rem;
color: #666;
margin: 0 0 24px 0;
}
.login-form {
display: flex;
flex-direction: column;
gap: 16px;
text-align: left;
}
.form-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.form-label {
font-weight: 600;
font-size: 0.9rem;
color: var(--tannengruen, #1e3d2f);
}
.form-input {
padding: 10px 14px;
border: 2px solid #ddd;
border-radius: 8px;
font-size: 1rem;
font-family: inherit;
background: #fafafa;
transition: border-color 0.2s, background-color 0.2s;
}
.form-input:focus {
outline: none;
border-color: var(--tannengruen, #1e3d2f);
background: #ffffff;
box-shadow: 0 0 0 3px rgba(30, 61, 47, 0.12);
}
.form-checkbox-row {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.88rem;
color: #555;
margin-top: 2px;
}
.form-checkbox-row input {
accent-color: var(--tannengruen, #1e3d2f);
width: 16px;
height: 16px;
cursor: pointer;
}
.login-btn {
background: var(--tannengruen, #1e3d2f);
color: var(--creme, #faf6ee);
border: 2px solid var(--tannengruen, #1e3d2f);
border-radius: 8px;
font-family: var(--font-hand, 'Caveat', cursive);
font-size: 1.25rem;
font-weight: 700;
padding: 10px 16px;
cursor: pointer;
box-shadow: 3px 4px 0px rgba(0, 0, 0, 0.15);
transition: all 0.18s ease;
margin-top: 8px;
}
.login-btn:hover {
background: #274f3d;
border-color: #274f3d;
transform: translateY(-1px);
box-shadow: 3px 5px 0px rgba(0, 0, 0, 0.22);
}
.login-btn:active {
transform: translateY(0);
box-shadow: 1px 2px 0px rgba(0, 0, 0, 0.15);
}
.login-error {
background: #fdf2f2;
border: 1px solid #f8b4b4;
color: #9b1c1c;
padding: 10px 14px;
border-radius: 8px;
font-size: 0.88rem;
margin-bottom: 16px;
text-align: left;
}
.login-footer {
margin-top: 24px;
font-size: 0.82rem;
color: #888;
}
</style>
</head>
<body class="login-body">
<div class="login-card">
<div class="login-badge">📖</div>
<h1 class="login-title">WTC Kochbuch</h1>
<p class="login-subtitle">Privater Bereich • Bitte anmelden</p>
{% if error %}
<div class="login-error">
⚠️ {{ error }}
</div>
{% endif %}
<form method="POST" action="/login" class="login-form">
<input type="hidden" name="next" value="{{ next_url or '/cookbook' }}">
<div class="form-group">
<label class="form-label" for="username">Benutzername</label>
<input class="form-input" type="text" id="username" name="username" value="{{ username or 'admin' }}" required autofocus autocomplete="username">
</div>
<div class="form-group">
<label class="form-label" for="password">Passwort</label>
<input class="form-input" type="password" id="password" name="password" required autocomplete="current-password" placeholder="Passwort eingeben">
</div>
<div class="form-checkbox-row">
<input type="checkbox" id="remember" name="remember" value="true" checked>
<label for="remember">Angemeldet bleiben (30 Tage)</label>
</div>
<button type="submit" class="login-btn">Anmelden ✨</button>
</form>
<div class="login-footer">
What To Cook? • Privates Rezeptarchiv & Kochbuch
</div>
</div>
</body>
</html>
+230
View File
@@ -0,0 +1,230 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ recipe.title }} – Mein Kochbuch</title>
<link rel="stylesheet" href="/static/cookbook.css?v=4.4">
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🍽️</text></svg>">
</head>
<body class="cookbook-body">
<!-- Navigation Bar -->
<header class="cb-nav">
<a href="/cookbook" class="cb-nav-brand">
<span class="cb-brand-icon">📖</span>
<h1 class="cb-nav-title">Kochbuch</h1>
</a>
<!-- Desktop Navigation -->
<div class="cb-nav-actions desktop-nav-actions">
<a href="/cookbook" class="cb-btn cb-btn-outline">
<span>📖</span> <span>Alle Rezepte</span>
</a>
<a href="/cookbook/edit/{{ recipe.id }}" class="cb-btn cb-btn-green">
<span>✏️</span> <span>Bearbeiten</span>
</a>
<button onclick="window.print()" class="cb-btn cb-btn-outline" title="Rezeptseite drucken">
<span>🖨️</span> <span>Drucken</span>
</button>
<a href="/logout" class="cb-nav-btn cb-nav-btn-logout" title="Abmelden">
<span>🚪</span> <span>Abmelden</span>
</a>
</div>
<!-- Mobile Kebab (3-Punkte) Menü -->
<div class="mobile-nav-wrapper">
<button type="button" class="cb-kebab-btn" onclick="toggleMobileMenu(event)" aria-label="Menü öffnen" title="Menü">
<span>⋮</span>
</button>
<div id="cb-mobile-menu" class="cb-mobile-dropdown">
<a href="/cookbook" class="cb-mobile-item">
<span>📖</span> <span>Zum Kochbuch</span>
</a>
<a href="/cookbook/edit/{{ recipe.id }}" class="cb-mobile-item">
<span>✏️</span> <span>Rezept bearbeiten</span>
</a>
<button type="button" onclick="window.print()" class="cb-mobile-item">
<span>🖨️</span> <span>Drucken & PDF</span>
</button>
<hr class="cb-mobile-divider">
<a href="/logout" class="cb-mobile-item cb-mobile-logout">
<span>🚪</span> <span>Abmelden</span>
</a>
</div>
</div>
</header>
<main class="book-wrapper">
<!-- EINHEITLICHE REZEPTSEITE -->
<article class="page recipe-page">
<!-- Header (Ohne Kapitelangabe & ohne Quelle im Titel) -->
<header class="recipe-header">
<h2 class="recipe-title">{{ recipe.title }}</h2>
<div class="recipe-meta-row">
{% if recipe.effort %}
<div class="recipe-meta-item">
<span>⏱️</span>
<span>Aufwand:
{% if recipe.effort == 'gering' %}Gering 🟢{% elif recipe.effort == 'hoch' %}Hoch 🔴{% else %}Mittel 🟡{% endif %}
</span>
</div>
{% endif %}
{% if recipe.prep_time %}
<div class="recipe-meta-item">
<span>🔪</span>
<span>Zubereitung: {{ recipe.prep_time }}</span>
</div>
{% endif %}
{% if recipe.cook_time %}
<div class="recipe-meta-item">
<span>🍳</span>
<span>Kochen/Backen: {{ recipe.cook_time }}</span>
</div>
{% endif %}
{% if recipe.portions %}
<div class="recipe-meta-item">
<span>👥</span>
<span>{{ recipe.portions }} Portionen</span>
</div>
{% endif %}
{% if recipe.source and recipe.source.strip() %}
<div class="recipe-meta-item">
<span>📖</span>
<span>Quelle: {{ recipe.source.strip() }}</span>
</div>
{% endif %}
</div>
</header>
<!-- 2-Spalten Layout -->
<div class="recipe-grid">
<!-- Linke Spalte: Foto + Zutaten -->
<aside class="recipe-side">
<div class="recipe-photo-box">
{% if recipe.image_url %}
<img src="{{ recipe.image_url }}" alt="{{ recipe.title }}">
{% else %}
<div class="recipe-photo-empty">
<span>📸</span>
<span>Kein Foto hinterlegt</span>
</div>
{% endif %}
</div>
<!-- Zutatenleiste ohne Portionsangabe (da schon in Kopfzeile) -->
<div class="ingredients-box {% if recipe.has_portion_table %}has-portion-matrix{% endif %}">
<h4 class="section-hand-title">Zutaten</h4>
{% if recipe.has_portion_table and recipe.portion_matrix %}
<!-- Tabelle für 2/4/6/8 Portionen (z. B. Waffeln / Pancakes) -->
<table class="portion-matrix-table">
<thead>
<tr>
<th class="matrix-th-ing">Zutat</th>
<th class="matrix-th-p">2 P.</th>
<th class="matrix-th-p">4 P.</th>
<th class="matrix-th-p">6 P.</th>
<th class="matrix-th-p">8 P.</th>
</tr>
</thead>
<tbody>
{% for row in recipe.portion_matrix %}
{% if row.is_header %}
<tr class="matrix-header-row">
<th colspan="5">{{ row.text }}</th>
</tr>
{% else %}
<tr class="matrix-row">
<td class="matrix-td-name">{{ row.name }}</td>
<td class="matrix-td-amt">{{ row.amounts[2] }}</td>
<td class="matrix-td-amt highlight">{{ row.amounts[4] }}</td>
<td class="matrix-td-amt">{{ row.amounts[6] }}</td>
<td class="matrix-td-amt">{{ row.amounts[8] }}</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
{% else %}
<!-- Normale kompakte Zutatenliste (kein Abschneiden im Druck) -->
<ul class="ingredients-list">
{% for line in recipe.ingredients.split('\n') %}
{% if line.strip() %}
{% set raw_line = line.strip() %}
{% if raw_line.endswith(':') or raw_line.lower().startswith('für ') %}
<li class="ing-subheading">{{ raw_line }}</li>
{% else %}
<li>{{ raw_line }}</li>
{% endif %}
{% endif %}
{% endfor %}
</ul>
{% endif %}
</div>
</aside>
<!-- Rechte Spalte: Zubereitung + Notizen + Quelle -->
<section class="recipe-main">
<div>
<h4 class="section-hand-title">Zubereitung</h4>
<div class="instructions-steps">
{% for step in recipe.instructions.split('\n') %}
{% if step.strip() %}
<div class="step-card">
<span class="step-badge">{{ loop.index }}</span>
<div class="step-text">{{ step.strip() }}</div>
</div>
{% endif %}
{% endfor %}
</div>
</div>
{% if recipe.notes and recipe.notes.strip() %}
<div class="notes-box">
<div class="notes-box-title">💡 Tipp & Notiz</div>
<div>{{ recipe.notes.strip() }}</div>
</div>
{% else %}
<div class="notes-box notes-box-empty print-only">
<div class="notes-box-title">💡 Eigene Notizen & Tipps</div>
<div class="notes-blank-lines">
<div class="notes-blank-line"></div>
<div class="notes-blank-line"></div>
</div>
</div>
</div>
{% endif %}
</section>
</div>
</article>
</main>
<script>
function toggleMobileMenu(e) {
if (e) e.stopPropagation();
const menu = document.getElementById('cb-mobile-menu');
if (menu) menu.classList.toggle('is-open');
}
document.addEventListener('click', (e) => {
if (!e.target.closest('.mobile-nav-wrapper')) {
const menu = document.getElementById('cb-mobile-menu');
if (menu) menu.classList.remove('is-open');
}
});
</script>
</body>
</html>
+357
View File
@@ -0,0 +1,357 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% if recipe %}Rezept bearbeiten{% else %}Neues Rezept eintragen{% endif %} – Mein Kochbuch</title>
<link rel="stylesheet" href="/static/cookbook.css?v=4.4">
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>📝</text></svg>">
</head>
<body class="cookbook-body">
<!-- Header -->
<header class="cb-nav">
<a href="/cookbook" class="cb-nav-brand">
<span class="cb-brand-icon">📖</span>
<h1 class="cb-nav-title">Kochbuch</h1>
</a>
<!-- Desktop Actions -->
<div class="cb-nav-actions desktop-nav-actions">
<a href="/cookbook" class="cb-btn cb-btn-creme" style="background: var(--tannengruen); color: var(--creme);">
<span>📖</span> <span>Zum Kochbuch</span>
</a>
<a href="/logout" class="cb-nav-btn cb-nav-btn-logout" title="Abmelden">
<span>🚪</span> <span>Abmelden</span>
</a>
</div>
<!-- Mobile 3-Dots Kebab Menu -->
<div class="mobile-nav-wrapper">
<button type="button" class="cb-kebab-btn" id="mobileMenuBtn" aria-label="Menü öffnen" onclick="toggleMobileMenu()">
⋮
</button>
<div class="cb-mobile-dropdown" id="mobileDropdown">
<a href="/cookbook" class="cb-mobile-item">
<span>📖</span> Zum Kochbuch
</a>
<div class="cb-mobile-divider"></div>
<a href="/logout" class="cb-mobile-item" style="color: #c53030;">
<span>🚪</span> Abmelden
</a>
</div>
</div>
</header>
<main class="book-wrapper" style="max-width: 780px;">
<div class="form-card">
<h2 class="form-title">
{% if recipe %}✏️ Rezept bearbeiten{% else %}➕ Neues Rezept eintragen{% endif %}
</h2>
<p class="form-subtitle">
Trage dein Lieblingsrezept ein – mit Foto, Zutaten und Zubereitungsschritten.
</p>
<form id="recipe-form" enctype="multipart/form-data">
{% if recipe %}
<input type="hidden" id="recipe-id" value="{{ recipe.id }}">
{% endif %}
<!-- Titel -->
<div class="cb-form-group">
<label for="title" class="cb-label">Name des Rezepts *</label>
<input type="text" id="title" name="title" class="cb-input"
value="{{ recipe.title if recipe else '' }}"
placeholder="z. B. Klassische Spaghetti Carbonara" required autocomplete="off">
</div>
<!-- Kategorie / Kapitel -->
<div class="cb-form-group">
<label for="chapter_number" class="cb-label">Kategorie / Kapitel *</label>
<select id="chapter_number" name="chapter_number" class="cb-select" required>
<option value="" disabled {% if not recipe and not selected_chapter %}selected{% endif %}>Kapitel wählen...</option>
{% for chap in chapters %}
<option value="{{ chap.number }}"
{% if (recipe and recipe.chapter_number == chap.number) or (not recipe and selected_chapter == chap.number) %}selected{% endif %}>
Kapitel {{ chap.number }}: {{ chap.title }}
</option>
{% endfor %}
</select>
</div>
<!-- Metadaten: Aufwand, Zeiten, Portionen -->
<div class="cb-row">
<div class="cb-col">
<div class="cb-form-group">
<label for="effort" class="cb-label">Arbeitsaufwand *</label>
<select id="effort" name="effort" class="cb-select" required>
<option value="gering" {% if recipe and recipe.effort == 'gering' %}selected{% endif %}>Gering 🟢 (≤ 20 Min)</option>
<option value="mittel" {% if not recipe or recipe.effort == 'mittel' %}selected{% endif %}>Mittel 🟡 (~ 45 Min)</option>
<option value="hoch" {% if recipe and recipe.effort == 'hoch' %}selected{% endif %}>Hoch 🔴 (> 60 Min)</option>
</select>
</div>
</div>
<div class="cb-col">
<div class="cb-form-group">
<label for="portions" class="cb-label">Portionen</label>
<input type="number" id="portions" name="portions" class="cb-input"
value="{{ recipe.portions if recipe else 4 }}" min="1" max="50">
</div>
</div>
</div>
<div class="cb-row">
<div class="cb-col">
<div class="cb-form-group">
<label for="prep_time" class="cb-label">Zubereitungszeit</label>
<input type="text" id="prep_time" name="prep_time" class="cb-input"
value="{{ recipe.prep_time if recipe else '' }}" placeholder="z. B. 15 Min">
</div>
</div>
<div class="cb-col">
<div class="cb-form-group">
<label for="cook_time" class="cb-label">Koch- / Backzeit</label>
<input type="text" id="cook_time" name="cook_time" class="cb-input"
value="{{ recipe.cook_time if recipe else '' }}" placeholder="z. B. 30 Min">
</div>
</div>
</div>
<!-- Checkbox: Multi-Portionen-Tabelle -->
<div class="cb-form-group" style="margin-top: 4px; margin-bottom: 20px; background: rgba(44, 74, 52, 0.05); padding: 10px 14px; border-radius: 8px; border: 1px dashed var(--tannengruen);">
<label style="display: flex; align-items: center; gap: 10px; cursor: pointer; font-size: 0.95rem; font-weight: 600; color: var(--dunkelgrau); margin-bottom: 0;">
<input type="checkbox" id="has_portion_table" name="has_portion_table" value="1"
{% if recipe and recipe.has_portion_table %}checked{% endif %}
style="width: 18px; height: 18px; accent-color: var(--tannengruen);">
<span>🔢 Zutatentabelle für 2 / 4 / 6 / 8 Portionen berechnen (z. B. für Waffeln, Pfannkuchen, Teige)</span>
</label>
</div>
<!-- Foto-Upload -->
<div class="cb-form-group">
<label class="cb-label">Foto des Gerichts 📸</label>
<div class="upload-dropzone" id="dropzone" onclick="document.getElementById('image-upload').click();">
<span style="font-size: 2.2rem;">📷</span>
<p style="font-family: var(--font-hand); font-size: 1.15rem; margin: 8px 0 4px 0; color: var(--tannengruen);">
Hier klicken, um ein Foto auszuwählen oder mit dem Smartphone aufzunehmen
</p>
<span style="font-size: 0.85rem; color: #777;">(JPG, PNG, WebP)</span>
<input type="file" id="image-upload" name="image_file" accept="image/*" style="display: none;">
</div>
<div class="preview-container" id="preview-box" {% if recipe and recipe.image_url %}style="display: block;"{% endif %}>
<img id="image-preview" src="{{ recipe.image_url if recipe and recipe.image_url else '' }}" alt="Vorschau">
</div>
</div>
<!-- Zutaten -->
<div class="cb-form-group">
<label for="ingredients" class="cb-label">Zutaten * (eine Zutat pro Zeile)</label>
<textarea id="ingredients" name="ingredients" class="cb-textarea" rows="8"
placeholder="500g Pasta&#10;150g Schinken oder Speck&#10;4 Eigelb&#10;50g Parmesan gerieben&#10;Salz und frisch gemahlener Pfeffer" required>{{ recipe.ingredients if recipe else '' }}</textarea>
</div>
<!-- Zubereitungsschritte -->
<div class="cb-form-group">
<label for="instructions" class="cb-label">Zubereitung * (Absätze werden nummeriert)</label>
<textarea id="instructions" name="instructions" class="cb-textarea" rows="10"
placeholder="Reichlich Salzwasser in einem großen Topf zum Kochen bringen.&#10;Den Schinken in Streifen schneiden und knusprig anbraten.&#10;Die Eigelbe mit dem geriebenen Käse und Pfeffer cremig rühren.&#10;Pasta al dente kochen und mit etwas Nudelwasser vermengen..." required>{{ recipe.instructions if recipe else '' }}</textarea>
</div>
<!-- Notizen & Tipps -->
<div class="cb-form-group">
<label for="notes" class="cb-label">💡 Tipps, Notizen & Kniffe (optional)</label>
<textarea id="notes" name="notes" class="cb-textarea" rows="3"
placeholder="Wichtig: Den Herd vor dem Zugeben der Eier-Käse-Mischung ausschalten, damit das Ei nicht stockt!">{{ recipe.notes if recipe else '' }}</textarea>
</div>
<!-- Credits / Copyright / Quelle -->
<div class="cb-form-group">
<label for="source" class="cb-label">© Quelle / Credits / Copyright (optional)</label>
<input type="text" id="source" name="source" class="cb-input"
value="{{ recipe.source if recipe and recipe.source else '' }}"
placeholder="z. B. Von Oma Anna, Chefkoch.de, Jamie Oliver, Familienrezept">
</div>
<!-- Checkbox: Auch als Zufallsgenerator-Gericht anlegen -->
{% if not recipe %}
<div class="cb-form-group" style="margin-top: 10px;">
<label style="display: flex; align-items: center; gap: 10px; cursor: pointer; font-family: var(--font-hand); font-size: 1.1rem;">
<input type="checkbox" id="add_to_dishes" name="add_to_dishes" value="true" checked style="width: 20px; height: 20px; accent-color: var(--tannengruen);">
<span>Auch als Gericht für den Zufallsgenerator hinzufügen</span>
</label>
</div>
{% endif %}
<!-- Error Feedback Banner -->
<div id="form-error-banner" style="display: none; background: #fff0f0; border: 2px solid #e53e3e; color: #9b1c1c; padding: 12px 16px; border-radius: 8px; margin-top: 15px; font-weight: 600;"></div>
<!-- Submit Button -->
<div style="display: flex; justify-content: flex-end; gap: 12px; margin-top: 24px;">
<a href="/cookbook" class="cb-btn cb-btn-outline" style="color: var(--schwarz); border-color: rgba(34,34,34,0.3);">
Abbrechen
</a>
<button type="submit" id="btn-submit" class="cb-btn cb-btn-creme" style="background: var(--tannengruen); color: var(--creme); font-size: 1.15rem; padding: 12px 24px;">
<span>💾</span> <span>Rezept speichern</span>
</button>
</div>
</form>
</div>
</main>
<script>
// Live Image Preview & Compression Helper
const fileInput = document.getElementById('image-upload');
const previewBox = document.getElementById('preview-box');
const previewImg = document.getElementById('image-preview');
const errorBanner = document.getElementById('form-error-banner');
let preparedImageFile = null;
fileInput.addEventListener('change', async function() {
const file = this.files[0];
if (!file) return;
// Vorschau anzeigen
const reader = new FileReader();
reader.onload = function(e) {
previewImg.src = e.target.result;
previewBox.style.display = 'block';
};
reader.readAsDataURL(file);
// Foto für schnellen Upload komprimieren (max 1600px Breite/Höhe, JPEG 85%)
try {
preparedImageFile = await compressImageFile(file, 1600, 1600, 0.85);
} catch (err) {
console.warn('Komprimierung fehlgeschlagen, Originaldatei wird verwendet:', err);
preparedImageFile = file;
}
});
function compressImageFile(file, maxWidth, maxHeight, quality) {
return new Promise((resolve) => {
// Bei SVG oder WebP/GIF direkt original belassen
if (file.type === 'image/svg+xml' || file.type === 'image/gif') {
return resolve(file);
}
const img = new Image();
const url = URL.createObjectURL(file);
img.onload = () => {
URL.revokeObjectURL(url);
let w = img.width;
let h = img.height;
if (w > maxWidth || h > maxHeight) {
if (w > h) {
h = Math.round((h * maxWidth) / w);
w = maxWidth;
} else {
w = Math.round((w * maxHeight) / h);
h = maxHeight;
}
}
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, w, h);
canvas.toBlob((blob) => {
if (!blob) return resolve(file);
const safeName = file.name.replace(/\.[^/.]+$/, "") + ".jpg";
resolve(new File([blob], safeName, { type: "image/jpeg" }));
}, 'image/jpeg', quality);
};
img.onerror = () => {
URL.revokeObjectURL(url);
resolve(file);
};
img.src = url;
});
}
// Form Submit (AJAX multipart)
const form = document.getElementById('recipe-form');
const btnSubmit = document.getElementById('btn-submit');
const recipeIdEl = document.getElementById('recipe-id');
form.addEventListener('submit', async function(e) {
e.preventDefault();
errorBanner.style.display = 'none';
errorBanner.textContent = '';
btnSubmit.disabled = true;
btnSubmit.innerHTML = '<span>⏳</span> Speichern...';
const formData = new FormData(form);
// Wenn ein Bild komprimiert vorliegt, dieses im FormData setzen
if (preparedImageFile) {
formData.set('image_file', preparedImageFile);
} else if (fileInput.files.length === 0) {
formData.delete('image_file');
}
// Checkbox Wert explizit absichern
const addDishCb = document.getElementById('add_to_dishes');
if (addDishCb) {
formData.set('add_to_dishes', addDishCb.checked ? 'true' : 'false');
}
const hasPortionTableCb = document.getElementById('has_portion_table');
if (hasPortionTableCb) {
formData.set('has_portion_table', hasPortionTableCb.checked ? '1' : '0');
}
const isEdit = !!recipeIdEl;
const url = isEdit ? `/api/recipes/${recipeIdEl.value}` : '/api/recipes';
const method = isEdit ? 'PUT' : 'POST';
try {
const res = await fetch(url, {
method: method,
body: formData
});
let data;
const textResponse = await res.text();
try {
data = JSON.parse(textResponse);
} catch (e) {
throw new Error(`Serverfehler (${res.status}): ${textResponse.slice(0, 150)}`);
}
if (!res.ok) {
const detailMsg = (typeof data.detail === 'string')
? data.detail
: (Array.isArray(data.detail) ? data.detail.map(d => d.msg || d).join(', ') : 'Fehler beim Speichern');
throw new Error(detailMsg);
}
const recipeId = data.recipe ? data.recipe.id : (recipeIdEl ? recipeIdEl.value : '');
window.location.href = `/cookbook#rezept-${recipeId}`;
} catch (err) {
console.error('Fehler beim Speichern:', err);
errorBanner.textContent = `❌ ${err.message}`;
errorBanner.style.display = 'block';
btnSubmit.disabled = false;
btnSubmit.innerHTML = '<span>💾</span> Rezept speichern';
}
});
// Mobile Kebab Menu Functions
function toggleMobileMenu() {
const dd = document.getElementById('mobileDropdown');
if (dd) dd.classList.toggle('show');
}
document.addEventListener('click', function(e) {
const wrap = document.querySelector('.mobile-nav-wrapper');
if (wrap && !wrap.contains(e.target)) {
const dd = document.getElementById('mobileDropdown');
if (dd) dd.classList.remove('show');
}
});
</script>
</body>
</html>
Executable
+24
View File
@@ -0,0 +1,24 @@
import sqlite3
import os
db_path = "/home/maxi/Projekt/wtc.waiswuaschd.de/data/wtc.db"
conn = sqlite3.connect(db_path)
c = conn.cursor()
tables = c.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()
print("Tables:", tables)
for t in tables:
tname = t[0]
cnt = c.execute(f"SELECT COUNT(*) FROM {tname}").fetchone()[0]
print(f"Table {tname}: {cnt} rows")
rows = c.execute("SELECT id, name, category, effort FROM dishes").fetchall()
print(f"Dishes ({len(rows)}):")
for r in rows[:5]:
print(" ", r)
r_rows = c.execute("SELECT * FROM recipes").fetchall()
print(f"Recipes ({len(r_rows)}):")
for r in r_rows:
print(" ", r)
+97
View File
@@ -0,0 +1,97 @@
import os
from PIL import Image
src_path = "/home/maxi/Projekt/wtc.waiswuaschd.de/app/static/img/chapters/source_chapters.jpg"
out_dir = "/home/maxi/Projekt/wtc.waiswuaschd.de/app/static/img/chapters"
im = Image.open(src_path).convert("RGBA")
w, h = im.size
def make_transparent(sub_img):
sub_img = sub_img.convert("RGBA")
data = sub_img.getdata()
new_data = []
for item in data:
r, g, b, a = item
lum = 0.299 * r + 0.587 * g + 0.114 * b
if lum >= 248:
new_data.append((r, g, b, 0))
elif lum <= 200:
new_data.append((r, g, b, 255))
else:
alpha = int(255 * (248 - lum) / (248 - 200))
new_data.append((r, g, b, alpha))
res = Image.new("RGBA", sub_img.size)
res.putdata(new_data)
bbox = res.getbbox()
if bbox:
px1 = max(0, bbox[0] - 8)
py1 = max(0, bbox[1] - 8)
px2 = min(res.width, bbox[2] + 8)
py2 = min(res.height, bbox[3] + 8)
res = res.crop((px1, py1, px2, py2))
return res
chapters_data = []
# 2. Traditionell & Basics
sub2 = im.crop((25, 80, 308, 262))
for y in range(sub2.height):
for x in range(sub2.width):
actual_x = 25 + x
actual_y = 80 + y
if actual_x > 268 and actual_y > 165:
sub2.putpixel((x, y), (255, 255, 255, 255))
chapters_data.append((2, "traditionell_basics", sub2))
# 3. Pasta & Nudelgerichte
sub3 = im.crop((270, 20, 518, 270))
for y in range(sub3.height):
for x in range(sub3.width):
actual_x = 270 + x
actual_y = 20 + y
if actual_x < 308 and actual_y < 165:
sub3.putpixel((x, y), (255, 255, 255, 255))
chapters_data.append((3, "pasta_nudelgerichte", sub3))
# 4. Kartoffeln
sub4 = im.crop((515, 20, 745, 270))
chapters_data.append((4, "kartoffeln", sub4))
# 5. Reis & Getreide
sub5 = im.crop((40, 315, 270, 505))
chapters_data.append((5, "reis_getreide", sub5))
# 6. Teig & Gebackenes
sub6 = im.crop((270, 330, 520, 536))
chapters_data.append((6, "teig_gebackenes", sub6))
# 7. Asiatische Küche: bowl and chopsticks within [45, 580, 292, 725]
sub7 = im.crop((45, 580, 292, 725))
chapters_data.append((7, "asiatische_kueche", sub7))
# 8. Süßspeisen: starts at y=568 (below label 6)
sub8 = im.crop((460, 568, 750, 725))
chapters_data.append((8, "suessspeisen", sub8))
# 9. Desserts
sub9 = im.crop((30, 765, 258, 938))
chapters_data.append((9, "desserts", sub9))
# 10. Kuchen & Gebäck
sub10 = im.crop((265, 745, 510, 938))
chapters_data.append((10, "kuchen_gebaeck", sub10))
# 11. Plätzchen: starts at y=774 (below label 8 descender!)
sub11 = im.crop((515, 774, 745, 938))
chapters_data.append((11, "plaetzchen", sub11))
for num, name, img in chapters_data:
final_img = make_transparent(img)
filename = f"chapter_{num}.png"
filepath = os.path.join(out_dir, filename)
final_img.save(filepath, "PNG")
print(f"Saved Chapter {num:2d} ({name}) -> {filename} ({final_img.width}x{final_img.height})")
print("All 10 chapters extracted with 100% purity!")
@@ -0,0 +1,33 @@
--- Page 1 ---
Rezept online aufrufen
Zutaten für 4 Portionen:
150 gMehl
1 ELZucker
1 TLBackpulver
2Ei(er)
1 Prise(n)Salz
150 mlMilch
3 ELÖl
n. B.Ahornsirup
200 gBeeren, (TK)
Rezept von: hannah_kocht_einfach
American Pancakes
Ein amerikanischer Frühstückstraum
Mehl, Zucker, Backpulver, Eier, Salz und Milch in einer Schüssel
vermengen.
Jetzt ein wenig Öl in einer Pfanne erhitzen. Dann den
Pfannkuchenteig portionsweise (etwa 2 - 3 Esslö�el reichen)
hineingeben und zu einem großen, drei Zentimeter dicken Kreis
formen.
Bei mittlerer Hitzestufe wird der Teig nun so lange gebraten, bis die
untere Seite golden und braun ist.
Anschließend ein Mal wenden, bis er goldbraun gebraten ist.
Ahornsirup darüber gießen und mit Tiefkühlbeeren garnieren.
Warm genießen.
Arbeitszeitca. 10 Minuten
Koch-/Backzeitca. 15 Minuten
Gesamtzeitca. 25 Minuten
Schwierigkeitsgradsimpel
Chefkoch Rezept: American Pancakes von hannah_kocht_einfach about:blank
1 von 1 30.05.2024, 16:00
+269
View File
@@ -0,0 +1,269 @@
--- Page 1 ---
Einfach Backen / Brigitte Sporrer
ZUTATEN
(FÜR 1 KUCHEN)
1
Dose
Ananas
in Scheiben
(Abtropfgewicht ca. 350 g)
220
g
weiche
Butter
30
g
brauner
Zucker
130
g
Zucker
1
Prise
Salz
3
Eier
(Gr. M)
200
g
Weizenmehl
(Type 405, alternativ
Dinkelmehl Type 630)
2
TL
Backpulver
nach Belieben
Cocktailkirsche
oder
Maraschinokirschen zum
Dekorieren
Backen
:
40 Min.
Dauer
:
25 Min.
Niveau
:
Einfach
Zubereitung
1
.
SCHRITT
1
1
Dose
Dose
Ananas
Ananas
in Scheiben (Abtropfgewicht ca. 350 g)
in Scheiben (Abtropfgewicht ca. 350 g)
Den Backofen auf
180 Grad Ober-/Unterhitze (Umluft: 160
180 Grad Ober-/Unterhitze (Umluft: 160
Grad)
Grad)
vorheizen.
Die
Ananasscheiben auf einem Sieb
abtropfen lassen.
Ein Stück Backpapier in die
Springform (Ø
Springform (Ø
26 cm)
26 cm)
einspannen.
2
.
SCHRITT
40
40
g
g
weiche
weiche
Butter
Butter
,
,
30
30
g
g
brauner
brauner
Zucker
Zucker
40 g der weichen Butter mit einem Pinsel auf dem Boden der
Form verteilen. Den braunen Zucker gleichmäßig darauf
streuen.
3
.
SCHRITT
Acht der Ananasscheiben dicht an dicht auf den
Springformboden legen. Die übrigen Ananasscheiben (ca. 3-4
Stück) sehr fein hacken oder pürieren.
4
.
SCHRITT
180
180
g
g
weiche
weiche
Butter
Butter
,
,
130
130
g
g
Zucker
Zucker
,
,
1
1
Prise
Prise
Salz
Salz
,
,
3
3
Eier
Eier
(Gr. M)
(Gr. M)
,
,
200
200
g
g
Weizenmehl
Weizenmehl
(Type 405, alternativ Dinkelmehl Type 630)
(Type 405, alternativ Dinkelmehl Type 630)
,
,
2
2
TL
TL
Backpulver
Backpulver
Für den Teig
Für den Teig
die übrige weiche Butter mit Zucker und Salz in
einer Rührschüssel schaumig schlagen. Eier nach und nach
zugeben und auf höchster Stufe weiterrühren. Das Mehl mit
Backpulver mischen und abwechselnd mit dem Ananaspüree
unter den Teig rühren.
5
.
SCHRITT
super saftig
Ananaskuchen
Noch mehr leckere Rezepte: https://www.einfachbacken.de
--- Page 2 ---
Nach Belieben die Löcher der Ananasscheiben mit je einer
Kirsche ausfüllen. Teig darauf geben und glatt streichen. Im
unteren Drittel des vorgeheizten Backofens
ca. 40 Minuten
ca. 40 Minuten
goldgelb backen. Am besten ein Stück Backpapier auf den
Kuchenrost legen, damit der eventuell heraustropfende Saft
bzw. Karamell nicht am Ofenboden einbrennt.
6
.
SCHRITT
Den fertigen Kuchen
ca. 5 Minuten
ca. 5 Minuten
auf einem Kuchenrost
auskühlen lassen. Dann direkt auf die gewünschte Tortenplatte
stürzen und das mitgebackene Backpapier entfernen. Der
Kuchen ergibt ca. 12 Stücke.
Noch mehr leckere Rezepte: https://www.einfachbacken.de
@@ -0,0 +1,27 @@
Apfel-Salzkaramell-Tarte mit Erdnüssen (krimi&Keks)
Für eine rechteckige Tarteform
Zutaten
Zuerst den Backofen auf 170 Grad Umluft vorheizen und die Tarteform einfetten.
Für den Mürbeteig alle Zutaten mit den Knethaken des Mixers oder der Küchenmaschine (oder den Händen) zu einem glatten Teig verkneten. Den Teig auf dem Boden der Tarteform ausrollen und am Rand hochziehen. Jetzt ein Blatt Backpapier auf den Teig legen und die Hülsenfrüchte auf dem Papier verteilen. Das simuliert eine Füllung beim Vorbacken und verhindert, dass der Teig am Boden Blasen wirft und am Rand herunterfließen kann. Den Teig für 12 Minuten auf der mittleren Schiene backen.
Inzwischen das Salzkaramell kochen. Dazu Zucker, Butter und Sahne in einem kleinen Topf aufkochen und so lange einköcheln lassen, bis sich eine hellbraune, dickflüssige Soße gebildet hat. Das dauert ungefähr 15 Minuten. Zwischendurch unbedingt umrühren, damit das Karamell nicht anbrennt. Ist die gewünschte Konsistenz erreicht, Meersalz nach Lust und Laune dazugeben und kurz verrühren.
Jetzt sollte auch der Mürbeteig vorgebacken sein und aus dem Ofen kommen. Das Backpapier mit den Hülsenfrüchten wieder entfernen. Achtung: Die Hülsenfrüchte (ich nehme getrocknete weiße Bohnen) werden extrem heiß, also verbrennt Euch nicht die Finger!
Die Äpfel vierteln, entkernen und in dünne Scheiben schneiden.
Die Salzkaramell-Soße auf den Mürbeteig streichen, sodass der Teigboden gut bedeckt ist. Auf das Karamell einige Erdnüsse streuen und dann die Apfelscheiben wie Dachziegel auf dem Teig verteilen. Wer mag, kann noch ein wenig Zimt über die Apfelspalten streuen.
Die Apfeltarte backt für 20 Minuten auf der mittleren Schiene im Ofen. Dann herausziehen und mit einem Backpinsel noch etwas von dem Salzkaramell auf die Apfelspalten pinseln. Noch ein paar Erdnüsse über die Äpfel streuen und die Tarte für weitere 10 Minuten in den Ofen geben. Nach Ende der Backzeit die Apfeltarte aus dem Backofen nehmen und auf einem Gitter abkühlen lassen. Es bleibt ein wenig von der Salzkaramell-Soße übrig – ungefäh so viel, wie in dem kleinen Weckglas auf den Fotos zu sehen ist. Die restliche Soße könnt Ihr entweder zur Apfeltarte servieren oder anderweitig naschen. Herr krimiundkeks hat einen Löffel davon in Naturjoghurt gerührt und war sehr begeistert…
Die Apfeltarte lauwarm oder komplett abgekühlt servieren.
--- Table ---
Mürbeteig:
200g Mehl
100g kalte Butter in kleinen Würfeln
50g Zucker
1 EL kaltes Wasser
Zimt
Salzkaramell:
125g Zucker
200ml Sahne
2 EL Butter
Meersalz nach Geschmack
Belag:
2 mittelgroße, säuerliche Äpfel
etwa 2 EL geröstete und gesalzene Erdnüsse
@@ -0,0 +1,46 @@
--- Slide 1 ---
Bao Buns mit Champignons
Mit Karotten, Gurken und Edamame
Zutaten 2 Personen
Fertige Bao Buns (TK)
Edamame in Schale
Gurke
Röstzwiebeln
Mayonnaise
Tonkatsu Soße
Für die Karotten:
2 Karotten
50ml Wasser
50ml Branntweinessig
½ TL Salz
1 TL Zucker
Für die Champignons:
400g Champignons
4 EL Sojasauce
1 EL Reisessig
1 EL Agavendicksaft
1 Knoblauchzehe
Chilipulver
Karotten schälen und in feine Stifte hobeln. Zutaten der Marinade (50ml Wasser, 50ml Essig, ½ TL Salz, 1 TL Zucker) in einen Topf geben und aufkochen. Karotten dazugeben und für 30 Sekunden köcheln lassen. Herd ausschalten und Karotten weiter marinieren lassen.
Champignons in Scheiben schneiden.
Zutaten der Marinade (4EL Sojasauce, 1 EL Reisessig, 1 EL Agavendicksaft, 1 Knoblauchzehe, Chilipulver) vermischen und über die Champignons geben.
Gurken in feine Stifte hobeln.
2 Töpfe mit Wasser für Edamame und Bao Buns aufsetzen.
Eadmame in das kochende Wasser geben.
Bao Buns mit dem Dampfeinsatz in den Topf geben, mit Deckel dämpfen lassen.
Champignons mit Marinade heiß braten.
 Dauer: gute 5 Minuten
Bao Buns herausnehmen und mit
Champignons
Gurke
Karotte
Röstzwiebeln
Mayonaise
Tonkatsu Soße füllen
Edamame abgießen und salzen.
1
2
3
4
5
+19
View File
@@ -0,0 +1,19 @@
Blauchbeer-Ricotta-Tarte (zimtkeksundapfeltarte)
Für den Teig Mehl, Puderzucker und kalte Butter mit den Mixer zu eine krümeligen Teig verrühren, dann Ei und 2 EL kaltes Wasser einarbeiten und mit den Händen schnell zu einem glatten Teig verkneten. Für 30 Minuten in Folie gewickelt in den Kühlschrank legen.
Backofen auf 180 °C (160 °C Umluft) vorheizen. Den Mürbteig nach der Ruhezeit ausrollen und in die gefettete Form geben. Mit einer Gabel mehrfach einstechen, dann für 15 Minuten vorbacken.
Inzwischen die Eier, den Zucker und Vanillemark schaumig schlagen. Ricotta zufügen, weiterrühren, zuletzt Zitronensaft und -schale unterschlagen. Die Masse auf den vorgebackenen Mürbteig streichen und für weitere 45 Minuten backen. In der Form auskühlen lassen, dann mit den Beeren belegen. Mit etwas Puderzucker bestäubt servieren.
--- Table ---
Für den Mürbteig
220 g Mehl
100 g kalte Butter
50 g Puderzucker
1 Ei
2 EL kaltes Wasser
Belag
500 g Ricotta
2 Eier
Mark einer Vanilleschote
Saft- und Schale einer Bio-Zitrone
150 g Zucker
Ca. 300 g Heidelbeeren (oder andere Beeren Eurer Wahl)
@@ -0,0 +1 @@
[.jpg file: Blätterteigpäckchen mit Spargel.jpg]
@@ -0,0 +1,16 @@
--- Page 1 ---
Bounty®-Kuchen Rezept | Dr. Oetker https://www.oetker.de/rezepte/r/bounty-kuchen
1 von 4 24.08.2024, 12:25
--- Page 2 ---
Bounty®-Kuchen Rezept | Dr. Oetker https://www.oetker.de/rezepte/r/bounty-kuchen
2 von 4 24.08.2024, 12:25
--- Page 3 ---
Bounty®-Kuchen Rezept | Dr. Oetker https://www.oetker.de/rezepte/r/bounty-kuchen
3 von 4 24.08.2024, 12:25
--- Page 4 ---
Bounty®-Kuchen Rezept | Dr. Oetker https://www.oetker.de/rezepte/r/bounty-kuchen
4 von 4 24.08.2024, 12:25
+16
View File
@@ -0,0 +1,16 @@
Honig-Senf-Brezen-Cracker (toertchenmadeinberling)
Die Brezeln in kleine Stücke schneiden.
Senf, Honig, Öl, Essig, Salz, Paprika und Pfeffer in eine Schüssel geben und mit einem Schneebesen verrühren.
Die Soße über die Brezelstücke geben, gut verrühren und die Brezelstücke auf einem mit Backpapier belegten Blech verteilen.
Im vorgeheiztem Ofen bei 160° C Ober-/ Unterhitze im unteren Drittel für ca. 30 Minuten backen. Danach die Brezelstücke durchwenden, den Ofen auf 100° C runterdrehen und weitere 30 Minuten backen.
Die Brezelstücke aus dem Ofen nehmen und abkühlen lassen. Die Stücke werden erst hart, wenn sie abgekühlt sind.
--- Table ---
2 „alte“ Brezeln
50 g Dijonsenf
25 g Honig
25 ml Sonnenblumenöl
15 ml Apfelessig
1/2 Tl Salz
1/2 Tl Paprikapulver
1/8 Tl Pfeffer
@@ -0,0 +1,18 @@
--- Page 1 ---
2 10 Minuten 10 Minuten
Ernährung: 26 Kalorien, 0,3 fett
Champignons Weihnachtsmarkt mit Knoblauchsauce
--- Page 2 ---
ZUTATEN
ANLEITUNG
Zutaten:
Firefox https://ayseskochblog.de/champignons-weihnachtsmarkt-mit-knoblau...
2 von 3 08.11.2024, 14:29
--- Page 3 ---
TIPP:
Tipp: du kannst die Knoblauchsauce auch mit ein wenig Tabasco verfeinern.
Firefox https://ayseskochblog.de/champignons-weihnachtsmarkt-mit-knoblau...
3 von 3 08.11.2024, 14:29
+28
View File
@@ -0,0 +1,28 @@
Chili sin Carne (daily vegan)
8 Portionen
Etwas Öl in einer Pfanne erhitzen und das trockene Sojagranulat scharf darin abraten.
Parallel dazu in einem großen Topf etwas Öl erhitzen und die Zwiebeln darin glasig braten. Den Knoblauch und das angebratene Sojagranulat dazu geben. Mit dem Salz, Pfeffer, Koriander und Kreuzkümmel würzen und die Chilis dazu geben. Alles zusammen unter Rühren kurz anbraten.
Pizzatomaten, Tomatenmark und 1 l Brühe einrühren und ca. 15 Minuten köcheln, dabei ab und zu umrühren.
Anschließend auch Mais, Paprika, Kidney-Bohnen, Oregano und Schokolade dazu geben und weitere 15 Minuten köcheln.
Tipps:
Mein veganes Chili con Carne lässt sich sehr gut zu Beginn einer Party oder in den nächsten Tag wieder aufwärmen. Dadurch ist es auch bestens geeignet für Meal Prep.
Besonders gut schmeckt veganes Chili Sin Carne mit Brot oder Reis.
Ihr könnt das Chili übrigens auch (portioniert) einfrieren und bei Bedarf wieder auftauen.
--- Table ---
200 g Sojagranulat
4 Zwiebeln, gewürfelt
3 Knoblauch-Zehen, gepresst
2-4 scharfe Chilischoten, sehr fein gehackt (Menge je nach Geschmack)
800 ml Pizza-Tomaten
1 l Brühe
2 Gläser oder Dosen Mais, abgetropft
2 Gläser oder Dosen Kidneybohnen
7 EL Tomatenmark
2 grüne Paprika, gewürfelt1 Tafel
vegane Zartbitter-Schokolade, in Stücken
2 EL Kreuzkümmmel (Cumin)
2 EL Oregano
1 TL Koriander, gemahlen
2 TL Salz
2 TL Pfeffer
@@ -0,0 +1,50 @@
--- Page 1 ---
Rezept online aufrufen
Zutaten für 6 Portionen:
Für die Creme:
300 mlMilch
200 mlSahne, sü
2 Stück(e)Orange(n), Schale, unbehandelt
1 Stange/nZimt
½Vanilleschote(n), aufgeschlitzt
5Ei(er), davon das Eigelb
50 gZucker
Muskat, gerieben
6 ELZucker
Rezept von: Aurora
Crema catalana
Den Backofen auf 150°C vorheizen. Ein tiefes Backblech mit
Küchenkrepp auslegen, 2 cm hoch mit heißem Wasser füllen und in
die mittlere Schiene des Backofens schieben.
Milch mit süßer Sahne, der unbehandelten Orangenschale,
Zimtstange und der aufgeschlitzten Vanillestange in einen Topf
geben und zum Kochen bringen. Einmal aufkochen, den Topf vom
Herd nehmen und Orangenschale, Vanillestange und Zimtstange
herausnehmen. Etwas abkühlen lassen.
Eigelb mit Zucker mit dem Handrührgerät schaumig schlagen.
Anschließend langsam die Milch-Sahne-Mischung darunter rühren
und durch ein feines Sieb gießen. Die Eier-Milch-Masse vorsichtig in
kleine ofenfeste Förmchen geben, dabei darauf achten, dass die
Oberfläche schaumfrei bleibt. Eventuell den Schaum mit einem
Lö�el abnehmen. Die Förmchen so in das Wasserbad stellen, dass
sie zur Häl�e im Wasser stehen. Das ganze etwa 1 Stunde im
Backofen stocken lassen. Die Förmchen aus dem Ofen nehmen,
abkühlen lassen.
Vor dem Servieren: Den Grill des Backofens vorheizen oder den
Backofen auf größte Leistung stellen. Die Creme mit einem Hauch
geriebenem Muskat und pro Förmchen 1 EL Zucker bestreuen. Den
Zucker unter dem Grill goldbraun karamellisieren lassen. Das sollte
so schnell wie möglich geschehen, da die Creme selbst nicht heiß
werden soll.
Tipp:
Einfacher erhält man die Zuckerkruste mit einer Lötlampe. Einfach
mit der Flamme der Lötlampe über den Zucker streichen. Fertig.
Bei der "originalen" Crema catalana wird die Zuckerkruste mit
einem speziellen glühendem spiralförmigen Eisen gebrannt.
Arbeitszeitca. 45 Minuten
Gesamtzeitca. 45 Minuten
Schwierigkeitsgradnormal
Kcal p. P .ca. 285
Chefkoch Rezept: Crema catalana von Aurora https://www.chefkoch.de/rezepte/drucken/79651030530977/Crema-cata...
1 von 1 30.05.2024, 16:07
@@ -0,0 +1,11 @@
--- Page 1 ---
Cremige Zucchini-Bohnenpfanne mit
Erdnussbutter
Ja, Resteverwertung kann auch Spaß machen! Diese schnelle und leckere,
cremige Zucchini-Bohnenpfanne haben wir aus Kühlschrankresten und
geretteten Lebensmitteln zubereitet.
10 Min. 15 Min. 25 Min.
Hauptgericht Afrikanisch 2 Portionen Nadine
Zutaten
Anleitungen
+23
View File
@@ -0,0 +1,23 @@
Currytaschen (zuckerjagswurst)
30 Minuten | 8 Currytaschen
Zutaten
0,5 Zwiebeln
75g Lauch
75g Möhren
75g veganes Hack
1,5TL Thailändisches rotes Curry
100ml Sahne
0,5TL Gemüsebrühe
1,5EL Sojasauce
1 Blätterteig
Zubereitung
Ofen auf 180°C Umluft vorheizen.
Zwiebeln würfeln & Lauch in feine Ringe schneiden.
Möhren raspeln.
Zwiebeln anbraten. Hack dazugeben und 3-4 Minuten braten.
Lauch, Möhren und Curry dazugeben, weitere 3-4 Minuten braten.
¾ der Sahne dazugeben, mit Gemüsebrühe und Sojasauce würzen.
Pfanne vom Herd nehmen.
Blätterteig in 8 Quadrate schneiden. Füllung auf den Teig geben und Taschen verschließen.
Oberseite mit Sahne bestreichen.
10-12 Minuten backen.
@@ -0,0 +1,74 @@
--- Page 1 ---
Rezept online aufrufen
Zutaten für 1 Portionen:
Für den Teig:
3Ei(er)
175 gZucker
175 mlÖl
175 mlMilch
350 gMehl
3 TLBackpulver
1 ELKakaopulver
1 ELMilch
1 GlasSauerkirschen, abgetrop�
Fett für das Blech
Für die Creme:
1 Pck.Vanillepuddingpulver
400 mlMilch
2 ELZucker
250 gButter
Für die Glasur:
200 gZartbitterschokolade
2 ELNutella
1 ELÖl
Rezept von: alina1st
Donauwelle - super easy
hier kann eigentlich gar nichts mehr schief
gehen...
Für ein tiefes Backblech von ca. 30 x 40 cm. Geringfügig kleiner ist
auch möglich.
Zuerst die Butter aus dem Kühlschrank nehmen und in Stücken in
eine Schüssel geben, damit sie schön weich werden kann.
Dann das Puddingpulver mit 50 ml Milch und dem Zucker glatt
rühren, den Rest der Milch aufkochen. Das angerührte
Puddingpulver einrühren, nochmals aufkochen lassen und sofort in
eine Schüssel umgießen. Frischhaltefolie direkt auf den warmen
Pudding legen, so dass möglichst keine Lu�blasen zwischen
Pudding und Folie entstehen. Den Pudding nun bei
Raumtemperatur auskühlen lassen, bitte nicht kalt stellen!
Ein tiefes Blech (ich nutze ein 30 x 40 cm Blech, auf das ich einen
Backrahmen stelle) dünn einfetten und mit Backpapier belegen, so
dass das Papier gut anliegt. Den Ofen auf 180 °C Ober-/Unterhitze
vorheizen.
Die Eier mit dem Zucker dick-cremig aufschlagen und langsam das
Öl zugießen. Anschließend Mehl und Backpulver mischen und
abwechselnd mit der Milch unter die Eiercreme rühren. Die Häl�e
des Teiges auf das Blech streichen. Den übrigen Teig mit Kakao und
Milch verrühren und vorsichtig darüber streichen. Die Kirschen
darauf verteilen.
Den Kuchen im vorgeheizten Ofen ca. 25 - 30 min. backen.
Anschließend das Backpapier vom Blech auf einen Rost ziehen und
auskühlen lassen.
Die inzwischen zimmerwarm gewordene Butter cremig rühren. Die
Folie vom ebenfalls zimmerwarm gewordenen Pudding abnehmen
und nun lö�elweise den Pudding unter die Butter rühren. Falls der
Kuchenboden noch warm ist, die Creme evtl. so lange kalt stellen.
Danach die Creme gleichmäßig auf den Kuchen streichen. Wieder
kalt stellen.
Die Zartbitterschokolade im Wasserbad unter Rühren schmelzen
lassen, dann Nutella und Öl darunter rühren, so dass ein recht
flüssiger Schokoladenguss entsteht. Den Guss gleichmäßig auf
dem Kuchen verteilen und mit einer Teigkarte oder einem Messer
Chefkoch Rezept: Donauwelle - super easy von alina1st https://www.chefkoch.de/rezepte/drucken/563541154614076/Donauwel...
1 von 2 30.05.2024, 16:34
--- Page 2 ---
auf der Creme glattstreichen.
Am besten im Kühlschrank fest werden lassen.
Arbeitszeitca. 1 Stunde
Koch-/Backzeitca. 35 Minuten
Gesamtzeitca. 1 Stunde 35 Minuten
Schwierigkeitsgradsimpel
Kcal p. P .ca. 7914
Chefkoch Rezept: Donauwelle - super easy von alina1st https://www.chefkoch.de/rezepte/drucken/563541154614076/Donauwel...
2 von 2 30.05.2024, 16:34
+33
View File
@@ -0,0 +1,33 @@
--- Page 1 ---
Rezept online aufrufen
Zutaten für 8 Portionen:
500 gMehl
20 gHefe, frische
375 mlWasser
1 TLZucker
1 TLSalz
5 ELOlivenöl
Mehl, für die Arbeitsfläche und
Bleche
Rosmarin oder Thymian
Focaccia
Mehl in eine große Schüssel sieben, in die Mitte eine Mulde
drücken. Hefe und Zucker in 3/8 l lauwarmem Wasser auflösen und
in die Mulde gießen. Zugedeckt 30 Minuten gehen lassen.
3 EL Olivenöl und Salz unterrühren und den Teig gut durchkneten
(dauert ca. 10 Minuten). Zugedeckt nochmals gehen lassen, ca. 1
Stunde. Backofen auf 220° C vorheizen. Den Teig halbieren und zu
dicken Fladen ausrollen, diese auf die bemehlten Bleche legen. Mit
den Fingern Dellen in die Oberfläche drücken. Ein weiteres Mal
zudecken und eine weitere halbe Stunde gehen lassen. Mit dem
restlichen Öl bestreichen und dann nach Wahl mit Rosmarinnadeln
oder getrocknetem Thymian bestreuen. Auf der mittleren Schiene
ca. 15 - 20 Minuten backen (Umlu� 200! C).
Arbeitszeitca. 30 Minuten
Ruhezeitca. 2 Stunden
Gesamtzeitca. 2 Stunden 30 Minuten
Schwierigkeitsgradnormal
Kcal p. P .ca. 273
Chefkoch Rezept: Focaccia https://www.chefkoch.de/rezepte/drucken/405741129901058/Focaccia.html
1 von 1 30.05.2024, 16:09
@@ -0,0 +1,45 @@
--- Page 1 ---
Rezept online aufrufen
Zutaten für 4 Portionen:
Für die Creme:
50 gWeichweizengrieß
1 Pck.Vanillepuddingpulver
700 mlMilch, kalt
4Ei(er)
125 gZucker
2 ELButter, weich
Für den Teig:
500 gBlätterteig
65 gButter
Für den Sirup: :
375 gZucker
250 mlWasser, lauwarm
1 TLZimt
1Zitrone(n), ausgepresst
Galaktoboureko - griechischer
Grießauflauf
Den Sirup sollte man zuerst zubereiten, da er kalt sein muss, bevor
man ihn auf den Auflauf gießt.
Grieß und Vanillepuddingpulver mit etwas kalter Milch anrühren.
Restliche Milch aufkochen lassen, Grieß-/Puddingpulvermischung
unterrühren.
Eier mit Zucker und flüssiger Butter verrühren und unter die
Grießmasse rühren.
Eine rechteckige Auflau�orm mit Butter ausstreichen, die Häl�e
des Blätterteigs hineinlegen, Creme darauf verteilen und mit
restlichem Blätterteig abdecken. Den Blätterteig mit einem Messer
an der Oberfläche rautenförmig einschneiden, mit ein wenig
Wasser bespritzen und bei 180 °C auf der untersten Schiene ca. 30
Minuten goldgelb backen.
Für den Sirup Feinkristallzucker mit Wasser aufkochen und solange
köcheln lassen, bis sich dieser aufgelöst hat. Dann Zimt und
Zitronensa� dazugeben. Sirup auskühlen lassen.
Heißen Grießauflauf aus dem Backrohr nehmen und sofort mit
abgekühltem Sirup übergießen.
Arbeitszeitca. 30 Minuten
Koch-/Backzeitca. 50 Minuten
Gesamtzeitca. 1 Stunde 20 Minuten
Schwierigkeitsgradnormal
Chefkoch Rezept: Galaktoboureko - griechischer Grießauflauf https://www.chefkoch.de/rezepte/drucken/1048301209719211/Galakto...
1 von 1 30.05.2024, 16:10
@@ -0,0 +1,59 @@
--- Page 1 ---
Rezept online aufrufen
Zutaten für 4 Portionen:
250 gBrötchen oder Brot, altbacken
300 mlMilch zum Einweichen
2Ei(er)
2 ELMehl
200 gBergkäse oder Gruyere
n. B.Salz und Pfe�er
n. B.Muskat
etwasKümmel, gerieben, optional
1 großeZwiebel(n), in Würfel geschnitten
½ BundPetersilie
3 ELSemmelbrösel
1 ELButter
etwasPflanzenöl
Rezept von: felidae177
Gebratene Kasknödel
Käseknödel, prima als Beilage für Pilze in
Sahnesoße oder Geschnetzeltes
Das Brot oder Brötchen in kleine Stücke schneiden, in eine große
Schüssel füllen und mit warmer Milch übergießen. Mit den Händen
etwas mischen und ca. 20 - 30 Minuten einweichen. Die
Milchmenge hängt davon ab, wie trocken das Brot ist, 300 bis 400
ml Milch sollten reichen. Man kann auch nach einiger Zeit noch
etwas angießen, falls es zu wenig ist.
Die Zwiebel schälen und in Stücke schneiden. In Butter andünsten,
nicht bräunen, und auf die geweichte Brotmasse geben. Den Käse
klein würfeln und mit den Eiern ebenfalls zur Masse geben.
Die Petersilie waschen, trocken schütteln und klein hacken.
Zusammen mit Salz, Pfe�er, Muskat und evtl. etwas geriebenem
Kümmel in die Schüssel geben und die Masse nun gut mit den
Händen durcharbeiten, probieren und ggf. nachwürzen. Die
Feuchtigkeit reguliert man mit 2 bis 3 EL Mehl. Der Teig sollte
weich, aber nicht zu flüssig werden. Ggf. kann man ihn
anschließend noch 15 Minuten ruhen lassen.
Die Semmelbrösel in einen tiefen Teller füllen und in einer großen,
breiten Pfanne einen EL Butter und etwas neutrales Pflanzenöl
erhitzen. Mit feuchten Händen - wichtig, sonst klebt es - aus jeweils
ca. 1 - 2 EL Masse kleine Buletten formen, in den Semmelbröseln
wälzen und im Fett von beiden Seiten braten. Dazu das Fett nicht zu
heiß werden lassen, lieber etwas länger backen.
Die heißen Kasknödel rasch mit Rahmsoße, Pilzen oder
Geschnetzeltem servieren. Wenn man sie anschneidet läu� immer
etwas vom würzigen Käse aus, das schmeckt sehr lecker.
Sie schmecken am nächsten Tag auch kalt im Büro, aber heiß und
knusprig sind sie natürlich am Besten.
Arbeitszeitca. 30 Minuten
Ruhezeitca. 35 Minuten
Koch-/Backzeitca. 20 Minuten
Gesamtzeitca. 1 Stunde 25 Minuten
Schwierigkeitsgradnormal
Kcal p. P .ca. 585
Chefkoch Rezept: Gebratene Kasknödel von felidae177 https://www.chefkoch.de/rezepte/drucken/2656011417082549/Gebrate...
1 von 2 30.05.2024, 16:12
--- Page 2 ---
Chefkoch Rezept: Gebratene Kasknödel von felidae177 https://www.chefkoch.de/rezepte/drucken/2656011417082549/Gebrate...
2 von 2 30.05.2024, 16:12
@@ -0,0 +1,55 @@
--- Page 1 ---
Rezept online aufrufen
Zutaten für 1 Portionen:
Für den Mürbeteig:
300 gMehl
100 gButter
100 gZucker
1 TLBackpulver
1 Pkt.Vanillinzucker
2Ei(er)
Für die Füllung:
1.500 gÄpfel, möglichst saure
50 gButter
50 gRosinen
50 gZucker
1 Pkt.Vanillepuddingpulver
Für die Streusel:
150 gMehl
100 gButter
100 gZucker
Rezept von: mbus
Gedeckter Apfelkuchen mit
Streuseln
Mehl, Butter, Zucker, Backpulver, Vanillinzucker und Eier zu einem
Mürbeteig verarbeiten, diesen dann 30 Min. ruhen lassen.
Die Äpfel schälen, achteln und dann in Würfel schneiden. Dabei das
Kerngehäuse entfernen. Zusammen mit der Butter, dem Zucker
und den Rosinen in einen Topf geben und solange erhitzen, bis die
Apfelstückchen anfangen zu verkochen und eine festere Masse
entsteht. Dann zur Seite stellen.
Einen Vanillepudding nach Packungsangabe zubereiten. Evtl.
werden dafür weiter Zutaten benötigt (Milch). Den Pudding
abkühlen lassen, jedoch nur soweit, dass er nicht ganz fest ist
sondern dickflüssig.
Eine Springform einfetten und den Mürbeteig darin ausrollen.
Einen Rand ca. 3 cm hochziehen, damit die Masse nicht ausläu�.
Nun erst den Pudding auf den Teig geben und gleichmäßig
verteilen. Dann die Apfelmasse darauf geben und vorsichtig
verteilen.
Aus dem Mehl, der Butter und dem Zucker einen Krümelteig
erstellen und oben auf die Apfelfüllung geben.
Bei 160 Grad für ca. 60 Minuten in den vorgeheizten Backofen
geben. Damit der Kuchen auch braun wird, drehe ich den Backofen
10 Minuten vor dem Ende noch einmal auf 200 Grad hoch, aber
dabeistehen, sonst wird er vielleicht zu braun.
Wer mag, kann auch den Äpfeln Zimt zusetzen, wir mögen jedoch
keinen Zimt.
Arbeitszeitca. 45 Minuten
Koch-/Backzeitca. 1 Stunde
Gesamtzeitca. 1 Stunde 45 Minuten
Schwierigkeitsgradsimpel
Kcal p. P .ca. 5707
Chefkoch Rezept: Gedeckter Apfelkuchen mit Streuseln von mbus https://www.chefkoch.de/rezepte/drucken/305151110793264/Gedeckte...
1 von 1 30.05.2024, 16:36
@@ -0,0 +1,46 @@
--- Page 1 ---
Rezept online aufrufen
Zutaten für 3 Portionen:
Für die Sauce:
2 TL, gehäu�Speisestärke
3 ELWasser, kalt
3 ELZucker
3 ELReisessig
3 ELSojasauce, hell
2 ELSojasauce, dunkel
1 Zehe/nKnoblauch, fein gehackt
Für das Gemüse:
2Paprikaschote(n)
n. B.Chilischote(n)
500 gChampignons
1 BundFrühlingszwiebel(n)
1 HandvollErdnüsse, geröstet
Sonnenblumenöl zum Braten
Salz und Pfe�er
Rezept von: zuiko
Gemüse nach Gonbao-Art
vegetarisches Wok-Gericht mit dunkler,
pikanter Sauce
Für die Gonbao-Sauce die Speisestärke in 3 EL kaltem Wasser
auflösen. Die übrigen Zutaten für die Sauce dazugeben und alles
miteinander verrühren.
Die Champignons vierteln, Frühlingszwiebeln in Ringe schneiden,
Paprikaschoten in Würfel schneiden, Chilischoten fein hacken.
Im Wok oder einer hohen Pfanne etwas Sonnenblumenöl erhitzen
und zunächst die Paprika sowie die fein gehackten Chilischoten bei
mittlerer bis hoher Hitze anbraten. Nach ungefähr 3 Minuten die
Champignons dazugeben. Alles mit Pfe�er und Salz würzen und
weitere 5 Minuten unter ständigem Rühren anbraten.
Die Gonbao-Sauce sowie die Frühlingszwiebeln in den Wok geben.
Die Sauce einmal aufkochen lassen. Dann die Hitze reduzieren und
die gerösteten Erdnüsse dazugeben. Das Gemüse solange weiter
braten, bis es gar, aber noch bissfest ist.
Das Gemüse nach Gonbao-Art mit gekochtem Reis oder
chinesischen Nudeln servieren.
Arbeitszeitca. 15 Minuten
Koch-/Backzeitca. 15 Minuten
Gesamtzeitca. 30 Minuten
Schwierigkeitsgradnormal
Chefkoch Rezept: Gemüse nach Gonbao-Art von zuiko https://www.chefkoch.de/rezepte/drucken/3676711553884546/Gemues...
1 von 1 30.05.2024, 16:13
@@ -0,0 +1,21 @@
--- Page 1 ---
Rezept online aufrufen
Zutaten für 6 Portionen:
30 gFett oder Butter
1Ei(er)
Salz
70 gGrieß
1 LiterSuppe
Rezept von: jawi
Griessnockerl (Suppeneinlage)
Das Fett schaumig rühren, das Ei dazurühren, ebenso den Grieß
und das Salz. Die Masse 5 Minuten stehen lassen. Mit 2 Esslö�eln
Nockerln formen. Die Nockerl in siedendem Salzwasser oder auch
gleich in der Suppe 5 Minuten kochen lassen und danach ca. 15
Minuten ziehen lassen.
Arbeitszeitca. 35 Minuten
Gesamtzeitca. 35 Minuten
Schwierigkeitsgradnormal
Chefkoch Rezept: Griessnockerl (Suppeneinlage) von jawi https://www.chefkoch.de/rezepte/drucken/47781016275357/Griessnock...
1 von 1 30.05.2024, 16:15
@@ -0,0 +1,24 @@
--- Page 1 ---
Rezept online aufrufen
Zutaten für 2 Portionen:
½ LiterMilch
5 ELGrieß
3 ELZucker
1 ELKakaopulver oder etwas Zimt
Rezept von: pilotilka
Grießbrei
sehr beliebt bei Kindern
Einen kleinen Teil der Milch in eine Rührschüssel geben, den Rest
aufkochen. Grieß und Zucker in die Rührschüssel geben, gut
verrühren. Wenn die Milch aufkocht, die Flüssigkeit aus der
Rührschüssel dazugeben und unterrühren.
Unter ständigem Rühren aufkochen und ein paar Minuten kochen
lassen. Auf dem Teller mit etwas Kakaopulver oder
Zimtzuckergemisch bestreuen.
Arbeitszeitca. 10 Minuten
Gesamtzeitca. 10 Minuten
Schwierigkeitsgradsimpel
Kcal p. P .ca. 398
Chefkoch Rezept: Grießbrei von pilotilka https://www.chefkoch.de/rezepte/drucken/1086221214500502/Griessbre...
1 von 1 30.05.2024, 16:14
+40
View File
@@ -0,0 +1,40 @@
Gulasch (vollveggie)
6 Portionen
Eine große Rührschüssel auf eine Küchenwaage stellen, alle Zutaten bis auf das Sojafleisch darin abwiegen bzw. abmessen und mit einem Schneebesen gut durchrühren.
Nun das trockene Sojafleisch zu der Marinade in die Schüssel geben und mit einem Rührlöffel gut unterheben. Zum Ziehen entweder in der Schüssel belassen, abdecken und immer mal wieder durchrühren. Oder aber die Masse in einen Plastikbeutel geben, verschließen und immer mal wieder gut durchkneten. Damit die Marinade besonders gut in das Sojafleisch einziehen kann, mindestens 12 Stunden, am besten über Nacht, einweichen lassen.
Die Zwiebeln schälen und in feine Würfel schneiden. Die Knoblauchzehen ebenfalls schälen und entweder in feine Würfel schneiden oder mit einer Knoblauchpresse direkt in den Topf pressen – die Knoblauchzehen werden aber erst später untergehoben, damit sie nicht verbrennen oder bitter werden. Paprikaschote waschen, halbieren, entkernen und in feine Würfel oder Streifen schneiden.
Das Olivenöl in einem großen Topf erhitzen und darin die über Nacht eingelegten Sojawürfel (falls erforderlich, diese vorher etwas ausdrücken) samt eventuell vorhandener Restmarinade für 5 Minuten auf hoher Stufe scharf anbraten, zwischendurch mit einem Kochlöffel immer mal wieder kräftig umrühren. Keine Sorge: Wenn sich etwas am Topfboden ansetzen sollte, lässt sich das spätestens nach der Flüssigkeitszugabe gut lösen und einrühren.
Die Zwiebel- und Paprikawürfel hinzugeben und weitere 5 Minuten scharf mitbraten, zwischendurch immer mal wieder kräftig umrühren. Dann den Herd auf mittlere Stufe herunterdrehen. Danach das Tomatenmark unterrühren, kurz anschwitzen lassen und die Knoblauchwürfel unterheben oder mit einer Knoblauchpresse direkt in den Topf pressen.
Die Gewürze Paprika edelsüß und scharf, Majoran und Thymian hinzugeben, kräftig unterrühren und kurz mitbraten; eine sogenannte Feinwaage eignet sich übrigens perfekt zum Abwiegen von Gewürzen und Kräutern. Auch hier keine Sorge: Wenn sich etwas am Topfboden ansetzen sollte, lässt sich das spätestens nach der Flüssigkeitszugabe gut lösen und einrühren.
Dann das Mehl bzw. Speisestärke oder Saucenpulver unterheben. Kurz ziehen lassen. Anschließend mit dem Rotwein, alternativ Kirschsaft oder Gemüsebrühe, vorsichtig ablöschen, kräftig umrühren und kurz simmern lassen. Auch hier wieder keine Sorge: Wenn sich etwas am Topfboden ansetzen sollte, lässt sich das spätestens nach der Flüssigkeitszugabe gut lösen und einrühren.
Das Gulasch nun etwa 30 Minuten zugedeckt bei kleiner Stufe köcheln lassen und das Wasser dabei portionsweise unterrühren, bis die gewünschte Konsistenz erreicht ist. Dabei immer mal wieder kräftig durchrühren. Mit Zucker, Salz und Pfeffer sowie nach Belieben mit den anderen Gewürzen abschmecken. Das Gulasch dickt übrigens noch nach, besonders am nächsten Tag; dann einfach etwas Rotwein, Wasser, Kirschsaft oder Gemüsebrühe bis zur gewünschten Konsistenz nachgießen.
--- Table ---
Marinade:
70 Sojasauce
40g Worcestershiresauce
50 g Tomatenmark
30g Balsamico
20g Olivenöl
15g Senf, mittelscharf
15 g Agavendicksaft
1 Msp. Sambal Oelek
10g Paprikapulver edelsüß
½ TL Paprikapulver, rosenscharf
500ml Wasser, heiß
250g Sojafleisch (Medaillions)
Gulasch:
175g Zwiebeln
200g Paprika, rot
2 Knoblauchzehen
3EL Olivenöl
3EL Tomatenmark
1TL Paprikapulver, edelsüß
½ TL Paprikapulver, rosenscharf
1TL Majoran
1TL Thymian
6EL Weizenmehl
500ml Rotwein, trocken
1200ml Wasser
1Prise Rohrzucker
+28
View File
@@ -0,0 +1,28 @@
Heidelbeer-Tiramisu
8 Portionen
Bio-Zitrone heiß abwaschen, abtrocknen, Schale abreiben und Saft auspressen. Zitronensaft beiseite stellen.
Heidelbeeren waschen, verlesen und 400 g abwiegen. Heidelbeeren zusammen mit der Hälfte der Zitronenschale und Gelierzucker in einen Topf geben. Aufkochen. Mit einem Kartoffelstampfer die Beeren zerquetschen. Köcheln, bis die Gelierprobe gut ist.
Sahne mit Vanillezucker steif schlagen. Mascarpone mit Joghurt, Diamant Feinster Zucker, restlicher Zitronenschale und Zitronensaft glatt rühren. Zuletzt Sahne vorsichtig unterheben.
Orangen auspressen. Löffelbiskuits kurz in den gepressten Orangensaft tauchen und den Boden einer rechteckigen Auflaufform (ca. 15 x 30 cm) damit auslegen. Darauf vorsichtig erst die Hälfte der Heidelbeersoße, dann die Hälfte der Cremeschicht verteilen. So fortfahren, bis alle Zutaten verbraucht sind. Die letzte Lage sollte aus Creme bestehen. Mindestens 5 Stunden im Kühlschrank durchziehen lassen.
Heidelbeeren waschen und verlesen, Schokolade raspeln. Tiramisu mit Heidelbeeren und Schokoladenraspeln verzieren.
--- Table ---
Heidelbeersoße:
1 Zitrone Bio
400g Heidelbeeren
150g Gelierzucker 2:1
Creme:
250g Sahne
1 Päckchen Vanillezucker
500g Mascarpone
300g Joghurt
80g Zucker
Keksschickt:
1-2 Orangen
250g Löffelbiskuits
Deko:
50g Heidelbeeren
25g weiße Schokolade
+15
View File
@@ -0,0 +1,15 @@
Kidneybohnen-Brownies
Zutaten
Zunächst die Kidneybohnen mit einem Mixer oder einer Küchenmaschine pürieren, anschließend mit den restlichen Zutaten (bis auf die Zartbitterschokolade) zu einer cremigen Masse verrühren.
Die gehackte Schokolade unterheben und die Masse dann in eine mit Backpapier ausgelegte bzw. gefettete Form gegeben und im vorgeheizten Ofen bei 200 Grad Umluft für ca. 15-20 Minuten gebacken.
--- Table ---
1 Dose Kidneybohnen
30 g gemahlene Mandeln
2 Eier
50 g Kakaopulver
100 g Zucker oder Zuckerersatz (z.B. 100 g Xylit oder 125 g Erythrit)
2 EL neutrales Öl (z.B. Rapsöl)
1 Pck. Vanillezucker
1/2 Pck. Backpulver
100 g gehackte Zartbitterschokolade
@@ -0,0 +1,7 @@
--- Page 1 ---
LEBKUCHEN-MOUSSE MIT GLÜHWEINKIRSCHEN https://transglobalpanparty.com/lebkuchen-mousse-gluehweinkirschen/
1 von 2 24.08.2024, 12:41
--- Page 2 ---
LEBKUCHEN-MOUSSE MIT GLÜHWEINKIRSCHEN https://transglobalpanparty.com/lebkuchen-mousse-gluehweinkirschen/
2 von 2 24.08.2024, 12:41
@@ -0,0 +1,49 @@
--- Slide 1 ---
Lachs in Thai-Curry-Soße mit Zucchini
Goldenem Reis und eingelegten roten Zwiebelb
Zutaten 2 Personen
200g Lachsfilet
150g Jasminreis
1 Zucchini
1 Knoblauchzehe
1 Zwiebel
180ml Kokosmilch
25g rote Currypaste
2g Currygewürz
4g Brühepulver
25g Frühlingszwiebeln
1 rote Zwiebel
Was Du zu Hause haben solltest
300ml Wasser
1/4TL Weißweinessig
Salz
Pfeffer
Zucker
25 – 35 Minuten
Erhitze 300 ml Wasser im Wasserkocher.
In einen kleinen Topf mit Deckel 300 ml heißes Wasser füllen.
Hälfte „Hello Curry“ und 0,25 TL Salz dazugeben und aufkochen lassen.
Reis zugeben und bei niedriger Hitze 10 Min. abgedeckt köcheln lassen.
Vom Herd nehmen und abgedeckt 10 Min. ziehen lassen.
Gelbe Zwiebel grob würfeln.
Rote Zwiebel in feine Streifen schneiden.
Frühlingszwiebel in feine Ringe schneiden.
Knoblauch fein hacken.
Zucchini längs halbieren und in 0,5 cm Halbmonde schneiden.
In einem kleinen Topf 50 ml Wasser, 50 ml Essig, Salz und Zucker aufkochen.
Zwiebelstreifen hinzugeben und 30 Sek. köcheln lassen. Von der Hitze nehmen und marinieren lassen.
Lachsfilets rundum salzen.
In einer großen Pfanne 1 EL Öl erhitzen. Lachs darin auf der Hautseite 2 Min. anbraten, dann wenden und weitere 2 – 3 Min. braten, bis der Fisch innen nicht mehr glasig ist.
Lachs herausnehmen und nach Belieben die Haut abziehen.
In derselben Pfanne erneut 1 EL Öl erhitzen. Zwiebel, weißen Teil der Frühlingszwiebel und Zucchini darin 3 – 4 Min. anschwitzen.
Knoblauch, rote Currypaste und restliches „Hello Curry“ darin 1 weitere Min. anbraten.
Pfanneninhalt mit Kokosmilch, 100 ml Wasser und Brühepulver ablöschen und 2 – 3 Min. köcheln lassen.
Soße mit 0,25 TL Essig, Salz, Pfeffer und eine Prise Zucker abschmecken.
Lachs zurück in die Pfanne geben und warm halten, bis der Reis gar ist.
Reis und Thai-Curry auf tiefen Tellern verteilen. Lachs darauf anrichten und mit grünem Teil der Frühlingszwiebel und eingelegten roten Zwiebeln garnieren.
1
2
3
4
5
@@ -0,0 +1,19 @@
Mango-Limetten-Triffle
Für 2 Personen
Zutaten
1 Limette
2 Esslöffel Wasser heiss
1 Teelöffel Puderzucker (1)
50 g weisse Schokolade
180 g griechischer Joghurt
1 Esslöffel Puderzucker (2)
4 Löffelbiscuits
0.5 Mango klein
1 Teelöffel Puderzucker (3)
Zubereitung
Die Schale von ½ Limette fein abreiben. Den Saft der ganzen Limette auspressen.
1 Esslöffel Limettensaft mit dem heissen Wasser und der ersten Portion Puderzucker (1) gut verrühren. Beiseitestellen.
Die weisse Schokolade fein hacken. In eine Metallschüssel geben und ü ber einem heissen – nicht kochenden – Wasserbad schmelzen lassen. Dann den Joghurt, die zweite Portion Puderzucker (2), 1 Esslöffel Limettensaft sowie die abgeriebene Limettenschale unterrühren.
Die Löffelbiskuits halbieren. Jeweils abwechselnd mit der Limettencreme in 2 Gläser schichten, dabei jede Lage Löffelbiskuits mit der Limettensaftmischung beträufeln. Mindestens 15 Minuten kalt stellen.
Die Mango schälen und das Fruchtfleisch vom Stein schneiden. Dieses mit der dritten Portion Puderzucker (3) in einen hohen Becher geben und fein pürieren. Bis zum Servieren kühl stellen.
Vor dem Servieren das Mangopüree über die Triffles in den Gläsern verteilen.
@@ -0,0 +1,20 @@
--- Slide 1 ---
One-Pot Mie Nudeln
Zutaten 2 Personen
400ml Kokosmilch
300ml Wasser
3 EL Sojasoße
2 EL Erdnussbutter
1 EL Currypulver
3 Nester Mie Nudeln
½ Brokkoli
3 Karotten
Karotten schälen und schneiden.
Brokkoli schneiden.
Kokosmilch, Wasser, Sojasoße, Erdnussbutter, Currypulver in eine hohe und große Pfanne geben. Unter rühren aufkochen.
Nudeln und Gemüse in die Pfanne geben. Deckel drauf und ca. 15 Minuten auf kleiner Flamme gar ziehen lassen. Ab und zu umrühren.
1
2
3
Mit Kokos, Curry, Karotte und Brokkoli
@@ -0,0 +1,54 @@
--- Page 1 ---
2 Paprika
1 rote Zwiebel
2 Knoblauchzehen
60 g Olivenöl
1 TL Cumin (Kreuzkümmel)
1 TL Cayennepfeffer
1 TL Salz
1 TL Pfeffer
1 EL Majoran
1 TL Rosmarin
1 TL Thymian
1/2 TL Muskatnuss
1/2 TL Curry
2 EL Tomatenmark
30 ml Ahornsirup
220 g Basmatireis
80 g Berglinsen
400 g gehackte Tomaten aus
der Dose
600 ml Gemüsebrühe
30 g Zartbitterschokolade
200 g Mais (Abtropfgewicht)
200 g Kidneybohnen
100 g ger. Cheddar
2 EL Pankomehl (Paniermehl)
2 EL Petersilie
4-6 Personen I 28 cm Ofenform Den Backofen auf 180 °C Umluft vorheizen. Paprika, Zwiebel
und Knoblauch inStücke schneiden. In zwei Esslöffel
Olivenöl scharf anbraten. Gewürze und Kräuter mischen
und hinzugeben, kurz mitbraten. Ahornsirup hinzugeben
und leicht karamellsieren lassen.
Reis und Berglinsen hinzugeben und mit Tomaten und
Brühe ablöschen. Schokolade, Tomatenmark, Mais und
Kidneybohnen unterrühren. Die Hälfte des Käses ebenso
grob untergeben.
Wenn die Pfanne sich nicht für den Ofen eignet, Masse in
eine Ofenform geben. Im Ofen 20 Minuten backen. Den
restlichen Cheddar, Olivenöl, Panko- oder Paniermehl (oder
ein Toastbrot zerkleinert), Petersilie miteinander
vermengen. Nach der Backzeit über die Masse geben und
weiter 5-10 Minuten garen. Reis und Linsen sollten weich
sein.
Gerne mit Creme Fraiche oder Schmand servieren.
One Pot Chili Reispfanne
aus dem Ofen
Schnell gemacht, würzig und lecker
So wird's gemachtEinkaufsliste
knusperstuebchen.net
Zeit
Vorbereitung | 5 Min
Kochen | 20-30 Min
Fertig in |30 Min
@@ -0,0 +1,29 @@
Orangentofu mit Reis
2-3 Personen
Zutaten
Für den Tofu:
400g Tofu
4EL Speisestärke
1TL Paprikapulver edelsüß
Für die Soße:
6-8 Orangen für 250g Orangensaft
1 Orange (Abrieb)
1 Zwiebel
5g Ingwer
1 Knoblauchzehe
0,5 TL Chiliflocken
50g Rohrzucker
1EL Reisessig
2EL Sojasauce
1TL Speisestärke
Zum Servieren:
Reis
Sesam
Frühlingszwiebeln
Zubereitung
Den Tofu in grobe Stücke zerteilen & in eine verschließbare Box geben. Speisestärke, Paprikapulver, Salz & Pfeffer dazugeben. Box verschließen und schütteln.
Öl in einer Pfanne erhitzen. Tofu von allen Seiten anbraten, bis es außen goldbraun ist.
In der Zwischenzeit Orangensaft auspressen und Schale abreiben.
Zwiebel, Ingwer, Knoblauch schäle und fein hacken.
Tofu aus der Pfanne nehmen und beiseitestellen. Zwiebeln, Ingwer, Knoblauch und Chili anbraten. Großteil des Orangensafts, Zucker, Reisessig, Sojasauce und Orangenabrieb dazugeben und ca. 5 Minuten köcheln lassen.
Speisestärke mit dem Rest des Orangensafts verrühren, zur Soße geben und eindicken lassen. Tofu zur Sauce geben.
@@ -0,0 +1,24 @@
Orzo Halloumi Salat (nisi_cherie)
3 Portionen
Für den Salat:
100g Orzo Nudeln
80g Granatapfelkerne
100g Halloumi Natur
Honig
150g kleine Tomaten
½ Gurke
Basilikum
1 Frühlingszwiebel
Dressing:
30ml Olivenöl
15ml weißer Balsamico
1,5TL Honig
1,5TL Senf
Salz & Pfeffer
Orzo nach Packungsanweisung kochen & abkühlen lassen.
Zutaten für das Dressing verrühren.
Halloumi in Stücke schneiden.
Öl in einer Pfanne erhitzen & Halloumi rundeherum anbraten. Etwas Honig darüber geben und kurz in der Pfanne weiter schwenken.
Tomaten & Gurken schneiden.
Frühlingszwiebeln & Basilikum hacken.
Alles miteinander vermengen.
+201
View File
@@ -0,0 +1,201 @@
--- Slide 1 ---
Butterplätzchen
Zutaten:
6 Eigelb
500g Mehl
250g Zucker
250g Butter
Alle Zutaten verknete. Über Nacht in den Kühlschrank.
Teig ausrollen und ausstechen.
Für ca. 10 Minuten bei 180°C backen.
Variationen: Spitzbuben, Hausfreunde
--- Slide 2 ---
Marzipankissen
Teig:
300g Mehl
100g Puderzucker
200g kalte Butter
2 Eigelb
1 Prise Salz
Füllung:
300g Marzipan
2 Eiweiß
2EL Zitronensaft
8 Tr. Bittermandelaroma
Puderzucker zum Bestäuben
Zubereitung:
Alle Zutaten des Teigs verkneten. Teig zu einem Rechteck (ca. 20x15cm) formen und ca. 2 Std. in den Kühlschrank (nicht zu lange!). Marzipan 2 Std. in den Kühlschrank oder 15 Min. in die Gefriere.
Marzipan grob raspeln. Mit anderen Zutaten der Füllung gut quirlen.
Teig viertel, 8 cm breit ausrollen. Teig mit der Füllung bestreichen, einrollen und dann Stücke abschneiden (wie Mini-Blätterteigschnecken).
Bei 180°C 12-14 Min. backen.
Noch warm mit Puderzucker bestäuben.
--- Slide 3 ---
gefüllte Marzipan-Orangen-Plätzchen
Teig:
150g Mehl
50g Speisestärke
125g Zucker
1 Pck. Vanillezucker
1 TL Orangenabrieb
1 Prise Salz
175g kalte Butter
1 Ei
100g Marzipan
100g gemahlene Mandeln
Füllung: Orangenmarmelade
Glasur:
2-3 EL Orangensaft
1 TL Orangenabrieb
150g Puderzucker
Zubereitung:
Alle Zutaten des Teigs verkneten. Für 30 Min. in den Kühlschrank.
Teig ausrollen, Kreise ausstechen.
Bei 175°C ca. 12 Min. backen.
Auskühlen lassen, dann mit Marmelade bestreichen und zusammensetzen.
Glasur anrühren und Plätzchen bestreichen.
--- Slide 4 ---
Lebkuchenplätzchen
Teig:
250g Honig
125g Butter
125g Zucker
1 Ei
500g Mehl
1 Pck. Backpulver
2EL Kakao
1 Pck. Lebkuchengewürz
Prise Salz
Zitronenabrieb
Deko:
Schokoglasuren
Puderzucker
Streusel
Zubereitung:
Honig, Butter und Zucker in einem Top unter Rühren erhitzen, bis sich alles gut verbunden hat. Abkühlen lassen.
Ei unterrühren. Mehl mit Backpulver und Kakao vermischen und untersieben. Gewürze hinzufügen und alles zu einem Teig verkneten.
1 Std. kalt stellen.
Teig ca. ½ cm dick ausrollen und ausstechen.
Bei 180°C 15-20 Minuten backen.
Mit Schokoguss, Zuckerguss und Streuseln verzieren.
--- Slide 5 ---
Zimt-Orangen-Plätzchen
Zubereitung:
Butter und Zucker schaumig schlage. Orangenabrieb und Zimt untermischen. Mehl unterrühren.
Teig sofort zwischen Backpapier. ausrollen und ausstechen.
Bei 175°C ca. 10 Min backen.
Abkühlen lassen, dann zur Hälfte in Schokoladenguss tunken.
Zutaten:
200g weiche Butter
100g Puderzucker
300g Mehl
Abrieb von 2 Bio Orangen
2 TL Zimt
Schokoglasur Zartbitter
--- Slide 6 ---
Vanillekipferl
Teig:
250g Mehl
210g Butter
100g gemahlene Mandeln
80g Zucker
2 Pck. guter Vanillezucker
Deko:
½ Pck. Puderzucker
1 Pck. guter Vanillezucker
Zubereitung:
Zutaten des Teigs verknete, dann 30 Min. in den Kühlschrank.
In der Zwischenzeit den Puderzucker sieben und mit Vanillezucker mischen.
Kipferlteig zu Rollen formen. Stücke abschneiden und Kipferl formen. Vorsicht: werden im Ofen sehr viel größer.
Bei 175°C Umluft ca. 10-15 Min. backen.
Lauwarm im Puderzucker wenden. Zu warm zerbröseln die Plätzchen, zu kalt haftet der Puderzucker nicht.
--- Slide 7 ---
Kaffee Kakao Plätzchen
Teig:
160g Mehl
30g Kakao
40g Puderzucker
2TL Kaffeepulver
30g Schokoraspeln
150g Butter
Deko:
Weiße Kuvertüre
1 Pck. Schoko-Kaffeebohnen
Zubereitung:
Alle Zutaten des Teigs verknete.
3 Rollen formen. Rollen 20 Min. kalt stellen.
Scheiben von der Rolle anschneiden.
Bei 160°C ca. 12 Min. backen.
Dekorieren
--- Slide 8 ---
Zimtsterne
Teig:
500g gemahlene Mandeln
300g Puderzucker
2TL Zimt
2 Eiweiß
2EL Mandellikör
Glasur:
1 Eiweiß
125g Puderzucker
Zubereitung:
Mandeln, Puderzucker und Zimt mischen. Eiweiß und Likör zugeben. Verkneten.
Teig zwischen Backpapier ca. 1cm dick ausrollen. Sterne ausstechen, Ausstecher zwischendurch in Puderzucker tauchen.
Eiweiß steif schlagen. Puderzucker nach und nach zugeben, dabei weiterschlagen.
Sterne bepinseln.
Auf unterster Schiene bei 150°C Ober-/ Unterhitze ca. 10-15 Min. backen.
--- Slide 9 ---
Orangen Schoko Kipferl
220g Mehl
120g weiche Butter
100g Puderzucker
4EL Kakao
2 Eigelb
Alle Zutaten außer der Glasur verkneten
Teig zu einer Rolle formen. Stücke abschneiden und zu Kipferl formen.
Bei 180°C ca. 10-12 Min. backen.
Kipferl auskühlen lassen, anschließend die Enden in Schokoglasur tunken.
2EL Milch
1 Msp. Zimt
Abrieb von 1 Orange
5 Tr. Orangenaroma
Kuchenglasur
ZUTATEN
--- Slide 10 ---
Marzipanwölkchen
Zutaten:
400g Marzipan
2 Eiweiß
100g Puderzucker
140g gehobelte Mandeln
Marzipan hobeln.
Eiweiß mit Puderzucker und Marzipan in einer Schüssel mit dem Handrührgerät zu mixen, bis eine gleichmäßige Masse entsteht.
Hände befeuchten. Ca. Walnussgroße Kugeln formen. Kugeln in Mandeln wälzen.
Backen: 150°C Ober-/Unterhitze – 20 Minuten
--- Slide 11 ---
Erdnuss-Karamell-Makronen
Zubereitung:
Ofen auf 160°C Ober-/ Unterhitze vorheizen
Erdnüsse grob hacken
Eiweiß mit einer Prise Salz steif schlagen
Rohrzucker nach und nach einrieseln lassen
Erdnussbutter & Erdnüsse unterheben
2 TL auf die Oblaten setzen
20-25 Min. backen
Zutaten:
2 Eiweiß
100g Rohrzucker
50g Erdnussbutter crunchy
150g Erdnüsse (geröstet, gesalzen)
25 Oblaten
1 Prise Salz

Some files were not shown because too many files have changed in this diff Show More