Initial commit: What To Cook (WTC) v4.4 - Digitales Kochbuch, Wochenmenueplan & Buchdruck
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,754 @@
|
|||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Dict, Any, Optional
|
||||||
|
|
||||||
|
try:
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv()
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
import pymysql
|
||||||
|
import pymysql.cursors
|
||||||
|
except ImportError:
|
||||||
|
pymysql = None
|
||||||
|
|
||||||
|
DB_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "data", "wtc.db")
|
||||||
|
|
||||||
|
DB_ENGINE = os.getenv("DB_ENGINE", "mariadb").lower()
|
||||||
|
MARIADB_HOST = os.getenv("MARIADB_HOST", "db.waiswuaschd.de")
|
||||||
|
MARIADB_PORT = int(os.getenv("MARIADB_PORT", "3306"))
|
||||||
|
MARIADB_USER = os.getenv("MARIADB_USER", "v2_usr_wtc_cookbook")
|
||||||
|
MARIADB_PASS = os.getenv("MARIADB_PASS", "XKikHf&j6Wj!rs5%A8#ZzyvE#*A1hT")
|
||||||
|
MARIADB_NAME = os.getenv("MARIADB_NAME", "v2_db_wtc_cookbook")
|
||||||
|
|
||||||
|
LEGACY_CATEGORIES = [
|
||||||
|
"Reis",
|
||||||
|
"Nudeln",
|
||||||
|
"Kartoffeln",
|
||||||
|
"Teigig",
|
||||||
|
"asiatisch",
|
||||||
|
"Hausmannskost",
|
||||||
|
"Suppen",
|
||||||
|
"Eintöpfe",
|
||||||
|
"Süßspeisen",
|
||||||
|
"Fisch"
|
||||||
|
]
|
||||||
|
|
||||||
|
# 11 Kochbuch-Kapitel aus dem Benutzerentwurf mit handgezeichneten Doodles & Icons
|
||||||
|
CHAPTERS = [
|
||||||
|
{"number": 1, "title": "Suppen & Eintöpfe", "icon": "🍲", "doodle_hint": "Dampfende Suppenschale", "illustration_url": "/static/img/chapters/chapter_1.png"},
|
||||||
|
{"number": 2, "title": "Traditionell & Basics", "icon": "🥟", "doodle_hint": "Klassiker & Brotzeit", "illustration_url": "/static/img/chapters/chapter_2.png"},
|
||||||
|
{"number": 3, "title": "Pasta & Nudelgerichte", "icon": "🍝", "doodle_hint": "Spaghetti & Pasta", "illustration_url": "/static/img/chapters/chapter_3.png"},
|
||||||
|
{"number": 4, "title": "Kartoffeln", "icon": "🥔", "doodle_hint": "Kartoffelauflauf & Klöße", "illustration_url": "/static/img/chapters/chapter_4.png"},
|
||||||
|
{"number": 5, "title": "Reis & Getreide", "icon": "🍚", "doodle_hint": "Reisschale & Körner", "illustration_url": "/static/img/chapters/chapter_5.png"},
|
||||||
|
{"number": 6, "title": "Teig & Gebackenes", "icon": "🍕", "doodle_hint": "Pizza, Flammkuchen & Brot", "illustration_url": "/static/img/chapters/chapter_6.png"},
|
||||||
|
{"number": 7, "title": "Asiatische Küche", "icon": "🥢", "doodle_hint": "Wok, Ramen & Thai-Curry", "illustration_url": "/static/img/chapters/chapter_7.png"},
|
||||||
|
{"number": 8, "title": "Süßspeisen", "icon": "🥞", "doodle_hint": "Kaiserschmarrn & Knödel", "illustration_url": "/static/img/chapters/chapter_8.png"},
|
||||||
|
{"number": 9, "title": "Desserts", "icon": "🍰", "doodle_hint": "Tiramisu & Mousse", "illustration_url": "/static/img/chapters/chapter_9.png"},
|
||||||
|
{"number": 10, "title": "Kuchen & Gebäck", "icon": "🎂", "doodle_hint": "Torten & Feingebäck", "illustration_url": "/static/img/chapters/chapter_10.png"},
|
||||||
|
{"number": 11, "title": "Plätzchen", "icon": "🍪", "doodle_hint": "Weihnachtsplätzchen & Kekse", "illustration_url": "/static/img/chapters/chapter_11.png"}
|
||||||
|
]
|
||||||
|
|
||||||
|
CATEGORIES = [c["title"] for c in CHAPTERS] + [c for c in LEGACY_CATEGORIES if c not in [ch["title"] for ch in CHAPTERS]]
|
||||||
|
|
||||||
|
EFFORTS = ["gering", "mittel", "hoch"]
|
||||||
|
|
||||||
|
CHAPTER_MAP = {c["title"]: c["number"] for c in CHAPTERS}
|
||||||
|
for c in CHAPTERS:
|
||||||
|
CHAPTER_MAP[str(c["number"])] = c["number"]
|
||||||
|
|
||||||
|
|
||||||
|
def get_connection():
|
||||||
|
if DB_ENGINE == "mariadb":
|
||||||
|
if pymysql is None:
|
||||||
|
raise RuntimeError("PyMySQL ist nicht installiert, wird aber für MariaDB benötigt.")
|
||||||
|
return pymysql.connect(
|
||||||
|
host=MARIADB_HOST,
|
||||||
|
port=MARIADB_PORT,
|
||||||
|
user=MARIADB_USER,
|
||||||
|
password=MARIADB_PASS,
|
||||||
|
database=MARIADB_NAME,
|
||||||
|
charset="utf8mb4",
|
||||||
|
cursorclass=pymysql.cursors.DictCursor,
|
||||||
|
autocommit=True
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def execute_query(query: str, params: tuple = (), fetch: str = "all", commit: bool = False):
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
if DB_ENGINE == "mariadb":
|
||||||
|
# MariaDB uses %s instead of ?
|
||||||
|
maria_query = query.replace("?", "%s")
|
||||||
|
with conn.cursor() as cursor:
|
||||||
|
cursor.execute(maria_query, params)
|
||||||
|
last_id = cursor.lastrowid
|
||||||
|
rowcount = cursor.rowcount
|
||||||
|
if fetch == "all":
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
return rows
|
||||||
|
elif fetch == "one":
|
||||||
|
return cursor.fetchone()
|
||||||
|
else:
|
||||||
|
return {"lastrowid": last_id, "rowcount": rowcount}
|
||||||
|
else:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(query, params)
|
||||||
|
if commit:
|
||||||
|
conn.commit()
|
||||||
|
last_id = cursor.lastrowid
|
||||||
|
rowcount = cursor.rowcount
|
||||||
|
if fetch == "all":
|
||||||
|
rows = [dict(r) for r in cursor.fetchall()]
|
||||||
|
return rows
|
||||||
|
elif fetch == "one":
|
||||||
|
row = cursor.fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
else:
|
||||||
|
return {"lastrowid": last_id, "rowcount": rowcount}
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def init_db():
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
if DB_ENGINE == "mariadb":
|
||||||
|
with conn.cursor() as cursor:
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS dishes (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
category VARCHAR(100) NOT NULL,
|
||||||
|
effort VARCHAR(20) NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_dish_cat (category),
|
||||||
|
INDEX idx_dish_eff (effort)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
""")
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS recipes (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
category VARCHAR(100) NOT NULL,
|
||||||
|
chapter_number INT NOT NULL,
|
||||||
|
effort VARCHAR(20) NOT NULL DEFAULT 'mittel',
|
||||||
|
prep_time VARCHAR(50) DEFAULT '',
|
||||||
|
cook_time VARCHAR(50) DEFAULT '',
|
||||||
|
portions INT DEFAULT 4,
|
||||||
|
ingredients LONGTEXT NOT NULL,
|
||||||
|
instructions LONGTEXT NOT NULL,
|
||||||
|
notes TEXT,
|
||||||
|
source VARCHAR(255) DEFAULT '',
|
||||||
|
image_url VARCHAR(500) DEFAULT '',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_recipe_chapter (chapter_number),
|
||||||
|
INDEX idx_recipe_cat (category)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
""")
|
||||||
|
# Migration check for MariaDB
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = %s AND TABLE_NAME = 'recipes' AND COLUMN_NAME = 'source'
|
||||||
|
""", (MARIADB_NAME,))
|
||||||
|
if not cursor.fetchone():
|
||||||
|
cursor.execute("ALTER TABLE recipes ADD COLUMN source VARCHAR(255) DEFAULT ''")
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = %s AND TABLE_NAME = 'recipes' AND COLUMN_NAME = 'printed_at'
|
||||||
|
""", (MARIADB_NAME,))
|
||||||
|
if not cursor.fetchone():
|
||||||
|
cursor.execute("ALTER TABLE recipes ADD COLUMN printed_at DATETIME NULL DEFAULT NULL AFTER image_url")
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = %s AND TABLE_NAME = 'recipes' AND COLUMN_NAME = 'has_portion_table'
|
||||||
|
""", (MARIADB_NAME,))
|
||||||
|
if not cursor.fetchone():
|
||||||
|
cursor.execute("ALTER TABLE recipes ADD COLUMN has_portion_table TINYINT(1) NOT NULL DEFAULT 0 AFTER portions")
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS meal_plans (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
year INT NOT NULL,
|
||||||
|
week INT NOT NULL,
|
||||||
|
day_of_week INT NOT NULL,
|
||||||
|
meal_type VARCHAR(20) NOT NULL,
|
||||||
|
recipe_id INT NULL,
|
||||||
|
custom_text VARCHAR(255) NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uq_meal_slot (year, week, day_of_week, meal_type),
|
||||||
|
INDEX idx_plan_week (year, week)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
""")
|
||||||
|
|
||||||
|
cursor.execute("DELETE FROM dishes")
|
||||||
|
else:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS dishes (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||||
|
category TEXT NOT NULL,
|
||||||
|
effort TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS recipes (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
category TEXT NOT NULL,
|
||||||
|
chapter_number INTEGER NOT NULL,
|
||||||
|
effort TEXT NOT NULL DEFAULT 'mittel',
|
||||||
|
prep_time TEXT DEFAULT '',
|
||||||
|
cook_time TEXT DEFAULT '',
|
||||||
|
portions INTEGER DEFAULT 4,
|
||||||
|
has_portion_table INTEGER DEFAULT 0,
|
||||||
|
ingredients TEXT NOT NULL,
|
||||||
|
instructions TEXT NOT NULL,
|
||||||
|
notes TEXT DEFAULT '',
|
||||||
|
source TEXT DEFAULT '',
|
||||||
|
image_url TEXT DEFAULT '',
|
||||||
|
printed_at TIMESTAMP NULL DEFAULT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
# Migration check for SQLite
|
||||||
|
cursor.execute("PRAGMA table_info(recipes)")
|
||||||
|
existing_cols = [c[1] for c in cursor.fetchall()]
|
||||||
|
if "source" not in existing_cols:
|
||||||
|
cursor.execute("ALTER TABLE recipes ADD COLUMN source TEXT DEFAULT ''")
|
||||||
|
if "printed_at" not in existing_cols:
|
||||||
|
cursor.execute("ALTER TABLE recipes ADD COLUMN printed_at TIMESTAMP NULL DEFAULT NULL")
|
||||||
|
if "has_portion_table" not in existing_cols:
|
||||||
|
cursor.execute("ALTER TABLE recipes ADD COLUMN has_portion_table INTEGER DEFAULT 0")
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS meal_plans (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
year INTEGER NOT NULL,
|
||||||
|
week INTEGER NOT NULL,
|
||||||
|
day_of_week INTEGER NOT NULL,
|
||||||
|
meal_type TEXT NOT NULL,
|
||||||
|
recipe_id INTEGER NULL,
|
||||||
|
custom_text TEXT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE (year, week, day_of_week, meal_type)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
cursor.execute("DELETE FROM dishes")
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# --- Dishes (Zufallsgenerator) ---
|
||||||
|
|
||||||
|
def get_all_dishes(category: Optional[str] = None, effort: Optional[str] = None, search: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||||
|
query = "SELECT id, name, category, effort, created_at FROM dishes WHERE 1=1"
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if category and category.strip() and category != "alle":
|
||||||
|
query += " AND category = ?"
|
||||||
|
params.append(category.strip())
|
||||||
|
|
||||||
|
if effort and effort.strip() and effort != "alle":
|
||||||
|
query += " AND effort = ?"
|
||||||
|
params.append(effort.strip())
|
||||||
|
|
||||||
|
if search and search.strip():
|
||||||
|
query += " AND name LIKE ?"
|
||||||
|
params.append(f"%{search.strip()}%")
|
||||||
|
|
||||||
|
query += " ORDER BY category ASC, name ASC"
|
||||||
|
return execute_query(query, tuple(params), fetch="all")
|
||||||
|
|
||||||
|
|
||||||
|
def add_dish(name: str, category: str, effort: str) -> Dict[str, Any]:
|
||||||
|
name = name.strip()
|
||||||
|
category = category.strip()
|
||||||
|
effort = effort.strip()
|
||||||
|
|
||||||
|
if not name:
|
||||||
|
raise ValueError("Name darf nicht leer sein.")
|
||||||
|
if category not in CATEGORIES:
|
||||||
|
raise ValueError(f"Ungültige Kategorie '{category}'. Gültig sind: {', '.join(CATEGORIES)}")
|
||||||
|
if effort not in EFFORTS:
|
||||||
|
raise ValueError(f"Ungültiger Aufwand '{effort}'. Gültig sind: {', '.join(EFFORTS)}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
res = execute_query(
|
||||||
|
"INSERT INTO dishes (name, category, effort) VALUES (?, ?, ?)",
|
||||||
|
(name, category, effort),
|
||||||
|
fetch="none",
|
||||||
|
commit=True
|
||||||
|
)
|
||||||
|
dish_id = res["lastrowid"]
|
||||||
|
return execute_query("SELECT id, name, category, effort, created_at FROM dishes WHERE id = ?", (dish_id,), fetch="one")
|
||||||
|
except Exception as e:
|
||||||
|
if "UNIQUE" in str(e).upper() or "DUPLICATE" in str(e).upper():
|
||||||
|
raise ValueError(f"Ein Gericht mit dem Namen '{name}' existiert bereits.")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def update_dish(dish_id: int, name: str, category: str, effort: str) -> Dict[str, Any]:
|
||||||
|
name = name.strip()
|
||||||
|
category = category.strip()
|
||||||
|
effort = effort.strip()
|
||||||
|
|
||||||
|
if not name:
|
||||||
|
raise ValueError("Name darf nicht leer sein.")
|
||||||
|
if category not in CATEGORIES:
|
||||||
|
raise ValueError(f"Ungültige Kategorie '{category}'.")
|
||||||
|
if effort not in EFFORTS:
|
||||||
|
raise ValueError(f"Ungültiger Aufwand '{effort}'.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
res = execute_query(
|
||||||
|
"UPDATE dishes SET name = ?, category = ?, effort = ? WHERE id = ?",
|
||||||
|
(name, category, effort, dish_id),
|
||||||
|
fetch="none",
|
||||||
|
commit=True
|
||||||
|
)
|
||||||
|
if res["rowcount"] == 0:
|
||||||
|
raise KeyError(f"Gericht #{dish_id} nicht gefunden.")
|
||||||
|
return execute_query("SELECT id, name, category, effort, created_at FROM dishes WHERE id = ?", (dish_id,), fetch="one")
|
||||||
|
except Exception as e:
|
||||||
|
if "UNIQUE" in str(e).upper() or "DUPLICATE" in str(e).upper():
|
||||||
|
raise ValueError(f"Ein Gericht mit dem Namen '{name}' existiert bereits.")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def delete_dish(dish_id: int) -> bool:
|
||||||
|
res = execute_query("DELETE FROM dishes WHERE id = ?", (dish_id,), fetch="none", commit=True)
|
||||||
|
return res["rowcount"] > 0
|
||||||
|
|
||||||
|
|
||||||
|
def clear_all_dishes():
|
||||||
|
execute_query("DELETE FROM dishes", fetch="none", commit=True)
|
||||||
|
if DB_ENGINE == "sqlite":
|
||||||
|
try:
|
||||||
|
execute_query("DELETE FROM sqlite_sequence WHERE name = 'dishes'", fetch="none", commit=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def get_stats() -> Dict[str, Any]:
|
||||||
|
total_res = execute_query("SELECT COUNT(*) as total FROM dishes", fetch="one")
|
||||||
|
total = total_res["total"] if total_res else 0
|
||||||
|
|
||||||
|
cat_rows = execute_query("SELECT category, COUNT(*) as cnt FROM dishes GROUP BY category", fetch="all")
|
||||||
|
by_category = {row["category"]: row["cnt"] for row in cat_rows}
|
||||||
|
|
||||||
|
eff_rows = execute_query("SELECT effort, COUNT(*) as cnt FROM dishes GROUP BY effort", fetch="all")
|
||||||
|
by_effort = {row["effort"]: row["cnt"] for row in eff_rows}
|
||||||
|
|
||||||
|
recipe_count_res = execute_query("SELECT COUNT(*) as total FROM recipes", fetch="one")
|
||||||
|
total_recipes = recipe_count_res["total"] if recipe_count_res else 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total": total,
|
||||||
|
"by_category": by_category,
|
||||||
|
"by_effort": by_effort,
|
||||||
|
"total_recipes": total_recipes
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_category_terms(cat_input: Optional[str]) -> List[str]:
|
||||||
|
if not cat_input or str(cat_input).strip().lower() in ["", "alle", "beliebig", "none"]:
|
||||||
|
return []
|
||||||
|
s = str(cat_input).strip()
|
||||||
|
chap_num = None
|
||||||
|
if s.isdigit():
|
||||||
|
chap_num = int(s)
|
||||||
|
else:
|
||||||
|
for c in CHAPTERS:
|
||||||
|
if s.lower() == c["title"].lower() or s.lower() in c["title"].lower():
|
||||||
|
chap_num = c["number"]
|
||||||
|
break
|
||||||
|
|
||||||
|
chapter_terms = {
|
||||||
|
1: ["Suppen", "Eintöpfe", "Suppe", "Eintopf", "Suppen & Eintöpfe", "Chili", "Gulasch"],
|
||||||
|
2: ["Traditionell & Basics", "Hausmannskost", "Traditionell", "Basics", "Fisch", "Brotzeit", "Klassiker", "Schnitzel", "Rouladen", "Klopse", "Forelle", "Lachs"],
|
||||||
|
3: ["Pasta & Nudelgerichte", "Nudeln", "Pasta", "Spaghetti", "Penne", "Tagliatelle", "Carbonara", "Lasagne", "Spätzle"],
|
||||||
|
4: ["Kartoffeln", "Kartoffel", "Gratin", "Pommes", "Püree"],
|
||||||
|
5: ["Reis & Getreide", "Reis", "Getreide", "Risotto", "Paella"],
|
||||||
|
6: ["Teig & Gebackenes", "Teigig", "Teig", "Pizza", "Flammkuchen", "Quiche", "Calzone"],
|
||||||
|
7: ["Asiatische Küche", "asiatisch", "Asiatisch", "Curry", "Pad Thai", "Bami", "Wok", "Ramen", "Sushi"],
|
||||||
|
8: ["Süßspeisen", "Süßspeise", "Kaiserschmarrn", "Germknödel", "Dampfnudel", "Apfelküchle", "Milchreis"],
|
||||||
|
9: ["Desserts", "Dessert", "Tiramisu", "Mousse", "Pudding"],
|
||||||
|
10: ["Kuchen & Gebäck", "Kuchen", "Gebäck", "Torte"],
|
||||||
|
11: ["Plätzchen", "Kekse", "Keks"]
|
||||||
|
}
|
||||||
|
|
||||||
|
if chap_num and chap_num in chapter_terms:
|
||||||
|
return chapter_terms[chap_num]
|
||||||
|
|
||||||
|
# Legacy category names mapping
|
||||||
|
legacy_map = {
|
||||||
|
"nudeln": chapter_terms[3],
|
||||||
|
"pasta": chapter_terms[3],
|
||||||
|
"reis": chapter_terms[5],
|
||||||
|
"kartoffeln": chapter_terms[4],
|
||||||
|
"teigig": chapter_terms[6],
|
||||||
|
"suppen": chapter_terms[1],
|
||||||
|
"eintöpfe": chapter_terms[1],
|
||||||
|
"eintoepfe": chapter_terms[1],
|
||||||
|
"hausmannskost": chapter_terms[2],
|
||||||
|
"asiatisch": chapter_terms[7],
|
||||||
|
"süßspeisen": chapter_terms[8],
|
||||||
|
"suessspeisen": chapter_terms[8],
|
||||||
|
"fisch": ["Fisch", "Forelle", "Lachs", "Kabeljau", "Hausmannskost"]
|
||||||
|
}
|
||||||
|
if s.lower() in legacy_map:
|
||||||
|
return legacy_map[s.lower()]
|
||||||
|
|
||||||
|
return [s]
|
||||||
|
|
||||||
|
|
||||||
|
def get_chapter_info_for_dish(dish_category: str = "", dish_name: str = "", recipe_chapter: Optional[int] = None) -> Dict[str, Any]:
|
||||||
|
if recipe_chapter and 1 <= recipe_chapter <= len(CHAPTERS):
|
||||||
|
return CHAPTERS[recipe_chapter - 1]
|
||||||
|
|
||||||
|
combo = f"{dish_category or ''} {dish_name or ''}".lower()
|
||||||
|
|
||||||
|
if any(k in combo for k in ["suppe", "eintopf", "chili", "brühe"]):
|
||||||
|
return CHAPTERS[0] # 1: Suppen & Eintöpfe
|
||||||
|
if any(k in combo for k in ["nudel", "pasta", "spaghetti", "penne", "tagliatelle", "carbonara", "bolognese", "spätzle", "lasagne"]):
|
||||||
|
return CHAPTERS[2] # 3: Pasta & Nudelgerichte
|
||||||
|
if any(k in combo for k in ["kartoffel", "pommes", "gratin", "püree"]):
|
||||||
|
return CHAPTERS[3] # 4: Kartoffeln
|
||||||
|
if any(k in combo for k in ["curry", "pad thai", "bami", "wok", "ramen", "sushi", "asiatisch"]):
|
||||||
|
return CHAPTERS[6] # 7: Asiatische Küche
|
||||||
|
if any(k in combo for k in ["reis", "risotto", "paella", "djuvec"]):
|
||||||
|
return CHAPTERS[4] # 5: Reis & Getreide
|
||||||
|
if any(k in combo for k in ["pizza", "flammkuchen", "quiche", "calzone", "teig", "brot"]):
|
||||||
|
return CHAPTERS[5] # 6: Teig & Gebackenes
|
||||||
|
if any(k in combo for k in ["kuchen", "torte", "muffin", "gebäck"]):
|
||||||
|
return CHAPTERS[9] # 10: Kuchen & Gebäck
|
||||||
|
if any(k in combo for k in ["keks", "plätzchen"]):
|
||||||
|
return CHAPTERS[10] # 11: Plätzchen
|
||||||
|
if any(k in combo for k in ["tiramisu", "mousse", "pudding", "dessert"]):
|
||||||
|
return CHAPTERS[8] # 9: Desserts
|
||||||
|
if any(k in combo for k in ["kaiserschmarrn", "knödel", "apfelküchle", "dampfnudel", "milchreis", "süßspeis"]):
|
||||||
|
return CHAPTERS[7] # 8: Süßspeisen
|
||||||
|
|
||||||
|
return CHAPTERS[1] # 2: Traditionell & Basics
|
||||||
|
|
||||||
|
|
||||||
|
def query_recipe_candidates(chapter_number: Optional[int] = None, efforts: Optional[List[str]] = None) -> List[Dict[str, Any]]:
|
||||||
|
"""Gibt alle Rezepte aus der recipes-Tabelle zurück, die zu den Shuffle-Kriterien passen."""
|
||||||
|
all_recipes = get_all_recipes()
|
||||||
|
if not all_recipes:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# 1. Nach Kapitel filtern (wenn spezifiziert)
|
||||||
|
if chapter_number is not None and chapter_number > 0:
|
||||||
|
pool = [r for r in all_recipes if r.get("chapter_number") == chapter_number]
|
||||||
|
else:
|
||||||
|
pool = list(all_recipes)
|
||||||
|
|
||||||
|
if not pool:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# 2. Nach Aufwand filtern (nur, wenn eine Teilmenge gewählt wurde)
|
||||||
|
if efforts and len(efforts) > 0 and len(efforts) < len(EFFORTS):
|
||||||
|
clean_efforts = [e.strip().lower() for e in efforts if e.strip() in EFFORTS]
|
||||||
|
effort_pool = [r for r in pool if (r.get("effort") or "").lower() in clean_efforts]
|
||||||
|
if effort_pool:
|
||||||
|
return effort_pool
|
||||||
|
|
||||||
|
return pool
|
||||||
|
|
||||||
|
|
||||||
|
def query_candidates(category: Optional[str], efforts: Optional[List[str]]) -> List[Dict[str, Any]]:
|
||||||
|
"""Fallback-Funktion für alte Dish-Abfragen"""
|
||||||
|
return query_recipe_candidates()
|
||||||
|
|
||||||
|
|
||||||
|
# --- Recipes (Kochbuch) ---
|
||||||
|
|
||||||
|
def format_printed_date(p_at: Any) -> str:
|
||||||
|
if not p_at:
|
||||||
|
return ""
|
||||||
|
if hasattr(p_at, "strftime"):
|
||||||
|
return p_at.strftime("%d.%m.%Y")
|
||||||
|
try:
|
||||||
|
parts = str(p_at).split(" ")[0].split("-")
|
||||||
|
if len(parts) == 3:
|
||||||
|
return f"{parts[2]}.{parts[1]}.{parts[0]}"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return str(p_at)
|
||||||
|
|
||||||
|
|
||||||
|
def add_recipe(
|
||||||
|
title: str,
|
||||||
|
category: str,
|
||||||
|
chapter_number: int,
|
||||||
|
effort: str = "mittel",
|
||||||
|
prep_time: str = "",
|
||||||
|
cook_time: str = "",
|
||||||
|
portions: int = 4,
|
||||||
|
ingredients: str = "",
|
||||||
|
instructions: str = "",
|
||||||
|
notes: str = "",
|
||||||
|
source: str = "",
|
||||||
|
image_url: str = "",
|
||||||
|
printed_at: Optional[str] = None,
|
||||||
|
has_portion_table: int = 0
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
title = title.strip()
|
||||||
|
category = category.strip()
|
||||||
|
effort = effort.strip().lower()
|
||||||
|
source = source.strip()
|
||||||
|
|
||||||
|
if not title:
|
||||||
|
raise ValueError("Rezepttitel darf nicht leer sein.")
|
||||||
|
if not category:
|
||||||
|
raise ValueError("Kategorie darf nicht leer sein.")
|
||||||
|
if chapter_number not in [c["number"] for c in CHAPTERS]:
|
||||||
|
# Fallback to mapped chapter number if possible
|
||||||
|
chapter_number = CHAPTER_MAP.get(category, 1)
|
||||||
|
|
||||||
|
res = execute_query(
|
||||||
|
"""
|
||||||
|
INSERT INTO recipes (title, category, chapter_number, effort, prep_time, cook_time, portions, has_portion_table, ingredients, instructions, notes, source, image_url, printed_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(title, category, chapter_number, effort, prep_time, cook_time, portions, int(bool(has_portion_table)), ingredients, instructions, notes, source, image_url, printed_at),
|
||||||
|
fetch="none",
|
||||||
|
commit=True
|
||||||
|
)
|
||||||
|
recipe_id = res["lastrowid"]
|
||||||
|
return get_recipe(recipe_id)
|
||||||
|
|
||||||
|
|
||||||
|
def update_recipe(
|
||||||
|
recipe_id: int,
|
||||||
|
title: str,
|
||||||
|
category: str,
|
||||||
|
chapter_number: int,
|
||||||
|
effort: str = "mittel",
|
||||||
|
prep_time: str = "",
|
||||||
|
cook_time: str = "",
|
||||||
|
portions: int = 4,
|
||||||
|
ingredients: str = "",
|
||||||
|
instructions: str = "",
|
||||||
|
notes: str = "",
|
||||||
|
source: str = "",
|
||||||
|
image_url: Optional[str] = None,
|
||||||
|
printed_at: Optional[str] = None,
|
||||||
|
has_portion_table: Optional[int] = None
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
title = title.strip()
|
||||||
|
category = category.strip()
|
||||||
|
effort = effort.strip().lower()
|
||||||
|
source = source.strip()
|
||||||
|
|
||||||
|
if not title:
|
||||||
|
raise ValueError("Rezepttitel darf nicht leer sein.")
|
||||||
|
|
||||||
|
existing = get_recipe(recipe_id)
|
||||||
|
if not existing:
|
||||||
|
raise KeyError(f"Rezept #{recipe_id} nicht gefunden.")
|
||||||
|
|
||||||
|
use_portion_table = int(bool(has_portion_table)) if has_portion_table is not None else existing.get("has_portion_table", 0)
|
||||||
|
|
||||||
|
if image_url is not None:
|
||||||
|
query = """
|
||||||
|
UPDATE recipes
|
||||||
|
SET title = ?, category = ?, chapter_number = ?, effort = ?, prep_time = ?, cook_time = ?, portions = ?, has_portion_table = ?, ingredients = ?, instructions = ?, notes = ?, source = ?, image_url = ?, printed_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
"""
|
||||||
|
params = (title, category, chapter_number, effort, prep_time, cook_time, portions, use_portion_table, ingredients, instructions, notes, source, image_url, printed_at, recipe_id)
|
||||||
|
else:
|
||||||
|
query = """
|
||||||
|
UPDATE recipes
|
||||||
|
SET title = ?, category = ?, chapter_number = ?, effort = ?, prep_time = ?, cook_time = ?, portions = ?, has_portion_table = ?, ingredients = ?, instructions = ?, notes = ?, source = ?, printed_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
"""
|
||||||
|
params = (title, category, chapter_number, effort, prep_time, cook_time, portions, use_portion_table, ingredients, instructions, notes, source, printed_at, recipe_id)
|
||||||
|
|
||||||
|
res = execute_query(query, params, fetch="none", commit=True)
|
||||||
|
return get_recipe(recipe_id)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_recipe(recipe_id: int) -> bool:
|
||||||
|
res = execute_query("DELETE FROM recipes WHERE id = ?", (recipe_id,), fetch="none", commit=True)
|
||||||
|
return res["rowcount"] > 0
|
||||||
|
|
||||||
|
|
||||||
|
def get_recipe(recipe_id: int) -> Optional[Dict[str, Any]]:
|
||||||
|
row = execute_query("SELECT * FROM recipes WHERE id = ?", (recipe_id,), fetch="one")
|
||||||
|
if row:
|
||||||
|
row["printed_at_formatted"] = format_printed_date(row.get("printed_at"))
|
||||||
|
row["has_portion_table"] = bool(row.get("has_portion_table", 0))
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_recipes(
|
||||||
|
chapter_number: Optional[int] = None,
|
||||||
|
category: Optional[str] = None,
|
||||||
|
search: Optional[str] = None,
|
||||||
|
printed_status: Optional[str] = None
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
query = "SELECT * FROM recipes WHERE 1=1"
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if chapter_number is not None and chapter_number > 0:
|
||||||
|
query += " AND chapter_number = ?"
|
||||||
|
params.append(chapter_number)
|
||||||
|
|
||||||
|
if category and category.strip() and category != "alle":
|
||||||
|
query += " AND category = ?"
|
||||||
|
params.append(category.strip())
|
||||||
|
|
||||||
|
if search and search.strip():
|
||||||
|
query += " AND (title LIKE ? OR ingredients LIKE ? OR instructions LIKE ?)"
|
||||||
|
s = f"%{search.strip()}%"
|
||||||
|
params.extend([s, s, s])
|
||||||
|
|
||||||
|
if printed_status == "unprinted":
|
||||||
|
query += " AND printed_at IS NULL"
|
||||||
|
elif printed_status == "printed":
|
||||||
|
query += " AND printed_at IS NOT NULL"
|
||||||
|
|
||||||
|
query += " ORDER BY chapter_number ASC, title ASC"
|
||||||
|
rows = execute_query(query, tuple(params), fetch="all")
|
||||||
|
for r in rows:
|
||||||
|
r["printed_at_formatted"] = format_printed_date(r.get("printed_at"))
|
||||||
|
r["has_portion_table"] = bool(r.get("has_portion_table", 0))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def mark_recipes_printed(recipe_ids: List[int], print_date: Optional[str] = None) -> int:
|
||||||
|
"""Markiert die angegebenen Rezepte als gedruckt."""
|
||||||
|
if not recipe_ids:
|
||||||
|
return 0
|
||||||
|
date_val = print_date or datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
placeholders = ", ".join(["?"] * len(recipe_ids))
|
||||||
|
query = f"UPDATE recipes SET printed_at = ? WHERE id IN ({placeholders})"
|
||||||
|
params = [date_val] + list(recipe_ids)
|
||||||
|
res = execute_query(query, tuple(params), fetch="none", commit=True)
|
||||||
|
return res.get("rowcount", 0)
|
||||||
|
|
||||||
|
|
||||||
|
def unmark_recipes_printed(recipe_ids: List[int]) -> int:
|
||||||
|
"""Entfernt den Gedruckt-Status von den angegebenen Rezepten."""
|
||||||
|
if not recipe_ids:
|
||||||
|
return 0
|
||||||
|
placeholders = ", ".join(["?"] * len(recipe_ids))
|
||||||
|
query = f"UPDATE recipes SET printed_at = NULL WHERE id IN ({placeholders})"
|
||||||
|
res = execute_query(query, tuple(recipe_ids), fetch="none", commit=True)
|
||||||
|
return res.get("rowcount", 0)
|
||||||
|
|
||||||
|
|
||||||
|
def toggle_recipe_printed(recipe_id: int) -> Dict[str, Any]:
|
||||||
|
"""Schaltet den Druckstatus eines Rezepts um."""
|
||||||
|
rec = get_recipe(recipe_id)
|
||||||
|
if not rec:
|
||||||
|
raise KeyError(f"Rezept #{recipe_id} nicht gefunden.")
|
||||||
|
if rec.get("printed_at"):
|
||||||
|
unmark_recipes_printed([recipe_id])
|
||||||
|
else:
|
||||||
|
mark_recipes_printed([recipe_id])
|
||||||
|
return get_recipe(recipe_id)
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# WOCHENPLAN (MEAL PLANNER)
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
def get_meal_plan(year: int, week: int) -> List[Dict[str, Any]]:
|
||||||
|
"""Gibt alle Einträge des Wochenplans für die angegebene KW zurück."""
|
||||||
|
query = """
|
||||||
|
SELECT
|
||||||
|
m.id, m.year, m.week, m.day_of_week, m.meal_type, m.recipe_id, m.custom_text,
|
||||||
|
r.title AS recipe_title, r.image_url AS recipe_image_url, r.chapter_number AS recipe_chapter_number,
|
||||||
|
r.category AS recipe_category
|
||||||
|
FROM meal_plans m
|
||||||
|
LEFT JOIN recipes r ON m.recipe_id = r.id
|
||||||
|
WHERE m.year = ? AND m.week = ?
|
||||||
|
ORDER BY m.day_of_week ASC,
|
||||||
|
CASE m.meal_type
|
||||||
|
WHEN 'frueh' THEN 1
|
||||||
|
WHEN 'mittag' THEN 2
|
||||||
|
WHEN 'kaffee' THEN 3
|
||||||
|
WHEN 'abend' THEN 4
|
||||||
|
ELSE 5
|
||||||
|
END ASC
|
||||||
|
"""
|
||||||
|
return execute_query(query, (year, week), fetch="all")
|
||||||
|
|
||||||
|
|
||||||
|
def set_meal_plan_slot(
|
||||||
|
year: int,
|
||||||
|
week: int,
|
||||||
|
day_of_week: int,
|
||||||
|
meal_type: str,
|
||||||
|
recipe_id: Optional[int] = None,
|
||||||
|
custom_text: Optional[str] = None
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Setzt oder aktualisiert einen Slot im Wochenplan."""
|
||||||
|
if DB_ENGINE == "mariadb":
|
||||||
|
query = """
|
||||||
|
INSERT INTO meal_plans (year, week, day_of_week, meal_type, recipe_id, custom_text)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
recipe_id = VALUES(recipe_id),
|
||||||
|
custom_text = VALUES(custom_text),
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
"""
|
||||||
|
execute_query(query, (year, week, day_of_week, meal_type, recipe_id, custom_text), fetch="none", commit=True)
|
||||||
|
else:
|
||||||
|
query = """
|
||||||
|
INSERT INTO meal_plans (year, week, day_of_week, meal_type, recipe_id, custom_text, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT(year, week, day_of_week, meal_type) DO UPDATE SET
|
||||||
|
recipe_id = excluded.recipe_id,
|
||||||
|
custom_text = excluded.custom_text,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
"""
|
||||||
|
execute_query(query, (year, week, day_of_week, meal_type, recipe_id, custom_text), fetch="none", commit=True)
|
||||||
|
|
||||||
|
slot_query = """
|
||||||
|
SELECT
|
||||||
|
m.id, m.year, m.week, m.day_of_week, m.meal_type, m.recipe_id, m.custom_text,
|
||||||
|
r.title AS recipe_title, r.image_url AS recipe_image_url
|
||||||
|
FROM meal_plans m
|
||||||
|
LEFT JOIN recipes r ON m.recipe_id = r.id
|
||||||
|
WHERE m.year = ? AND m.week = ? AND m.day_of_week = ? AND m.meal_type = ?
|
||||||
|
"""
|
||||||
|
return execute_query(slot_query, (year, week, day_of_week, meal_type), fetch="one")
|
||||||
|
|
||||||
|
|
||||||
|
def delete_meal_plan_slot(year: int, week: int, day_of_week: int, meal_type: str) -> bool:
|
||||||
|
"""Löscht einen einzelnen Slot aus dem Wochenplan."""
|
||||||
|
query = "DELETE FROM meal_plans WHERE year = ? AND week = ? AND day_of_week = ? AND meal_type = ?"
|
||||||
|
res = execute_query(query, (year, week, day_of_week, meal_type), fetch="none", commit=True)
|
||||||
|
return res.get("rowcount", 0) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def clear_meal_plan_week(year: int, week: int) -> int:
|
||||||
|
"""Leert den gesamten Wochenplan für eine Kalenderwoche."""
|
||||||
|
query = "DELETE FROM meal_plans WHERE year = ? AND week = ?"
|
||||||
|
res = execute_query(query, (year, week), fetch="none", commit=True)
|
||||||
|
return res.get("rowcount", 0)
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Helper module for ingredient parsing, table structuring, and quantity scaling.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
from typing import List, Dict, Any, Optional
|
||||||
|
|
||||||
|
COMMON_UNITS = [
|
||||||
|
"kg", "g", "mg", "l", "liter", "ml", "cl", "el", "tl", "esslöffel", "teelöffel",
|
||||||
|
"pck.", "pck", "päckchen", "pkt.", "pkt", "dose", "dosen", "glas", "gläser",
|
||||||
|
"prise", "prisen", "msp.", "msp", "messerspitze", "bund", "stk.", "stk",
|
||||||
|
"stück", "stücke", "zehe", "zehen", "zweig", "zweige", "blatt", "blätter",
|
||||||
|
"scheibe", "scheiben", "stange", "stangen", "kopf", "nest", "nester", "tropfen", "tr."
|
||||||
|
]
|
||||||
|
|
||||||
|
def parse_fraction(val_str: str) -> Optional[float]:
|
||||||
|
val_str = val_str.strip()
|
||||||
|
if "/" in val_str:
|
||||||
|
parts = val_str.split("/")
|
||||||
|
if len(parts) == 2:
|
||||||
|
try:
|
||||||
|
return float(parts[0]) / float(parts[1])
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
return float(val_str.replace(",", "."))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def parse_ingredient_line(line: str) -> Dict[str, Any]:
|
||||||
|
raw = line.strip()
|
||||||
|
if not raw:
|
||||||
|
return {"type": "empty", "raw": ""}
|
||||||
|
|
||||||
|
# Check if section header (ends with colon or keywords)
|
||||||
|
lower = raw.lower()
|
||||||
|
if raw.endswith(":") or lower.startswith("für ") or lower in [
|
||||||
|
"teig", "sauce", "soße", "füllung", "belag", "creme", "glasur", "marinade", "deko",
|
||||||
|
"schichten & deko", "keksschicht", "heidelbeersoße", "zitronen-zimt-sirup", "zutaten",
|
||||||
|
"einlage & toppings", "gemüse & toppings", "lemon curd", "mascarponecreme"
|
||||||
|
]:
|
||||||
|
hdr_text = raw.rstrip(":")
|
||||||
|
return {
|
||||||
|
"type": "header",
|
||||||
|
"is_header": True,
|
||||||
|
"text": hdr_text,
|
||||||
|
"name": hdr_text,
|
||||||
|
"raw": raw
|
||||||
|
}
|
||||||
|
|
||||||
|
# Regex to match leading amount
|
||||||
|
match = re.match(r"^(\d+\s+\d+/\d+|\d+/\d+|\d+(?:[.,]\d+)?)(?:\s*-\s*(\d+(?:[.,]\d+)?))?\s*(.*)$", raw)
|
||||||
|
if match:
|
||||||
|
amt_str = match.group(1).strip()
|
||||||
|
range_end = match.group(2)
|
||||||
|
rest = match.group(3).strip()
|
||||||
|
|
||||||
|
if " " in amt_str and "/" in amt_str:
|
||||||
|
whole, frac = amt_str.split()
|
||||||
|
val = float(whole) + (parse_fraction(frac) or 0.0)
|
||||||
|
elif "/" in amt_str:
|
||||||
|
val = parse_fraction(amt_str) or 0.0
|
||||||
|
else:
|
||||||
|
val = parse_fraction(amt_str) or 0.0
|
||||||
|
|
||||||
|
display_amt = f"{amt_str}-{range_end}" if range_end else amt_str
|
||||||
|
|
||||||
|
rest_parts = rest.split(None, 1)
|
||||||
|
if rest_parts:
|
||||||
|
first_word_clean = rest_parts[0].lower().rstrip(".,")
|
||||||
|
if first_word_clean in COMMON_UNITS or rest_parts[0].lower() in COMMON_UNITS:
|
||||||
|
unit = rest_parts[0]
|
||||||
|
ing_name = rest_parts[1] if len(rest_parts) > 1 else ""
|
||||||
|
return {
|
||||||
|
"type": "ingredient",
|
||||||
|
"is_header": False,
|
||||||
|
"amount_val": val,
|
||||||
|
"amount": val,
|
||||||
|
"amount_str": display_amt,
|
||||||
|
"amount_formatted": display_amt,
|
||||||
|
"unit": unit,
|
||||||
|
"name": ing_name,
|
||||||
|
"raw": raw
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"type": "ingredient",
|
||||||
|
"is_header": False,
|
||||||
|
"amount_val": val,
|
||||||
|
"amount": val,
|
||||||
|
"amount_str": display_amt,
|
||||||
|
"amount_formatted": display_amt,
|
||||||
|
"unit": "",
|
||||||
|
"name": rest,
|
||||||
|
"raw": raw
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"type": "ingredient",
|
||||||
|
"is_header": False,
|
||||||
|
"amount_val": None,
|
||||||
|
"amount": None,
|
||||||
|
"amount_str": "",
|
||||||
|
"amount_formatted": "",
|
||||||
|
"unit": "",
|
||||||
|
"name": raw,
|
||||||
|
"raw": raw
|
||||||
|
}
|
||||||
|
|
||||||
|
def parse_ingredients_block(text: str) -> List[Dict[str, Any]]:
|
||||||
|
lines = [l.strip() for l in (text or "").split("\n") if l.strip()]
|
||||||
|
return [parse_ingredient_line(l) for l in lines]
|
||||||
|
|
||||||
|
|
||||||
|
def format_quantity_py(num: Optional[float]) -> str:
|
||||||
|
"""Formatiert eine Zahl lesbar für Rezepte (z. B. 0.5 -> ½, 1.5 -> 1 ½, 2.0 -> 2)."""
|
||||||
|
if num is None or num <= 0:
|
||||||
|
return ""
|
||||||
|
whole = int(num)
|
||||||
|
frac = round((num - whole) * 100) / 100.0
|
||||||
|
|
||||||
|
frac_str = ""
|
||||||
|
if 0.20 <= frac <= 0.30:
|
||||||
|
frac_str = "¼"
|
||||||
|
elif 0.31 <= frac <= 0.40:
|
||||||
|
frac_str = "⅓"
|
||||||
|
elif 0.45 <= frac <= 0.55:
|
||||||
|
frac_str = "½"
|
||||||
|
elif 0.63 <= frac <= 0.70:
|
||||||
|
frac_str = "⅔"
|
||||||
|
elif 0.72 <= frac <= 0.80:
|
||||||
|
frac_str = "¾"
|
||||||
|
|
||||||
|
if frac_str:
|
||||||
|
return f"{whole} {frac_str}".strip() if whole > 0 else frac_str
|
||||||
|
|
||||||
|
if abs(num - round(num)) < 0.05:
|
||||||
|
return str(int(round(num)))
|
||||||
|
|
||||||
|
# Deutsche Kommaschreibweise
|
||||||
|
val_str = f"{num:.1f}".rstrip("0").rstrip(".").replace(".", ",")
|
||||||
|
return val_str
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_portion_matrix(
|
||||||
|
parsed_ingredients: List[Dict[str, Any]],
|
||||||
|
base_portions: int = 4,
|
||||||
|
targets: tuple = (2, 4, 6, 8)
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Berechnet für Waffeln / Pancakes etc. eine vorberechnete Tabelle für 2, 4, 6, 8 Portionen."""
|
||||||
|
if not base_portions or base_portions <= 0:
|
||||||
|
base_portions = 4
|
||||||
|
|
||||||
|
matrix = []
|
||||||
|
for item in parsed_ingredients:
|
||||||
|
if item.get("is_header"):
|
||||||
|
matrix.append({
|
||||||
|
"is_header": True,
|
||||||
|
"text": item.get("text") or item.get("name") or "",
|
||||||
|
"amounts": {p: "" for p in targets}
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
row_amounts = {}
|
||||||
|
for p in targets:
|
||||||
|
amt_val = item.get("amount_val")
|
||||||
|
unit = item.get("unit") or ""
|
||||||
|
if amt_val is not None:
|
||||||
|
scaled_val = amt_val * (p / float(base_portions))
|
||||||
|
formatted_amt = format_quantity_py(scaled_val)
|
||||||
|
row_amounts[p] = f"{formatted_amt} {unit}".strip()
|
||||||
|
else:
|
||||||
|
row_amounts[p] = "—"
|
||||||
|
|
||||||
|
matrix.append({
|
||||||
|
"is_header": False,
|
||||||
|
"name": item.get("name") or item.get("raw") or "",
|
||||||
|
"raw": item.get("raw", ""),
|
||||||
|
"amounts": row_amounts
|
||||||
|
})
|
||||||
|
|
||||||
|
return matrix
|
||||||
|
|
||||||
@@ -0,0 +1,856 @@
|
|||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
import random
|
||||||
|
import shutil
|
||||||
|
import time
|
||||||
|
import hmac
|
||||||
|
import hashlib
|
||||||
|
from typing import List, Optional, Dict, Any
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from fastapi import FastAPI, HTTPException, Request, Query, Form, File, UploadFile
|
||||||
|
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from datetime import datetime, date, timedelta
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from .ingredients import parse_ingredients_block, calculate_portion_matrix
|
||||||
|
from .database import (
|
||||||
|
init_db,
|
||||||
|
get_all_dishes,
|
||||||
|
add_dish,
|
||||||
|
update_dish,
|
||||||
|
delete_dish,
|
||||||
|
get_stats,
|
||||||
|
query_candidates,
|
||||||
|
query_recipe_candidates,
|
||||||
|
CATEGORIES,
|
||||||
|
EFFORTS,
|
||||||
|
CHAPTERS,
|
||||||
|
CHAPTER_MAP,
|
||||||
|
get_all_recipes,
|
||||||
|
get_recipe,
|
||||||
|
add_recipe,
|
||||||
|
update_recipe,
|
||||||
|
delete_recipe,
|
||||||
|
get_chapter_info_for_dish,
|
||||||
|
mark_recipes_printed,
|
||||||
|
unmark_recipes_printed,
|
||||||
|
toggle_recipe_printed,
|
||||||
|
get_meal_plan,
|
||||||
|
set_meal_plan_slot,
|
||||||
|
delete_meal_plan_slot,
|
||||||
|
clear_meal_plan_week
|
||||||
|
)
|
||||||
|
|
||||||
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
STATIC_DIR = os.path.join(BASE_DIR, "static")
|
||||||
|
TEMPLATES_DIR = os.path.join(BASE_DIR, "templates")
|
||||||
|
|
||||||
|
# --- Authentifizierung & Privater Modus ---
|
||||||
|
APP_USERNAME = os.getenv("APP_USERNAME", "admin")
|
||||||
|
APP_PASSWORD = os.getenv("APP_PASSWORD", "wtc2026!")
|
||||||
|
SECRET_KEY = os.getenv("SECRET_KEY", "wtc_cookbook_secret_salt_2026_supersecure")
|
||||||
|
COOKIE_NAME = "wtc_auth"
|
||||||
|
PUBLIC_PATHS = {"/login", "/api/login", "/favicon.ico"}
|
||||||
|
|
||||||
|
def create_auth_token(username: str, days: int = 30) -> str:
|
||||||
|
expiry = int(time.time()) + (days * 86400)
|
||||||
|
msg = f"{username}:{expiry}"
|
||||||
|
sig = hmac.new(SECRET_KEY.encode("utf-8"), msg.encode("utf-8"), hashlib.sha256).hexdigest()
|
||||||
|
return f"{username}:{expiry}:{sig}"
|
||||||
|
|
||||||
|
def verify_auth_token(token: Optional[str]) -> bool:
|
||||||
|
if not token:
|
||||||
|
return False
|
||||||
|
parts = token.split(":")
|
||||||
|
if len(parts) != 3:
|
||||||
|
return False
|
||||||
|
user, expiry_str, sig = parts
|
||||||
|
try:
|
||||||
|
expiry = int(expiry_str)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
if time.time() > expiry:
|
||||||
|
return False
|
||||||
|
msg = f"{user}:{expiry}"
|
||||||
|
expected_sig = hmac.new(SECRET_KEY.encode("utf-8"), msg.encode("utf-8"), hashlib.sha256).hexdigest()
|
||||||
|
return hmac.compare_digest(sig, expected_sig)
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
init_db()
|
||||||
|
os.makedirs(os.path.join(STATIC_DIR, "uploads", "recipes"), exist_ok=True)
|
||||||
|
yield
|
||||||
|
|
||||||
|
app = FastAPI(title="What To Cook? - Zufallsgenerator & Kochbuch", lifespan=lifespan)
|
||||||
|
|
||||||
|
# Middleware für Website-weiten Schutz
|
||||||
|
@app.middleware("http")
|
||||||
|
async def auth_middleware(request: Request, call_next):
|
||||||
|
path = request.url.path
|
||||||
|
if path.startswith("/static/") or path in PUBLIC_PATHS:
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
token = request.cookies.get(COOKIE_NAME)
|
||||||
|
if not verify_auth_token(token):
|
||||||
|
if path.startswith("/api/"):
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=401,
|
||||||
|
content={"status": "unauthorized", "message": "Bitte anmelden."}
|
||||||
|
)
|
||||||
|
next_target = request.url.path
|
||||||
|
if request.url.query:
|
||||||
|
next_target += f"?{request.url.query}"
|
||||||
|
return RedirectResponse(url=f"/login?next={next_target}", status_code=303)
|
||||||
|
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
|
||||||
|
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
||||||
|
templates = Jinja2Templates(directory=TEMPLATES_DIR)
|
||||||
|
|
||||||
|
# Mapping Kochbuch-Kapitel -> Zufallsgenerator-Kategorien
|
||||||
|
CHAPTER_TO_DISH_CAT = {
|
||||||
|
"Suppen & Eintöpfe": "Suppen",
|
||||||
|
"Traditionell & Basics": "Hausmannskost",
|
||||||
|
"Pasta & Nudelgerichte": "Nudeln",
|
||||||
|
"Kartoffeln": "Kartoffeln",
|
||||||
|
"Reis & Getreide": "Reis",
|
||||||
|
"Teig & Gebackenes": "Teigig",
|
||||||
|
"Asiatische Küche": "asiatisch",
|
||||||
|
"Süßspeisen": "Süßspeisen",
|
||||||
|
"Desserts": "Süßspeisen",
|
||||||
|
"Kuchen & Gebäck": "Süßspeisen",
|
||||||
|
"Plätzchen": "Süßspeisen"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Pydantic Models ---
|
||||||
|
|
||||||
|
class SlotRollRequest(BaseModel):
|
||||||
|
slot_number: int = Field(..., ge=1, le=5)
|
||||||
|
chapter_number: Optional[int] = None
|
||||||
|
category: Optional[str] = None
|
||||||
|
efforts: Optional[List[str]] = None
|
||||||
|
current_dish_id: Optional[int] = None
|
||||||
|
|
||||||
|
class MultiRollRequest(BaseModel):
|
||||||
|
slots: List[SlotRollRequest]
|
||||||
|
|
||||||
|
class SingleRollRequest(BaseModel):
|
||||||
|
slot_number: int = Field(..., ge=1, le=5)
|
||||||
|
chapter_number: Optional[int] = None
|
||||||
|
category: Optional[str] = None
|
||||||
|
efforts: Optional[List[str]] = None
|
||||||
|
current_dish_id: Optional[int] = None
|
||||||
|
other_dish_ids: Optional[List[int]] = Field(default_factory=list)
|
||||||
|
|
||||||
|
class DishCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
category: Optional[str] = None
|
||||||
|
effort: str
|
||||||
|
|
||||||
|
class DishUpdate(BaseModel):
|
||||||
|
name: str
|
||||||
|
category: Optional[str] = None
|
||||||
|
effort: str
|
||||||
|
|
||||||
|
class MarkPrintedRequest(BaseModel):
|
||||||
|
recipe_ids: List[int]
|
||||||
|
printed_at: Optional[str] = None
|
||||||
|
|
||||||
|
class UnmarkPrintedRequest(BaseModel):
|
||||||
|
recipe_ids: List[int]
|
||||||
|
|
||||||
|
class MealPlanSlotRequest(BaseModel):
|
||||||
|
year: int
|
||||||
|
week: int
|
||||||
|
day_of_week: int
|
||||||
|
meal_type: str
|
||||||
|
recipe_id: Optional[int] = None
|
||||||
|
custom_text: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# AUTH VIEWS
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
@app.get("/login", response_class=HTMLResponse)
|
||||||
|
async def login_get(request: Request, next: str = "/cookbook"):
|
||||||
|
token = request.cookies.get(COOKIE_NAME)
|
||||||
|
if verify_auth_token(token):
|
||||||
|
return RedirectResponse(url=next or "/cookbook", status_code=303)
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request=request,
|
||||||
|
name="login.html",
|
||||||
|
context={"next_url": next, "error": None, "username": APP_USERNAME}
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.post("/login", response_class=HTMLResponse)
|
||||||
|
async def login_post(
|
||||||
|
request: Request,
|
||||||
|
username: str = Form("admin"),
|
||||||
|
password: str = Form(...),
|
||||||
|
remember: Optional[str] = Form(None),
|
||||||
|
next: str = Form("/cookbook")
|
||||||
|
):
|
||||||
|
valid_user = (username.strip().lower() == APP_USERNAME.lower()) or (username.strip() == "admin")
|
||||||
|
valid_pass = (password == APP_PASSWORD)
|
||||||
|
|
||||||
|
if valid_user and valid_pass:
|
||||||
|
days = 30 if remember else 1
|
||||||
|
token = create_auth_token(username.strip(), days=days)
|
||||||
|
target = next if next and not next.startswith("/login") else "/cookbook"
|
||||||
|
resp = RedirectResponse(url=target, status_code=303)
|
||||||
|
max_age = days * 86400
|
||||||
|
resp.set_cookie(
|
||||||
|
key=COOKIE_NAME,
|
||||||
|
value=token,
|
||||||
|
max_age=max_age,
|
||||||
|
expires=max_age,
|
||||||
|
path="/",
|
||||||
|
httponly=True,
|
||||||
|
samesite="lax"
|
||||||
|
)
|
||||||
|
return resp
|
||||||
|
else:
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request=request,
|
||||||
|
name="login.html",
|
||||||
|
context={
|
||||||
|
"next_url": next,
|
||||||
|
"error": "Ungültiges Passwort oder Benutzername.",
|
||||||
|
"username": username
|
||||||
|
},
|
||||||
|
status_code=400
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.get("/logout")
|
||||||
|
async def logout():
|
||||||
|
resp = RedirectResponse(url="/login", status_code=303)
|
||||||
|
resp.delete_cookie(key=COOKIE_NAME, path="/")
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# VIEWS
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
async def index_view():
|
||||||
|
"""Hauptroute leitet direkt auf das Kochbuch um"""
|
||||||
|
return RedirectResponse(url="/cookbook", status_code=302)
|
||||||
|
|
||||||
|
@app.get("/cookbook", response_class=HTMLResponse)
|
||||||
|
async def cookbook_view(
|
||||||
|
request: Request,
|
||||||
|
chapter: Optional[int] = Query(None)
|
||||||
|
):
|
||||||
|
"""Kochbuch Übersicht mit Kapiteln, Inhaltsverzeichnis und Rezepten"""
|
||||||
|
all_recipes = get_all_recipes()
|
||||||
|
|
||||||
|
# Berechne Portions-Matrix nur für Waffeln / ausgewählte Rezepte
|
||||||
|
for r in all_recipes:
|
||||||
|
is_matrix = bool(r.get("has_portion_table")) or any(k in (r.get("title") or "").lower() for k in ["waffel", "pancake", "pfannkuchen"])
|
||||||
|
r["has_portion_table"] = is_matrix
|
||||||
|
if is_matrix:
|
||||||
|
parsed = parse_ingredients_block(r.get("ingredients", ""))
|
||||||
|
r["portion_matrix"] = calculate_portion_matrix(parsed, base_portions=r.get("portions") or 4, targets=(2, 4, 6, 8))
|
||||||
|
|
||||||
|
# Gruppieren nach Kapitel
|
||||||
|
recipes_by_chapter: Dict[int, List[Dict[str, Any]]] = {}
|
||||||
|
for r in all_recipes:
|
||||||
|
chap_num = r.get("chapter_number", 1)
|
||||||
|
if chap_num not in recipes_by_chapter:
|
||||||
|
recipes_by_chapter[chap_num] = []
|
||||||
|
recipes_by_chapter[chap_num].append(r)
|
||||||
|
|
||||||
|
recipe_counts = {c["number"]: len(recipes_by_chapter.get(c["number"], [])) for c in CHAPTERS}
|
||||||
|
|
||||||
|
if chapter is not None and chapter > 0:
|
||||||
|
chapters_to_display = [c for c in CHAPTERS if c["number"] == chapter]
|
||||||
|
else:
|
||||||
|
chapters_to_display = CHAPTERS
|
||||||
|
|
||||||
|
# --- Seitenzahlen & Inhaltsverzeichnis (TOC) berechnen ---
|
||||||
|
# Seite 1: Cover
|
||||||
|
# TOC: 1 oder 2 Seiten (wenn > 20 Rezepte vorhanden sind)
|
||||||
|
total_display_recipes = len(all_recipes)
|
||||||
|
toc_page_count = 2 if total_display_recipes > 20 else (1 if total_display_recipes > 0 else 0)
|
||||||
|
current_page = 1 + toc_page_count + 1
|
||||||
|
|
||||||
|
display_chapters_data = []
|
||||||
|
for chap in chapters_to_display:
|
||||||
|
chap_copy = dict(chap)
|
||||||
|
chap_recipes = recipes_by_chapter.get(chap["number"], [])
|
||||||
|
if not chap_recipes:
|
||||||
|
display_chapters_data.append(chap_copy)
|
||||||
|
continue
|
||||||
|
|
||||||
|
chap_copy["page_number"] = current_page
|
||||||
|
current_page += 1
|
||||||
|
|
||||||
|
for r in chap_recipes:
|
||||||
|
r["page_number"] = current_page
|
||||||
|
current_page += 1
|
||||||
|
|
||||||
|
chap_copy["recipes"] = chap_recipes
|
||||||
|
display_chapters_data.append(chap_copy)
|
||||||
|
|
||||||
|
# Inhaltsverzeichnis auf 2 Seiten aufteilen
|
||||||
|
toc_p1 = []
|
||||||
|
toc_p2 = []
|
||||||
|
if toc_page_count == 2:
|
||||||
|
accum = 0
|
||||||
|
for c in display_chapters_data:
|
||||||
|
c_recs = recipes_by_chapter.get(c["number"], [])
|
||||||
|
if not c_recs:
|
||||||
|
continue
|
||||||
|
item_weight = len(c_recs) + 2
|
||||||
|
if accum < 28 or not toc_p1:
|
||||||
|
toc_p1.append(c)
|
||||||
|
accum += item_weight
|
||||||
|
else:
|
||||||
|
toc_p2.append(c)
|
||||||
|
else:
|
||||||
|
toc_p1 = [c for c in display_chapters_data if recipes_by_chapter.get(c["number"], [])]
|
||||||
|
|
||||||
|
# User Agent Check für Apple iOS
|
||||||
|
user_agent = request.headers.get("user-agent", "").lower()
|
||||||
|
is_ios = any(d in user_agent for d in ["iphone", "ipad", "ipod"])
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request=request,
|
||||||
|
name="cookbook.html",
|
||||||
|
context={
|
||||||
|
"chapters": CHAPTERS,
|
||||||
|
"current_chapter": chapter,
|
||||||
|
"chapters_to_display": display_chapters_data,
|
||||||
|
"recipes_by_chapter": recipes_by_chapter,
|
||||||
|
"recipe_counts": recipe_counts,
|
||||||
|
"total_recipes": len(all_recipes),
|
||||||
|
"toc_page_count": toc_page_count,
|
||||||
|
"toc_p1": toc_p1,
|
||||||
|
"toc_p2": toc_p2,
|
||||||
|
"is_ios": is_ios
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.get("/cookbook/recipe/{recipe_id}", response_class=HTMLResponse)
|
||||||
|
async def recipe_detail_view(request: Request, recipe_id: int):
|
||||||
|
"""Einheitliche Rezeptseite im handgezeichneten Stil"""
|
||||||
|
recipe = get_recipe(recipe_id)
|
||||||
|
if not recipe:
|
||||||
|
raise HTTPException(status_code=404, detail="Rezept nicht gefunden.")
|
||||||
|
|
||||||
|
is_matrix = bool(recipe.get("has_portion_table")) or any(k in (recipe.get("title") or "").lower() for k in ["waffel", "pancake", "pfannkuchen"])
|
||||||
|
recipe["has_portion_table"] = is_matrix
|
||||||
|
if is_matrix:
|
||||||
|
parsed = parse_ingredients_block(recipe.get("ingredients", ""))
|
||||||
|
recipe["portion_matrix"] = calculate_portion_matrix(parsed, base_portions=recipe.get("portions") or 4, targets=(2, 4, 6, 8))
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request=request,
|
||||||
|
name="recipe_detail.html",
|
||||||
|
context={
|
||||||
|
"recipe": recipe,
|
||||||
|
"chapters": CHAPTERS
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.get("/cookbook/new", response_class=HTMLResponse)
|
||||||
|
async def recipe_new_view(request: Request, chapter: Optional[int] = Query(None)):
|
||||||
|
"""Eingabemaske für neues Rezept"""
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request=request,
|
||||||
|
name="recipe_form.html",
|
||||||
|
context={
|
||||||
|
"recipe": None,
|
||||||
|
"chapters": CHAPTERS,
|
||||||
|
"selected_chapter": chapter
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.get("/cookbook/edit/{recipe_id}", response_class=HTMLResponse)
|
||||||
|
async def recipe_edit_view(request: Request, recipe_id: int):
|
||||||
|
"""Eingabemaske zum Bearbeiten eines Rezepts"""
|
||||||
|
recipe = get_recipe(recipe_id)
|
||||||
|
if not recipe:
|
||||||
|
raise HTTPException(status_code=404, detail="Rezept nicht gefunden.")
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request=request,
|
||||||
|
name="recipe_form.html",
|
||||||
|
context={
|
||||||
|
"recipe": recipe,
|
||||||
|
"chapters": CHAPTERS,
|
||||||
|
"selected_chapter": recipe.get("chapter_number")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# API: KOCHBUCH (RECIPES)
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
def save_uploaded_image(image_file: UploadFile) -> Optional[str]:
|
||||||
|
if not image_file or not image_file.filename:
|
||||||
|
return None
|
||||||
|
|
||||||
|
ext = os.path.splitext(image_file.filename)[1].lower()
|
||||||
|
if ext not in [".jpg", ".jpeg", ".png", ".webp", ".gif"]:
|
||||||
|
return None
|
||||||
|
|
||||||
|
upload_dir = os.path.join(STATIC_DIR, "uploads", "recipes")
|
||||||
|
os.makedirs(upload_dir, exist_ok=True)
|
||||||
|
|
||||||
|
safe_name = f"{uuid.uuid4().hex[:10]}_{os.path.basename(image_file.filename)}"
|
||||||
|
file_path = os.path.join(upload_dir, safe_name)
|
||||||
|
|
||||||
|
with open(file_path, "wb") as buffer:
|
||||||
|
shutil.copyfileobj(image_file.file, buffer)
|
||||||
|
|
||||||
|
return f"/static/uploads/recipes/{safe_name}"
|
||||||
|
|
||||||
|
@app.get("/api/recipes")
|
||||||
|
def api_get_recipes(
|
||||||
|
chapter: Optional[int] = Query(None),
|
||||||
|
category: Optional[str] = Query(None),
|
||||||
|
search: Optional[str] = Query(None),
|
||||||
|
printed: Optional[str] = Query(None)
|
||||||
|
):
|
||||||
|
return get_all_recipes(chapter_number=chapter, category=category, search=search, printed_status=printed)
|
||||||
|
|
||||||
|
@app.get("/api/recipes/{recipe_id}")
|
||||||
|
def api_get_recipe(recipe_id: int):
|
||||||
|
recipe = get_recipe(recipe_id)
|
||||||
|
if not recipe:
|
||||||
|
raise HTTPException(status_code=404, detail="Rezept nicht gefunden.")
|
||||||
|
recipe["parsed_ingredients"] = parse_ingredients_block(recipe.get("ingredients", ""))
|
||||||
|
return recipe
|
||||||
|
|
||||||
|
@app.post("/api/recipes")
|
||||||
|
async def api_create_recipe(
|
||||||
|
title: str = Form(...),
|
||||||
|
chapter_number: int = Form(...),
|
||||||
|
effort: str = Form("mittel"),
|
||||||
|
prep_time: str = Form(""),
|
||||||
|
cook_time: str = Form(""),
|
||||||
|
portions: int = Form(4),
|
||||||
|
ingredients: str = Form(...),
|
||||||
|
instructions: str = Form(...),
|
||||||
|
notes: str = Form(""),
|
||||||
|
source: str = Form(""),
|
||||||
|
printed_at: Optional[str] = Form(None),
|
||||||
|
has_portion_table: Optional[int] = Form(0),
|
||||||
|
add_to_dishes: Optional[str] = Form(None),
|
||||||
|
image_file: Optional[UploadFile] = File(None)
|
||||||
|
):
|
||||||
|
chapter_info = next((c for c in CHAPTERS if c["number"] == chapter_number), None)
|
||||||
|
category_title = chapter_info["title"] if chapter_info else "Traditionell & Basics"
|
||||||
|
|
||||||
|
image_url = ""
|
||||||
|
if image_file and image_file.filename:
|
||||||
|
image_url = save_uploaded_image(image_file) or ""
|
||||||
|
|
||||||
|
try:
|
||||||
|
recipe = add_recipe(
|
||||||
|
title=title,
|
||||||
|
category=category_title,
|
||||||
|
chapter_number=chapter_number,
|
||||||
|
effort=effort,
|
||||||
|
prep_time=prep_time,
|
||||||
|
cook_time=cook_time,
|
||||||
|
portions=portions,
|
||||||
|
ingredients=ingredients,
|
||||||
|
instructions=instructions,
|
||||||
|
notes=notes,
|
||||||
|
source=source,
|
||||||
|
image_url=image_url,
|
||||||
|
printed_at=printed_at if printed_at and printed_at.strip() else None,
|
||||||
|
has_portion_table=1 if (has_portion_table and str(has_portion_table).strip() in ("1", "true", "on", "yes")) else 0
|
||||||
|
)
|
||||||
|
|
||||||
|
should_add = bool(add_to_dishes and str(add_to_dishes).strip().lower() in ("true", "on", "1", "yes"))
|
||||||
|
if should_add:
|
||||||
|
dish_cat = CHAPTER_TO_DISH_CAT.get(category_title, "Hausmannskost")
|
||||||
|
dish_effort = effort if effort in EFFORTS else "mittel"
|
||||||
|
try:
|
||||||
|
add_dish(name=title, category=dish_cat, effort=dish_effort)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {"status": "ok", "recipe": recipe}
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
@app.put("/api/recipes/{recipe_id}")
|
||||||
|
async def api_update_recipe(
|
||||||
|
recipe_id: int,
|
||||||
|
title: str = Form(...),
|
||||||
|
chapter_number: int = Form(...),
|
||||||
|
effort: str = Form("mittel"),
|
||||||
|
prep_time: str = Form(""),
|
||||||
|
cook_time: str = Form(""),
|
||||||
|
portions: int = Form(4),
|
||||||
|
ingredients: str = Form(...),
|
||||||
|
instructions: str = Form(...),
|
||||||
|
notes: str = Form(""),
|
||||||
|
source: str = Form(""),
|
||||||
|
printed_at: Optional[str] = Form(None),
|
||||||
|
has_portion_table: Optional[int] = Form(None),
|
||||||
|
image_file: Optional[UploadFile] = File(None)
|
||||||
|
):
|
||||||
|
chapter_info = next((c for c in CHAPTERS if c["number"] == chapter_number), None)
|
||||||
|
category_title = chapter_info["title"] if chapter_info else "Traditionell & Basics"
|
||||||
|
|
||||||
|
new_image_url = None
|
||||||
|
if image_file and image_file.filename:
|
||||||
|
new_image_url = save_uploaded_image(image_file)
|
||||||
|
|
||||||
|
try:
|
||||||
|
table_val = 1 if (has_portion_table and str(has_portion_table).strip() in ("1", "true", "on", "yes")) else (0 if has_portion_table is not None else None)
|
||||||
|
recipe = update_recipe(
|
||||||
|
recipe_id=recipe_id,
|
||||||
|
title=title,
|
||||||
|
category=category_title,
|
||||||
|
chapter_number=chapter_number,
|
||||||
|
effort=effort,
|
||||||
|
prep_time=prep_time,
|
||||||
|
cook_time=cook_time,
|
||||||
|
portions=portions,
|
||||||
|
ingredients=ingredients,
|
||||||
|
instructions=instructions,
|
||||||
|
notes=notes,
|
||||||
|
source=source,
|
||||||
|
image_url=new_image_url,
|
||||||
|
printed_at=printed_at if printed_at and printed_at.strip() else None,
|
||||||
|
has_portion_table=table_val
|
||||||
|
)
|
||||||
|
return {"status": "ok", "recipe": recipe}
|
||||||
|
except KeyError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
@app.delete("/api/recipes/{recipe_id}")
|
||||||
|
def api_delete_recipe(recipe_id: int):
|
||||||
|
success = delete_recipe(recipe_id)
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(status_code=404, detail="Rezept nicht gefunden.")
|
||||||
|
return {"status": "ok", "deleted": True}
|
||||||
|
|
||||||
|
|
||||||
|
# --- API: Druckstatus & Druckdatum ---
|
||||||
|
|
||||||
|
@app.post("/api/recipes/mark-printed")
|
||||||
|
def api_mark_recipes_printed(payload: MarkPrintedRequest):
|
||||||
|
count = mark_recipes_printed(payload.recipe_ids, payload.printed_at)
|
||||||
|
return {"status": "ok", "marked_count": count}
|
||||||
|
|
||||||
|
@app.post("/api/recipes/unmark-printed")
|
||||||
|
def api_unmark_recipes_printed(payload: UnmarkPrintedRequest):
|
||||||
|
count = unmark_recipes_printed(payload.recipe_ids)
|
||||||
|
return {"status": "ok", "unmarked_count": count}
|
||||||
|
|
||||||
|
@app.post("/api/recipes/{recipe_id}/toggle-printed")
|
||||||
|
def api_toggle_recipe_printed(recipe_id: int):
|
||||||
|
try:
|
||||||
|
updated = toggle_recipe_printed(recipe_id)
|
||||||
|
return {"status": "ok", "recipe": updated}
|
||||||
|
except KeyError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# API: ZUFALLSGENERATOR & GERICHTE
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
@app.get("/api/categories")
|
||||||
|
def api_categories():
|
||||||
|
return {
|
||||||
|
"categories": CATEGORIES,
|
||||||
|
"efforts": EFFORTS,
|
||||||
|
"chapters": CHAPTERS
|
||||||
|
}
|
||||||
|
|
||||||
|
@app.get("/api/stats")
|
||||||
|
def api_stats():
|
||||||
|
return get_stats()
|
||||||
|
|
||||||
|
@app.get("/api/dishes")
|
||||||
|
def api_get_dishes(
|
||||||
|
category: Optional[str] = Query(None),
|
||||||
|
effort: Optional[str] = Query(None),
|
||||||
|
search: Optional[str] = Query(None)
|
||||||
|
):
|
||||||
|
return get_all_dishes(category=category, effort=effort, search=search)
|
||||||
|
|
||||||
|
@app.post("/api/dishes")
|
||||||
|
def api_create_dish(payload: DishCreate):
|
||||||
|
try:
|
||||||
|
cat = payload.category or "Hausmannskost"
|
||||||
|
dish = add_dish(payload.name, cat, payload.effort)
|
||||||
|
return {"status": "ok", "dish": dish}
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
@app.put("/api/dishes/{dish_id}")
|
||||||
|
def api_update_dish(dish_id: int, payload: DishUpdate):
|
||||||
|
try:
|
||||||
|
cat = payload.category or "Hausmannskost"
|
||||||
|
dish = update_dish(dish_id, payload.name, cat, payload.effort)
|
||||||
|
return {"status": "ok", "dish": dish}
|
||||||
|
except KeyError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
@app.delete("/api/dishes/{dish_id}")
|
||||||
|
def api_delete_dish(dish_id: int):
|
||||||
|
success = delete_dish(dish_id)
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(status_code=404, detail="Gericht nicht gefunden.")
|
||||||
|
return {"status": "ok", "deleted": True}
|
||||||
|
|
||||||
|
def resolve_slot_chapter(slot) -> Optional[int]:
|
||||||
|
if getattr(slot, "chapter_number", None) and slot.chapter_number > 0:
|
||||||
|
return slot.chapter_number
|
||||||
|
if slot.category:
|
||||||
|
s = str(slot.category).strip()
|
||||||
|
if s.isdigit():
|
||||||
|
return int(s)
|
||||||
|
for c in CHAPTERS:
|
||||||
|
if s.lower() in c["title"].lower():
|
||||||
|
return c["number"]
|
||||||
|
return None
|
||||||
|
|
||||||
|
@app.post("/api/roll")
|
||||||
|
def api_roll_dishes(payload: MultiRollRequest):
|
||||||
|
"""Rolls recipes directly from the cookbook without duplicates in round."""
|
||||||
|
slots = payload.slots
|
||||||
|
if not slots:
|
||||||
|
raise HTTPException(status_code=400, detail="Keine Mahlzeiten zur Auslosung übergeben.")
|
||||||
|
if len(slots) > 5:
|
||||||
|
raise HTTPException(status_code=400, detail="Maximal 5 Mahlzeiten können gleichzeitig ausgelost werden.")
|
||||||
|
|
||||||
|
results = []
|
||||||
|
chosen_ids = set()
|
||||||
|
|
||||||
|
slot_candidates = []
|
||||||
|
for slot in slots:
|
||||||
|
chap_num = resolve_slot_chapter(slot)
|
||||||
|
candidates = query_recipe_candidates(chap_num, slot.efforts)
|
||||||
|
slot_candidates.append({
|
||||||
|
"slot": slot,
|
||||||
|
"chapter_number": chap_num,
|
||||||
|
"candidates": candidates
|
||||||
|
})
|
||||||
|
|
||||||
|
indexed_slots = list(enumerate(slot_candidates))
|
||||||
|
indexed_slots.sort(key=lambda item: len(item[1]["candidates"]))
|
||||||
|
|
||||||
|
temp_assignment = {}
|
||||||
|
|
||||||
|
for original_idx, item in indexed_slots:
|
||||||
|
slot = item["slot"]
|
||||||
|
chap_num = item["chapter_number"]
|
||||||
|
candidates = item["candidates"]
|
||||||
|
|
||||||
|
available = [c for c in candidates if c["id"] not in chosen_ids]
|
||||||
|
if not available:
|
||||||
|
available = candidates
|
||||||
|
|
||||||
|
if not available:
|
||||||
|
chap_title = CHAPTERS[chap_num - 1]["title"] if chap_num and 1 <= chap_num <= len(CHAPTERS) else ""
|
||||||
|
temp_assignment[original_idx] = {
|
||||||
|
"slot_number": slot.slot_number,
|
||||||
|
"recipe": None,
|
||||||
|
"dish": None,
|
||||||
|
"status": "not_found",
|
||||||
|
"chapter_number": chap_num,
|
||||||
|
"chapter_title": chap_title,
|
||||||
|
"message": f"Noch keine Rezepte in Kapitel {chap_num} ({chap_title}) vorhanden." if chap_num else "Noch keine Rezepte im Kochbuch vorhanden."
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
|
||||||
|
alternatives = [c for c in available if c["id"] != slot.current_dish_id]
|
||||||
|
selection_pool = alternatives if alternatives else available
|
||||||
|
|
||||||
|
selected = random.choice(selection_pool)
|
||||||
|
chosen_ids.add(selected["id"])
|
||||||
|
|
||||||
|
selected_recipe = dict(selected)
|
||||||
|
c_num = selected_recipe.get("chapter_number", 1)
|
||||||
|
chap = CHAPTERS[c_num - 1] if 1 <= c_num <= len(CHAPTERS) else CHAPTERS[0]
|
||||||
|
selected_recipe["chapter_title"] = chap["title"]
|
||||||
|
selected_recipe["chapter_icon"] = chap["icon"]
|
||||||
|
selected_recipe["chapter_illustration"] = chap["illustration_url"]
|
||||||
|
selected_recipe["name"] = selected_recipe["title"]
|
||||||
|
selected_recipe["recipe_id"] = selected_recipe["id"]
|
||||||
|
|
||||||
|
temp_assignment[original_idx] = {
|
||||||
|
"slot_number": slot.slot_number,
|
||||||
|
"dish": selected_recipe,
|
||||||
|
"recipe": selected_recipe,
|
||||||
|
"status": "ok"
|
||||||
|
}
|
||||||
|
|
||||||
|
for idx in range(len(slots)):
|
||||||
|
results.append(temp_assignment[idx])
|
||||||
|
|
||||||
|
return {"status": "ok", "results": results}
|
||||||
|
|
||||||
|
@app.post("/api/roll-single")
|
||||||
|
def api_roll_single(payload: SingleRollRequest):
|
||||||
|
"""Rerolls a single recipe slot directly from cookbook."""
|
||||||
|
chap_num = resolve_slot_chapter(payload)
|
||||||
|
candidates = query_recipe_candidates(chap_num, payload.efforts)
|
||||||
|
if not candidates:
|
||||||
|
chap_title = CHAPTERS[chap_num - 1]["title"] if chap_num and 1 <= chap_num <= len(CHAPTERS) else ""
|
||||||
|
return {
|
||||||
|
"status": "not_found",
|
||||||
|
"dish": None,
|
||||||
|
"recipe": None,
|
||||||
|
"chapter_number": chap_num,
|
||||||
|
"chapter_title": chap_title,
|
||||||
|
"message": f"Noch keine Rezepte in Kapitel {chap_num} ({chap_title}) vorhanden." if chap_num else "Noch keine Rezepte im Kochbuch vorhanden."
|
||||||
|
}
|
||||||
|
|
||||||
|
other_ids = set(payload.other_dish_ids or [])
|
||||||
|
pool = [c for c in candidates if c["id"] not in other_ids]
|
||||||
|
if not pool:
|
||||||
|
pool = candidates
|
||||||
|
|
||||||
|
alternatives = [c for c in pool if c["id"] != payload.current_dish_id]
|
||||||
|
selection_pool = alternatives if alternatives else pool
|
||||||
|
|
||||||
|
selected = random.choice(selection_pool)
|
||||||
|
selected_recipe = dict(selected)
|
||||||
|
c_num = selected_recipe.get("chapter_number", 1)
|
||||||
|
chap = CHAPTERS[c_num - 1] if 1 <= c_num <= len(CHAPTERS) else CHAPTERS[0]
|
||||||
|
selected_recipe["chapter_title"] = chap["title"]
|
||||||
|
selected_recipe["chapter_icon"] = chap["icon"]
|
||||||
|
selected_recipe["chapter_illustration"] = chap["illustration_url"]
|
||||||
|
selected_recipe["name"] = selected_recipe["title"]
|
||||||
|
selected_recipe["recipe_id"] = selected_recipe["id"]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"slot_number": payload.slot_number,
|
||||||
|
"dish": selected_recipe,
|
||||||
|
"recipe": selected_recipe,
|
||||||
|
"is_repetition": (selected["id"] == payload.current_dish_id and len(candidates) > 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# API: WOCHENMENÜ-PLAN (MEAL PLANNER)
|
||||||
|
# ==========================================
|
||||||
|
|
||||||
|
def get_week_dates(year: int, week: int) -> Dict[str, Any]:
|
||||||
|
"""Berechnet die Kalenderdaten (Montag bis Sonntag) für eine gegebene KW."""
|
||||||
|
try:
|
||||||
|
monday = date.fromisocalendar(year, week, 1)
|
||||||
|
except AttributeError:
|
||||||
|
first_day = date(year, 1, 4)
|
||||||
|
monday = first_day + timedelta(days=((week - 1) * 7) - first_day.weekday())
|
||||||
|
|
||||||
|
day_names = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"]
|
||||||
|
days = []
|
||||||
|
for i in range(7):
|
||||||
|
cur_date = monday + timedelta(days=i)
|
||||||
|
days.append({
|
||||||
|
"day_index": i,
|
||||||
|
"name": day_names[i],
|
||||||
|
"date_formatted": cur_date.strftime("%d.%m."),
|
||||||
|
"iso_date": cur_date.strftime("%Y-%m-%d"),
|
||||||
|
"is_today": (cur_date == date.today())
|
||||||
|
})
|
||||||
|
sunday = monday + timedelta(days=6)
|
||||||
|
return {
|
||||||
|
"year": year,
|
||||||
|
"week": week,
|
||||||
|
"range_formatted": f"{monday.strftime('%d.%m.')} – {sunday.strftime('%d.%m.%Y')}",
|
||||||
|
"days": days
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/mealplan")
|
||||||
|
def api_get_mealplan(year: Optional[int] = Query(None), week: Optional[int] = Query(None)):
|
||||||
|
today = date.today()
|
||||||
|
iso_year, iso_week, _ = today.isocalendar()
|
||||||
|
y = year or iso_year
|
||||||
|
w = week or iso_week
|
||||||
|
week_info = get_week_dates(y, w)
|
||||||
|
slots = get_meal_plan(y, w)
|
||||||
|
|
||||||
|
# Slot-Map: "0_frueh", "0_mittag", "0_abend" etc.
|
||||||
|
slot_map = {}
|
||||||
|
for s in slots:
|
||||||
|
k = f"{s['day_of_week']}_{s['meal_type']}"
|
||||||
|
slot_map[k] = s
|
||||||
|
|
||||||
|
# Kompakte Rezeptliste für schnelles Dropdown / Zuweisung
|
||||||
|
all_recs = get_all_recipes()
|
||||||
|
recipes_summary = [
|
||||||
|
{
|
||||||
|
"id": r["id"],
|
||||||
|
"title": r["title"],
|
||||||
|
"category": r.get("category", ""),
|
||||||
|
"chapter_number": r.get("chapter_number", 1),
|
||||||
|
"effort": r.get("effort", "mittel")
|
||||||
|
}
|
||||||
|
for r in all_recs
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"year": y,
|
||||||
|
"week": w,
|
||||||
|
"current_year": iso_year,
|
||||||
|
"current_week": iso_week,
|
||||||
|
"is_current_week": (y == iso_year and w == iso_week),
|
||||||
|
"range_formatted": week_info["range_formatted"],
|
||||||
|
"days": week_info["days"],
|
||||||
|
"slots": slot_map,
|
||||||
|
"recipes": recipes_summary
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/mealplan/slot")
|
||||||
|
def api_save_mealplan_slot(payload: MealPlanSlotRequest):
|
||||||
|
if payload.day_of_week < 0 or payload.day_of_week > 6:
|
||||||
|
raise HTTPException(status_code=400, detail="Ungültiger Wochentag (0-6).")
|
||||||
|
if payload.meal_type not in ["frueh", "mittag", "kaffee", "abend"]:
|
||||||
|
raise HTTPException(status_code=400, detail="Ungültiger Mahlzeittyp (frueh, mittag, kaffee, abend).")
|
||||||
|
|
||||||
|
slot = set_meal_plan_slot(
|
||||||
|
year=payload.year,
|
||||||
|
week=payload.week,
|
||||||
|
day_of_week=payload.day_of_week,
|
||||||
|
meal_type=payload.meal_type,
|
||||||
|
recipe_id=payload.recipe_id,
|
||||||
|
custom_text=payload.custom_text
|
||||||
|
)
|
||||||
|
return {"status": "ok", "slot": slot}
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/mealplan/slot")
|
||||||
|
def api_delete_mealplan_slot(
|
||||||
|
year: int = Query(...),
|
||||||
|
week: int = Query(...),
|
||||||
|
day_of_week: int = Query(...),
|
||||||
|
meal_type: str = Query(...)
|
||||||
|
):
|
||||||
|
success = delete_meal_plan_slot(year, week, day_of_week, meal_type)
|
||||||
|
return {"status": "ok", "deleted": success}
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/mealplan/week")
|
||||||
|
def api_clear_mealplan_week(year: int = Query(...), week: int = Query(...)):
|
||||||
|
count = clear_meal_plan_week(year, week)
|
||||||
|
return {"status": "ok", "cleared_count": count}
|
||||||
|
|
||||||
@@ -0,0 +1,695 @@
|
|||||||
|
/**
|
||||||
|
* What To Cook? - Frontend Application
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Category icons mapping
|
||||||
|
const CATEGORY_ICONS = {
|
||||||
|
"Reis": "🍚",
|
||||||
|
"Nudeln": "🍝",
|
||||||
|
"Kartoffeln": "🥔",
|
||||||
|
"Teigig": "🍕",
|
||||||
|
"asiatisch": "🥢",
|
||||||
|
"Hausmannskost": "🥩",
|
||||||
|
"Suppen": "🍲",
|
||||||
|
"Eintöpfe": "🥘",
|
||||||
|
"Süßspeisen": "🥞",
|
||||||
|
"Fisch": "🐟"
|
||||||
|
};
|
||||||
|
|
||||||
|
const CATEGORIES = [
|
||||||
|
"Reis",
|
||||||
|
"Nudeln",
|
||||||
|
"Kartoffeln",
|
||||||
|
"Teigig",
|
||||||
|
"asiatisch",
|
||||||
|
"Hausmannskost",
|
||||||
|
"Suppen",
|
||||||
|
"Eintöpfe",
|
||||||
|
"Süßspeisen",
|
||||||
|
"Fisch"
|
||||||
|
];
|
||||||
|
|
||||||
|
const EFFORTS = ["gering", "mittel", "hoch"];
|
||||||
|
|
||||||
|
function getDishCategories(dish) {
|
||||||
|
if (!dish) return [];
|
||||||
|
if (Array.isArray(dish.categories) && dish.categories.length > 0) {
|
||||||
|
return dish.categories;
|
||||||
|
}
|
||||||
|
if (dish.category) {
|
||||||
|
return dish.category.split(",").map(c => c.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCategoryBadges(dish) {
|
||||||
|
const cats = getDishCategories(dish);
|
||||||
|
if (cats.length === 0) return "";
|
||||||
|
return cats.map(cat => {
|
||||||
|
const icon = CATEGORY_ICONS[cat] || "🍲";
|
||||||
|
return `<span class="badge-tag badge-category"><span>${icon}</span> ${cat}</span>`;
|
||||||
|
}).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Application State
|
||||||
|
const state = {
|
||||||
|
activeTab: "generator",
|
||||||
|
slots: [
|
||||||
|
{ slot_number: 1, active: true, category: "", efforts: [], current_dish: null },
|
||||||
|
{ slot_number: 2, active: true, category: "", efforts: [], current_dish: null },
|
||||||
|
{ slot_number: 3, active: false, category: "", efforts: [], current_dish: null },
|
||||||
|
{ slot_number: 4, active: false, category: "", efforts: [], current_dish: null },
|
||||||
|
{ slot_number: 5, active: false, category: "", efforts: [], current_dish: null },
|
||||||
|
],
|
||||||
|
admin: {
|
||||||
|
dishes: [],
|
||||||
|
search: "",
|
||||||
|
category: "alle",
|
||||||
|
effort: "alle",
|
||||||
|
deleteTargetId: null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Initialization ---
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
initTabs();
|
||||||
|
renderSlots();
|
||||||
|
populateDropdowns();
|
||||||
|
initGeneratorEvents();
|
||||||
|
initAdminEvents();
|
||||||
|
loadAdminDishes();
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Tab Navigation ---
|
||||||
|
function initTabs() {
|
||||||
|
const btnGenerator = document.getElementById("tab-btn-generator");
|
||||||
|
const btnAdmin = document.getElementById("tab-btn-admin");
|
||||||
|
const panelGenerator = document.getElementById("tab-generator");
|
||||||
|
const panelAdmin = document.getElementById("tab-admin");
|
||||||
|
|
||||||
|
btnGenerator.addEventListener("click", () => switchTab("generator"));
|
||||||
|
btnAdmin.addEventListener("click", () => switchTab("admin"));
|
||||||
|
|
||||||
|
function switchTab(tabName) {
|
||||||
|
state.activeTab = tabName;
|
||||||
|
if (tabName === "generator") {
|
||||||
|
btnGenerator.classList.add("active");
|
||||||
|
btnAdmin.classList.remove("active");
|
||||||
|
panelGenerator.classList.add("active");
|
||||||
|
panelAdmin.classList.remove("active");
|
||||||
|
panelAdmin.hidden = true;
|
||||||
|
panelGenerator.hidden = false;
|
||||||
|
} else {
|
||||||
|
btnAdmin.classList.add("active");
|
||||||
|
btnGenerator.classList.remove("active");
|
||||||
|
panelAdmin.classList.add("active");
|
||||||
|
panelGenerator.classList.remove("active");
|
||||||
|
panelGenerator.hidden = true;
|
||||||
|
panelAdmin.hidden = false;
|
||||||
|
loadAdminDishes();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Populate Category Dropdowns ---
|
||||||
|
function populateDropdowns() {
|
||||||
|
const adminCatSelect = document.getElementById("admin-filter-category");
|
||||||
|
const modalCatGroup = document.getElementById("modal-category-group");
|
||||||
|
|
||||||
|
if (modalCatGroup) {
|
||||||
|
modalCatGroup.innerHTML = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
CATEGORIES.forEach(cat => {
|
||||||
|
const icon = CATEGORY_ICONS[cat] || "🍲";
|
||||||
|
|
||||||
|
// Admin filter
|
||||||
|
if (adminCatSelect) {
|
||||||
|
const optFilter = document.createElement("option");
|
||||||
|
optFilter.value = cat;
|
||||||
|
optFilter.textContent = `${icon} ${cat}`;
|
||||||
|
adminCatSelect.appendChild(optFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modal checkbox pill
|
||||||
|
if (modalCatGroup) {
|
||||||
|
const label = document.createElement("label");
|
||||||
|
label.className = "category-checkbox-label";
|
||||||
|
label.innerHTML = `
|
||||||
|
<input type="checkbox" name="modal-category" value="${cat}">
|
||||||
|
<span class="cat-pill">
|
||||||
|
<span>${icon}</span> ${cat}
|
||||||
|
</span>
|
||||||
|
`;
|
||||||
|
modalCatGroup.appendChild(label);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Generator Slots Rendering ---
|
||||||
|
function renderSlots() {
|
||||||
|
const container = document.getElementById("slots-container");
|
||||||
|
container.innerHTML = "";
|
||||||
|
|
||||||
|
state.slots.forEach(slot => {
|
||||||
|
const card = document.createElement("div");
|
||||||
|
card.className = `slot-card ${slot.active ? "active" : "inactive"}`;
|
||||||
|
card.id = `slot-card-${slot.slot_number}`;
|
||||||
|
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="slot-card-header">
|
||||||
|
<div class="slot-title-group">
|
||||||
|
<span class="slot-number-badge">${slot.slot_number}</span>
|
||||||
|
<span class="slot-name">Gericht ${slot.slot_number}</span>
|
||||||
|
</div>
|
||||||
|
<label class="slot-toggle-label">
|
||||||
|
<span>${slot.active ? "Aktiv" : "Freigelassen"}</span>
|
||||||
|
<label class="switch">
|
||||||
|
<input type="checkbox" id="slot-toggle-${slot.slot_number}" ${slot.active ? "checked" : ""}>
|
||||||
|
<span class="slider"></span>
|
||||||
|
</label>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="slot-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="slot-cat-${slot.slot_number}" class="form-label">Kategorie:</label>
|
||||||
|
<select id="slot-cat-${slot.slot_number}" class="custom-select">
|
||||||
|
<option value="" ${slot.category === "" ? "selected" : ""}>🎲 Beliebig (Alle Kategorien)</option>
|
||||||
|
${CATEGORIES.map(cat => {
|
||||||
|
const icon = CATEGORY_ICONS[cat] || "🍲";
|
||||||
|
return `<option value="${cat}" ${slot.category === cat ? "selected" : ""}>${icon} ${cat}</option>`;
|
||||||
|
}).join("")}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Aufwand (Mehrfachauswahl möglich):</label>
|
||||||
|
<div class="effort-chips-group" id="effort-chips-${slot.slot_number}">
|
||||||
|
<button type="button" class="effort-chip ${slot.efforts.includes("gering") ? "selected" : ""}" data-effort="gering">
|
||||||
|
<span>🟢</span> Gering
|
||||||
|
</button>
|
||||||
|
<button type="button" class="effort-chip ${slot.efforts.includes("mittel") ? "selected" : ""}" data-effort="mittel">
|
||||||
|
<span>🟡</span> Mittel
|
||||||
|
</button>
|
||||||
|
<button type="button" class="effort-chip ${slot.efforts.includes("hoch") ? "selected" : ""}" data-effort="hoch">
|
||||||
|
<span>🔴</span> Hoch
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
container.appendChild(card);
|
||||||
|
|
||||||
|
// Event: Toggle Active
|
||||||
|
const toggleInput = card.querySelector(`#slot-toggle-${slot.slot_number}`);
|
||||||
|
toggleInput.addEventListener("change", (e) => {
|
||||||
|
slot.active = e.target.checked;
|
||||||
|
renderSlots();
|
||||||
|
updateActiveCounter();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Event: Category Change
|
||||||
|
const catSelect = card.querySelector(`#slot-cat-${slot.slot_number}`);
|
||||||
|
catSelect.addEventListener("change", (e) => {
|
||||||
|
slot.category = e.target.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Event: Effort Multi-Select Chips
|
||||||
|
const chips = card.querySelectorAll(`#effort-chips-${slot.slot_number} .effort-chip`);
|
||||||
|
chips.forEach(chip => {
|
||||||
|
chip.addEventListener("click", () => {
|
||||||
|
const effortVal = chip.getAttribute("data-effort");
|
||||||
|
const idx = slot.efforts.indexOf(effortVal);
|
||||||
|
if (idx > -1) {
|
||||||
|
slot.efforts.splice(idx, 1);
|
||||||
|
chip.classList.remove("selected");
|
||||||
|
} else {
|
||||||
|
slot.efforts.push(effortVal);
|
||||||
|
chip.classList.add("selected");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
updateActiveCounter();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateActiveCounter() {
|
||||||
|
const activeCount = state.slots.filter(s => s.active).length;
|
||||||
|
const countEl = document.getElementById("active-slots-count");
|
||||||
|
if (countEl) countEl.textContent = activeCount;
|
||||||
|
|
||||||
|
const btnHero = document.getElementById("btn-roll-all");
|
||||||
|
if (btnHero) {
|
||||||
|
const btnText = btnHero.querySelector(".btn-text");
|
||||||
|
if (activeCount === 0) {
|
||||||
|
btnText.textContent = "Bitte mindestens 1 Gericht aktivieren";
|
||||||
|
btnHero.disabled = true;
|
||||||
|
} else if (activeCount === 1) {
|
||||||
|
btnText.textContent = "1 Gericht auslosen";
|
||||||
|
btnHero.disabled = false;
|
||||||
|
} else {
|
||||||
|
btnText.textContent = `${activeCount} Gerichte auslosen`;
|
||||||
|
btnHero.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Generator Events (Roll Logic) ---
|
||||||
|
function initGeneratorEvents() {
|
||||||
|
const btnRollAll = document.getElementById("btn-roll-all");
|
||||||
|
const btnRerollAll = document.getElementById("btn-reroll-all");
|
||||||
|
const btnBack = document.getElementById("btn-back-to-config");
|
||||||
|
|
||||||
|
btnRollAll.addEventListener("click", () => executeRoll(false));
|
||||||
|
btnRerollAll.addEventListener("click", () => executeRoll(true));
|
||||||
|
|
||||||
|
btnBack.addEventListener("click", () => {
|
||||||
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeRoll(isReroll = false) {
|
||||||
|
const activeSlots = state.slots.filter(s => s.active);
|
||||||
|
if (activeSlots.length === 0) {
|
||||||
|
showToast("Bitte aktiviere mindestens 1 Gericht.", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const btnRollAll = document.getElementById("btn-roll-all");
|
||||||
|
const btnRerollAll = document.getElementById("btn-reroll-all");
|
||||||
|
btnRollAll.disabled = true;
|
||||||
|
btnRerollAll.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
slots: activeSlots.map(s => ({
|
||||||
|
slot_number: s.slot_number,
|
||||||
|
category: s.category || null,
|
||||||
|
efforts: s.efforts.length > 0 ? s.efforts : null,
|
||||||
|
current_dish_id: isReroll && s.current_dish ? s.current_dish.id : null
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await fetch("/api/roll", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(data.detail || "Fehler beim Auslosen.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update slots state with rolled dishes
|
||||||
|
data.results.forEach(resItem => {
|
||||||
|
const slot = state.slots.find(s => s.slot_number === resItem.slot_number);
|
||||||
|
if (slot) {
|
||||||
|
slot.current_dish = resItem.dish;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
renderResults(data.results);
|
||||||
|
showToast(isReroll ? "Alle Gerichte neu ausgelost!" : "Zufallsauswahl erfolgreich!", "success");
|
||||||
|
|
||||||
|
// Scroll smoothly to results
|
||||||
|
const resultsSection = document.getElementById("results-section");
|
||||||
|
resultsSection.classList.remove("hidden");
|
||||||
|
resultsSection.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message, "error");
|
||||||
|
} finally {
|
||||||
|
btnRollAll.disabled = false;
|
||||||
|
btnRerollAll.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render Results Grid
|
||||||
|
function renderResults(results) {
|
||||||
|
const container = document.getElementById("results-list");
|
||||||
|
const countBadge = document.getElementById("results-count-badge");
|
||||||
|
container.innerHTML = "";
|
||||||
|
|
||||||
|
const validDishes = results.filter(r => r.dish !== null);
|
||||||
|
countBadge.textContent = `${validDishes.length} ${validDishes.length === 1 ? "Gericht" : "Gerichte"}`;
|
||||||
|
|
||||||
|
results.forEach(item => {
|
||||||
|
const slot = state.slots.find(s => s.slot_number === item.slot_number);
|
||||||
|
const dish = item.dish;
|
||||||
|
const card = document.createElement("div");
|
||||||
|
card.className = "result-card";
|
||||||
|
card.id = `result-card-${item.slot_number}`;
|
||||||
|
|
||||||
|
if (!dish) {
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="result-card-top">
|
||||||
|
<span class="result-slot-label">Gericht ${item.slot_number}</span>
|
||||||
|
</div>
|
||||||
|
<div class="result-dish-name" style="color: var(--text-dim);">
|
||||||
|
⚠️ Keine passenden Gerichte gefunden
|
||||||
|
</div>
|
||||||
|
<p style="font-size: 0.85rem; color: var(--text-muted);">
|
||||||
|
Für die gewählte Kombination (Kategorie / Aufwand) gibt es noch keine Einträge.
|
||||||
|
</p>
|
||||||
|
`;
|
||||||
|
container.appendChild(card);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const effortLabel = dish.effort.charAt(0).toUpperCase() + dish.effort.slice(1);
|
||||||
|
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="result-card-top">
|
||||||
|
<span class="result-slot-label">Gericht ${item.slot_number}</span>
|
||||||
|
</div>
|
||||||
|
<div class="result-dish-name" id="dish-name-${item.slot_number}">${dish.name}</div>
|
||||||
|
<div class="result-tags">
|
||||||
|
${renderCategoryBadges(dish)}
|
||||||
|
<span class="badge-tag badge-effort-${dish.effort}">
|
||||||
|
${effortLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="result-card-actions">
|
||||||
|
<button class="btn-reroll-single" data-slot="${item.slot_number}" title="Nur dieses Gericht neu würfeln">
|
||||||
|
<span class="dice-icon">🎲</span> Neu würfeln
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
container.appendChild(card);
|
||||||
|
|
||||||
|
// Event: Single Reroll Button
|
||||||
|
const btnRerollSingle = card.querySelector(".btn-reroll-single");
|
||||||
|
btnRerollSingle.addEventListener("click", () => rerollSingleSlot(item.slot_number, btnRerollSingle, card));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Single Slot Reroll Logic ---
|
||||||
|
async function rerollSingleSlot(slotNumber, btnElement, cardElement) {
|
||||||
|
const slot = state.slots.find(s => s.slot_number === slotNumber);
|
||||||
|
if (!slot || !slot.current_dish) return;
|
||||||
|
|
||||||
|
btnElement.classList.add("spinning");
|
||||||
|
cardElement.classList.add("rerolling");
|
||||||
|
|
||||||
|
// Collect other dishes currently on screen so we don't duplicate
|
||||||
|
const otherDishIds = state.slots
|
||||||
|
.filter(s => s.active && s.slot_number !== slotNumber && s.current_dish)
|
||||||
|
.map(s => s.current_dish.id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
slot_number: slotNumber,
|
||||||
|
category: slot.category || null,
|
||||||
|
efforts: slot.efforts.length > 0 ? slot.efforts : null,
|
||||||
|
current_dish_id: slot.current_dish.id,
|
||||||
|
other_dish_ids: otherDishIds
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await fetch("/api/roll-single", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.detail || "Fehler beim Neu-Würfeln.");
|
||||||
|
|
||||||
|
if (data.status === "ok" && data.dish) {
|
||||||
|
slot.current_dish = data.dish;
|
||||||
|
const effortLabel = data.dish.effort.charAt(0).toUpperCase() + data.dish.effort.slice(1);
|
||||||
|
|
||||||
|
// Smoothly update the card content
|
||||||
|
const nameEl = cardElement.querySelector(`#dish-name-${slotNumber}`);
|
||||||
|
if (nameEl) nameEl.textContent = data.dish.name;
|
||||||
|
|
||||||
|
const tagsEl = cardElement.querySelector(".result-tags");
|
||||||
|
if (tagsEl) {
|
||||||
|
tagsEl.innerHTML = `
|
||||||
|
${renderCategoryBadges(data.dish)}
|
||||||
|
<span class="badge-tag badge-effort-${data.dish.effort}">
|
||||||
|
${effortLabel}
|
||||||
|
</span>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast(`Gericht ${slotNumber} neu gewürfelt: ${data.dish.name}`, "success");
|
||||||
|
} else {
|
||||||
|
showToast("Kein anderes passendes Gericht in dieser Kategorie gefunden.", "error");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message, "error");
|
||||||
|
} finally {
|
||||||
|
setTimeout(() => {
|
||||||
|
btnElement.classList.remove("spinning");
|
||||||
|
cardElement.classList.remove("rerolling");
|
||||||
|
}, 450);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Admin Section & CRUD ---
|
||||||
|
function initAdminEvents() {
|
||||||
|
const searchInput = document.getElementById("admin-search-input");
|
||||||
|
const clearSearchBtn = document.getElementById("admin-search-clear");
|
||||||
|
const catFilter = document.getElementById("admin-filter-category");
|
||||||
|
const effortFilter = document.getElementById("admin-filter-effort");
|
||||||
|
|
||||||
|
searchInput.addEventListener("input", (e) => {
|
||||||
|
state.admin.search = e.target.value.trim();
|
||||||
|
clearSearchBtn.classList.toggle("hidden", state.admin.search.length === 0);
|
||||||
|
loadAdminDishes();
|
||||||
|
});
|
||||||
|
|
||||||
|
clearSearchBtn.addEventListener("click", () => {
|
||||||
|
searchInput.value = "";
|
||||||
|
state.admin.search = "";
|
||||||
|
clearSearchBtn.classList.add("hidden");
|
||||||
|
loadAdminDishes();
|
||||||
|
});
|
||||||
|
|
||||||
|
catFilter.addEventListener("change", (e) => {
|
||||||
|
state.admin.category = e.target.value;
|
||||||
|
loadAdminDishes();
|
||||||
|
});
|
||||||
|
|
||||||
|
effortFilter.addEventListener("change", (e) => {
|
||||||
|
state.admin.effort = e.target.value;
|
||||||
|
loadAdminDishes();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Modal Open/Close
|
||||||
|
const dishModal = document.getElementById("dish-modal");
|
||||||
|
const btnOpenAdd = document.getElementById("btn-open-add-dish");
|
||||||
|
const btnCloseModal = document.getElementById("btn-close-modal");
|
||||||
|
const btnCancelModal = document.getElementById("btn-cancel-modal");
|
||||||
|
const dishForm = document.getElementById("dish-form");
|
||||||
|
|
||||||
|
btnOpenAdd.addEventListener("click", () => {
|
||||||
|
openDishModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
btnCloseModal.addEventListener("click", () => dishModal.close());
|
||||||
|
btnCancelModal.addEventListener("click", () => dishModal.close());
|
||||||
|
|
||||||
|
dishForm.addEventListener("submit", async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
await saveDishFromModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete Modal
|
||||||
|
const deleteModal = document.getElementById("delete-modal");
|
||||||
|
const btnCancelDelete = document.getElementById("btn-cancel-delete");
|
||||||
|
const btnConfirmDelete = document.getElementById("btn-confirm-delete");
|
||||||
|
|
||||||
|
btnCancelDelete.addEventListener("click", () => deleteModal.close());
|
||||||
|
btnConfirmDelete.addEventListener("click", async () => {
|
||||||
|
if (state.admin.deleteTargetId) {
|
||||||
|
await executeDeleteDish(state.admin.deleteTargetId);
|
||||||
|
deleteModal.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAdminDishes() {
|
||||||
|
const listContainer = document.getElementById("admin-dishes-list");
|
||||||
|
const statsSummary = document.getElementById("admin-stats-summary");
|
||||||
|
const countLabel = document.getElementById("dishes-count-label");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (state.admin.category && state.admin.category !== "alle") {
|
||||||
|
params.append("category", state.admin.category);
|
||||||
|
}
|
||||||
|
if (state.admin.effort && state.admin.effort !== "alle") {
|
||||||
|
params.append("effort", state.admin.effort);
|
||||||
|
}
|
||||||
|
if (state.admin.search) {
|
||||||
|
params.append("search", state.admin.search);
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`/api/dishes?${params.toString()}`);
|
||||||
|
const dishes = await res.json();
|
||||||
|
state.admin.dishes = dishes;
|
||||||
|
|
||||||
|
// Load overall stats
|
||||||
|
const statsRes = await fetch("/api/stats");
|
||||||
|
const stats = await statsRes.json();
|
||||||
|
statsSummary.textContent = `${stats.total} Gerichte in der Datenbank`;
|
||||||
|
countLabel.textContent = `${dishes.length} ${dishes.length === 1 ? "Gericht" : "Gerichte"} angezeigt`;
|
||||||
|
|
||||||
|
if (dishes.length === 0) {
|
||||||
|
listContainer.innerHTML = `
|
||||||
|
<div class="loading-spinner">Keine Gerichte mit diesen Suchkriterien gefunden.</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
listContainer.innerHTML = "";
|
||||||
|
dishes.forEach(dish => {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "admin-dish-row";
|
||||||
|
const effortLabel = dish.effort.charAt(0).toUpperCase() + dish.effort.slice(1);
|
||||||
|
|
||||||
|
row.innerHTML = `
|
||||||
|
<div class="admin-dish-info">
|
||||||
|
<div class="admin-dish-name">${dish.name}</div>
|
||||||
|
<div class="admin-dish-badges">
|
||||||
|
${renderCategoryBadges(dish)}
|
||||||
|
<span class="badge-tag badge-effort-${dish.effort}">${effortLabel}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="admin-dish-actions">
|
||||||
|
<button class="btn-action-icon btn-edit" title="Bearbeiten" data-id="${dish.id}">✏️</button>
|
||||||
|
<button class="btn-action-icon btn-delete" title="Löschen" data-id="${dish.id}">🗑️</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
listContainer.appendChild(row);
|
||||||
|
|
||||||
|
row.querySelector(".btn-edit").addEventListener("click", () => openDishModal(dish));
|
||||||
|
row.querySelector(".btn-delete").addEventListener("click", () => confirmDeleteDish(dish));
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
listContainer.innerHTML = `<div class="loading-spinner" style="color: var(--danger);">Fehler beim Laden: ${err.message}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDishModal(dish = null) {
|
||||||
|
const modal = document.getElementById("dish-modal");
|
||||||
|
const modalTitle = document.getElementById("modal-title");
|
||||||
|
const formId = document.getElementById("dish-form-id");
|
||||||
|
const nameInput = document.getElementById("dish-name-input");
|
||||||
|
const catCheckboxes = document.querySelectorAll('input[name="modal-category"]');
|
||||||
|
|
||||||
|
if (dish) {
|
||||||
|
modalTitle.textContent = "Gericht bearbeiten";
|
||||||
|
formId.value = dish.id;
|
||||||
|
nameInput.value = dish.name;
|
||||||
|
const cats = getDishCategories(dish);
|
||||||
|
catCheckboxes.forEach(cb => {
|
||||||
|
cb.checked = cats.includes(cb.value);
|
||||||
|
});
|
||||||
|
const radio = document.querySelector(`input[name="modal-effort"][value="${dish.effort}"]`);
|
||||||
|
if (radio) radio.checked = true;
|
||||||
|
} else {
|
||||||
|
modalTitle.textContent = "Neues Gericht anlegen";
|
||||||
|
formId.value = "";
|
||||||
|
nameInput.value = "";
|
||||||
|
catCheckboxes.forEach(cb => {
|
||||||
|
cb.checked = false;
|
||||||
|
});
|
||||||
|
const radio = document.querySelector(`input[name="modal-effort"][value="mittel"]`);
|
||||||
|
if (radio) radio.checked = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
modal.showModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveDishFromModal() {
|
||||||
|
const modal = document.getElementById("dish-modal");
|
||||||
|
const formId = document.getElementById("dish-form-id").value;
|
||||||
|
const name = document.getElementById("dish-name-input").value.trim();
|
||||||
|
const selectedCats = Array.from(document.querySelectorAll('input[name="modal-category"]:checked')).map(cb => cb.value);
|
||||||
|
const effortRadio = document.querySelector('input[name="modal-effort"]:checked');
|
||||||
|
const effort = effortRadio ? effortRadio.value : "mittel";
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
showToast("Bitte gib einen Namen für das Gericht ein.", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (selectedCats.length === 0) {
|
||||||
|
showToast("Bitte wähle mindestens eine Kategorie aus.", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!effort) {
|
||||||
|
showToast("Bitte wähle den Aufwand aus.", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isEdit = !!formId;
|
||||||
|
const url = isEdit ? `/api/dishes/${formId}` : "/api/dishes";
|
||||||
|
const method = isEdit ? "PUT" : "POST";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: method,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ name, categories: selectedCats, effort })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.detail || "Fehler beim Speichern.");
|
||||||
|
|
||||||
|
showToast(isEdit ? "Gericht aktualisiert!" : "Gericht erfolgreich hinzugefügt!", "success");
|
||||||
|
modal.close();
|
||||||
|
loadAdminDishes();
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDeleteDish(dish) {
|
||||||
|
state.admin.deleteTargetId = dish.id;
|
||||||
|
const deleteModal = document.getElementById("delete-modal");
|
||||||
|
const textEl = document.getElementById("delete-modal-text");
|
||||||
|
textEl.innerHTML = `Möchtest du <strong>"${dish.name}"</strong> wirklich löschen?`;
|
||||||
|
deleteModal.showModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeDeleteDish(dishId) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/dishes/${dishId}`, { method: "DELETE" });
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.detail || "Fehler beim Löschen.");
|
||||||
|
|
||||||
|
showToast("Gericht wurde gelöscht.", "success");
|
||||||
|
loadAdminDishes();
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Toast System ---
|
||||||
|
function showToast(message, type = "info") {
|
||||||
|
const container = document.getElementById("toast-container");
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const toast = document.createElement("div");
|
||||||
|
toast.className = `toast toast-${type}`;
|
||||||
|
const icon = type === "success" ? "✅" : (type === "error" ? "⚠️" : "ℹ️");
|
||||||
|
|
||||||
|
toast.innerHTML = `<span>${icon}</span><span>${message}</span>`;
|
||||||
|
container.appendChild(toast);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.style.transition = "opacity 0.3s, transform 0.3s";
|
||||||
|
toast.style.opacity = "0";
|
||||||
|
toast.style.transform = "translateY(-10px)";
|
||||||
|
setTimeout(() => toast.remove(), 300);
|
||||||
|
}, 3500);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
../style.css
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
../style.css
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 233 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 422 KiB |
|
After Width: | Height: | Size: 191 KiB |
@@ -0,0 +1 @@
|
|||||||
|
../app.js
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
../app.js
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "What To Cook? – Essens-Zufallsgenerator",
|
||||||
|
"short_name": "WhatToCook",
|
||||||
|
"description": "Zufallsgenerator für Essen mit Kategorien und Aufwandsfilter",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#0f141c",
|
||||||
|
"theme_color": "#ff6b35",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/static/icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/static/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 203 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 254 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 660 KiB |
|
After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 183 KiB |
|
After Width: | Height: | Size: 200 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 167 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 158 KiB |
|
After Width: | Height: | Size: 291 KiB |
|
After Width: | Height: | Size: 230 KiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 544 KiB |
|
After Width: | Height: | Size: 844 KiB |
|
After Width: | Height: | Size: 597 KiB |
|
After Width: | Height: | Size: 280 KiB |
@@ -0,0 +1,227 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||||
|
<meta name="theme-color" content="#1e222d">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
|
<title>What To Cook? – Essens-Zufallsgenerator</title>
|
||||||
|
<link rel="manifest" href="/static/manifest.json">
|
||||||
|
<link rel="apple-touch-icon" href="/static/icon-192.png">
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🍽️</text></svg>">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app-container">
|
||||||
|
|
||||||
|
<!-- Top Header -->
|
||||||
|
<header class="app-header">
|
||||||
|
<div class="brand">
|
||||||
|
<span class="brand-icon">🍽️</span>
|
||||||
|
<div>
|
||||||
|
<h1 class="brand-title">What To Cook?</h1>
|
||||||
|
<p class="brand-subtitle">Zufallsgenerator für Essen</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Tab Navigation -->
|
||||||
|
<nav class="nav-tabs" role="tablist">
|
||||||
|
<button id="tab-btn-generator" class="nav-tab active" data-tab="generator" role="tab" aria-selected="true">
|
||||||
|
<span class="icon">🎲</span> Generator
|
||||||
|
</button>
|
||||||
|
<a href="/cookbook" class="nav-tab" style="text-decoration: none;">
|
||||||
|
<span class="icon">📖</span> Kochbuch
|
||||||
|
</a>
|
||||||
|
<a href="/cookbook/new" class="nav-tab" style="text-decoration: none;">
|
||||||
|
<span class="icon">➕</span> Rezept
|
||||||
|
</a>
|
||||||
|
<button id="tab-btn-admin" class="nav-tab" data-tab="admin" role="tab" aria-selected="false">
|
||||||
|
<span class="icon">⚙️</span> Gerichte
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Main Content Area -->
|
||||||
|
<main class="app-main">
|
||||||
|
|
||||||
|
<!-- ========================================== -->
|
||||||
|
<!-- TAB 1: GENERATOR -->
|
||||||
|
<!-- ========================================== -->
|
||||||
|
<section id="tab-generator" class="tab-panel active" role="tabpanel">
|
||||||
|
|
||||||
|
<!-- Banner / Hint -->
|
||||||
|
<div class="info-card">
|
||||||
|
<div class="info-content">
|
||||||
|
<span class="info-icon">💡</span>
|
||||||
|
<div>
|
||||||
|
<strong>Bis zu 5 Gerichte konfigurieren:</strong>
|
||||||
|
Wähle Kategorie & Aufwand pro Gericht. Nicht benötigte Gerichte einfach frei lassen.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="active-counter-badge" id="active-slots-counter">
|
||||||
|
<span id="active-slots-count">2</span> von 5 aktiv
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 5 Dish Configuration Slots -->
|
||||||
|
<div class="slots-container" id="slots-container">
|
||||||
|
<!-- Rendered dynamically by app.js (1 to 5) -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Floating / Sticky Action Bar -->
|
||||||
|
<div class="action-bar-wrapper">
|
||||||
|
<button id="btn-roll-all" class="btn btn-primary btn-hero">
|
||||||
|
<span class="btn-icon">🎲</span>
|
||||||
|
<span class="btn-text">Zufallsgenerator starten</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Results Section (Anchor / Modal or Inline display) -->
|
||||||
|
<section id="results-section" class="results-section hidden">
|
||||||
|
<div class="results-header">
|
||||||
|
<div class="results-title-group">
|
||||||
|
<h2 class="results-title">Ausgeloste Gerichte</h2>
|
||||||
|
<span class="results-badge" id="results-count-badge">0 Gerichte</span>
|
||||||
|
</div>
|
||||||
|
<button id="btn-reroll-all" class="btn btn-outline btn-sm">
|
||||||
|
<span class="icon">🔄</span> Alle neu würfeln
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="results-list" class="results-grid">
|
||||||
|
<!-- Dynamic Result Cards -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="results-footer">
|
||||||
|
<button id="btn-back-to-config" class="btn btn-secondary">
|
||||||
|
✏️ Filter anpassen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ========================================== -->
|
||||||
|
<!-- TAB 2: ADMIN / DATABASE -->
|
||||||
|
<!-- ========================================== -->
|
||||||
|
<section id="tab-admin" class="tab-panel" role="tabpanel" hidden>
|
||||||
|
|
||||||
|
<div class="admin-topbar">
|
||||||
|
<div>
|
||||||
|
<h2 class="section-title">Gerichte verwalten</h2>
|
||||||
|
<p class="section-desc" id="admin-stats-summary">Lade Datenbank...</p>
|
||||||
|
</div>
|
||||||
|
<button id="btn-open-add-dish" class="btn btn-primary">
|
||||||
|
<span>➕</span> Neues Gericht
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Admin Filters -->
|
||||||
|
<div class="admin-filters-card">
|
||||||
|
<div class="search-input-wrap">
|
||||||
|
<span class="search-icon">🔍</span>
|
||||||
|
<input type="text" id="admin-search-input" placeholder="Gericht nach Namen suchen..." autocomplete="off">
|
||||||
|
<button id="admin-search-clear" class="btn-clear hidden" title="Suche leeren">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="filter-row">
|
||||||
|
<div class="form-group flex-1">
|
||||||
|
<label for="admin-filter-category" class="filter-label">Kategorie:</label>
|
||||||
|
<select id="admin-filter-category" class="custom-select">
|
||||||
|
<option value="alle">Alle Kategorien</option>
|
||||||
|
<!-- Filled via JS -->
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group flex-1">
|
||||||
|
<label for="admin-filter-effort" class="filter-label">Aufwand:</label>
|
||||||
|
<select id="admin-filter-effort" class="custom-select">
|
||||||
|
<option value="alle">Jeder Aufwand</option>
|
||||||
|
<option value="gering">Gering 🟢</option>
|
||||||
|
<option value="mittel">Mittel 🟡</option>
|
||||||
|
<option value="hoch">Hoch 🔴</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dishes Table / Cards for Mobile -->
|
||||||
|
<div class="dishes-list-wrap">
|
||||||
|
<div class="dishes-list-header">
|
||||||
|
<span id="dishes-count-label">Gerichte</span>
|
||||||
|
</div>
|
||||||
|
<div id="admin-dishes-list" class="admin-dishes-list">
|
||||||
|
<div class="loading-spinner">Lade Gerichte...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Dialog: Add / Edit Dish -->
|
||||||
|
<dialog id="dish-modal" class="modal">
|
||||||
|
<div class="modal-box">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 id="modal-title" class="modal-title">Neues Gericht anlegen</h3>
|
||||||
|
<button id="btn-close-modal" class="btn-icon-close" aria-label="Schließen">✕</button>
|
||||||
|
</div>
|
||||||
|
<form id="dish-form" method="dialog">
|
||||||
|
<input type="hidden" id="dish-form-id" value="">
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="dish-name-input" class="form-label">Name des Gerichts *</label>
|
||||||
|
<input type="text" id="dish-name-input" class="text-input" placeholder="z. B. Spaghetti Carbonara" required autocomplete="off">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Kategorien * <span class="form-label-hint">(Mehrfachauswahl möglich)</span></label>
|
||||||
|
<div class="category-checkbox-group" id="modal-category-group">
|
||||||
|
<!-- Filled via JS as checkbox pills -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Arbeitsaufwand *</label>
|
||||||
|
<div class="effort-radio-group">
|
||||||
|
<label class="effort-radio-label">
|
||||||
|
<input type="radio" name="modal-effort" value="gering" required>
|
||||||
|
<span class="effort-pill effort-gering">Gering 🟢 (≤ 20 Min)</span>
|
||||||
|
</label>
|
||||||
|
<label class="effort-radio-label">
|
||||||
|
<input type="radio" name="modal-effort" value="mittel" checked required>
|
||||||
|
<span class="effort-pill effort-mittel">Mittel 🟡 (~ 45 Min)</span>
|
||||||
|
</label>
|
||||||
|
<label class="effort-radio-label">
|
||||||
|
<input type="radio" name="modal-effort" value="hoch" required>
|
||||||
|
<span class="effort-pill effort-hoch">Hoch 🔴 (> 60 Min)</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" id="btn-cancel-modal" class="btn btn-secondary">Abbrechen</button>
|
||||||
|
<button type="submit" id="btn-save-modal" class="btn btn-primary">Speichern</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<!-- Dialog: Delete Confirm -->
|
||||||
|
<dialog id="delete-modal" class="modal">
|
||||||
|
<div class="modal-box modal-box-sm">
|
||||||
|
<h3 class="modal-title">Gericht löschen?</h3>
|
||||||
|
<p id="delete-modal-text" class="modal-text">Möchtest du dieses Gericht wirklich aus der Datenbank entfernen?</p>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button id="btn-cancel-delete" class="btn btn-secondary">Abbrechen</button>
|
||||||
|
<button id="btn-confirm-delete" class="btn btn-danger">Unwiderruflich löschen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<!-- Toast Notifications -->
|
||||||
|
<div id="toast-container" class="toast-container" aria-live="polite"></div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/static/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||||
|
<title>Anmeldung — WTC Kochbuch</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Caveat:wght@600;700&family=Lora:ital,wght@0,400;0,600;1,400&family=Playfair+Display:ital,wght@0,600;0,700;0,800;1,400&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="/static/cookbook.css?v=4.0">
|
||||||
|
<style>
|
||||||
|
body.login-body {
|
||||||
|
background-color: var(--creme, #faf6ee);
|
||||||
|
background-image:
|
||||||
|
radial-gradient(circle at 100% 0%, rgba(212, 175, 55, 0.08) 0%, transparent 40%),
|
||||||
|
radial-gradient(circle at 0% 100%, rgba(30, 61, 47, 0.08) 0%, transparent 40%),
|
||||||
|
repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(0, 0, 0, 0.015) 2px, rgba(0, 0, 0, 0.015) 4px);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-body, 'Lora', Georgia, serif);
|
||||||
|
color: var(--schwarz, #1a1a1a);
|
||||||
|
}
|
||||||
|
.login-card {
|
||||||
|
background: #ffffff;
|
||||||
|
border: 3px solid var(--tannengruen, #1e3d2f);
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 6px 8px 0px rgba(30, 61, 47, 0.25);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
padding: 36px 30px;
|
||||||
|
text-align: center;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.login-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
background: var(--creme, #faf6ee);
|
||||||
|
border: 2px solid var(--gold, #d4af37);
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
box-shadow: 0 4px 10px rgba(0,0,0,0.06);
|
||||||
|
}
|
||||||
|
.login-title {
|
||||||
|
font-family: var(--font-hand, 'Caveat', cursive);
|
||||||
|
font-size: 2.2rem;
|
||||||
|
color: var(--tannengruen, #1e3d2f);
|
||||||
|
margin: 0 0 4px 0;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
.login-subtitle {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: #666;
|
||||||
|
margin: 0 0 24px 0;
|
||||||
|
}
|
||||||
|
.login-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.form-label {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--tannengruen, #1e3d2f);
|
||||||
|
}
|
||||||
|
.form-input {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 2px solid #ddd;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-family: inherit;
|
||||||
|
background: #fafafa;
|
||||||
|
transition: border-color 0.2s, background-color 0.2s;
|
||||||
|
}
|
||||||
|
.form-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--tannengruen, #1e3d2f);
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 0 0 3px rgba(30, 61, 47, 0.12);
|
||||||
|
}
|
||||||
|
.form-checkbox-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
color: #555;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.form-checkbox-row input {
|
||||||
|
accent-color: var(--tannengruen, #1e3d2f);
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.login-btn {
|
||||||
|
background: var(--tannengruen, #1e3d2f);
|
||||||
|
color: var(--creme, #faf6ee);
|
||||||
|
border: 2px solid var(--tannengruen, #1e3d2f);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-family: var(--font-hand, 'Caveat', cursive);
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 10px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 3px 4px 0px rgba(0, 0, 0, 0.15);
|
||||||
|
transition: all 0.18s ease;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.login-btn:hover {
|
||||||
|
background: #274f3d;
|
||||||
|
border-color: #274f3d;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 3px 5px 0px rgba(0, 0, 0, 0.22);
|
||||||
|
}
|
||||||
|
.login-btn:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
box-shadow: 1px 2px 0px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
.login-error {
|
||||||
|
background: #fdf2f2;
|
||||||
|
border: 1px solid #f8b4b4;
|
||||||
|
color: #9b1c1c;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.login-footer {
|
||||||
|
margin-top: 24px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="login-body">
|
||||||
|
<div class="login-card">
|
||||||
|
<div class="login-badge">📖</div>
|
||||||
|
<h1 class="login-title">WTC Kochbuch</h1>
|
||||||
|
<p class="login-subtitle">Privater Bereich • Bitte anmelden</p>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="login-error">
|
||||||
|
⚠️ {{ error }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="POST" action="/login" class="login-form">
|
||||||
|
<input type="hidden" name="next" value="{{ next_url or '/cookbook' }}">
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="username">Benutzername</label>
|
||||||
|
<input class="form-input" type="text" id="username" name="username" value="{{ username or 'admin' }}" required autofocus autocomplete="username">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="password">Passwort</label>
|
||||||
|
<input class="form-input" type="password" id="password" name="password" required autocomplete="current-password" placeholder="Passwort eingeben">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-checkbox-row">
|
||||||
|
<input type="checkbox" id="remember" name="remember" value="true" checked>
|
||||||
|
<label for="remember">Angemeldet bleiben (30 Tage)</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="login-btn">Anmelden ✨</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="login-footer">
|
||||||
|
What To Cook? • Privates Rezeptarchiv & Kochbuch
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{ recipe.title }} – Mein Kochbuch</title>
|
||||||
|
<link rel="stylesheet" href="/static/cookbook.css?v=4.4">
|
||||||
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🍽️</text></svg>">
|
||||||
|
</head>
|
||||||
|
<body class="cookbook-body">
|
||||||
|
|
||||||
|
<!-- Navigation Bar -->
|
||||||
|
<header class="cb-nav">
|
||||||
|
<a href="/cookbook" class="cb-nav-brand">
|
||||||
|
<span class="cb-brand-icon">📖</span>
|
||||||
|
<h1 class="cb-nav-title">Kochbuch</h1>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Desktop Navigation -->
|
||||||
|
<div class="cb-nav-actions desktop-nav-actions">
|
||||||
|
<a href="/cookbook" class="cb-btn cb-btn-outline">
|
||||||
|
<span>📖</span> <span>Alle Rezepte</span>
|
||||||
|
</a>
|
||||||
|
<a href="/cookbook/edit/{{ recipe.id }}" class="cb-btn cb-btn-green">
|
||||||
|
<span>✏️</span> <span>Bearbeiten</span>
|
||||||
|
</a>
|
||||||
|
<button onclick="window.print()" class="cb-btn cb-btn-outline" title="Rezeptseite drucken">
|
||||||
|
<span>🖨️</span> <span>Drucken</span>
|
||||||
|
</button>
|
||||||
|
<a href="/logout" class="cb-nav-btn cb-nav-btn-logout" title="Abmelden">
|
||||||
|
<span>🚪</span> <span>Abmelden</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile Kebab (3-Punkte) Menü -->
|
||||||
|
<div class="mobile-nav-wrapper">
|
||||||
|
<button type="button" class="cb-kebab-btn" onclick="toggleMobileMenu(event)" aria-label="Menü öffnen" title="Menü">
|
||||||
|
<span>⋮</span>
|
||||||
|
</button>
|
||||||
|
<div id="cb-mobile-menu" class="cb-mobile-dropdown">
|
||||||
|
<a href="/cookbook" class="cb-mobile-item">
|
||||||
|
<span>📖</span> <span>Zum Kochbuch</span>
|
||||||
|
</a>
|
||||||
|
<a href="/cookbook/edit/{{ recipe.id }}" class="cb-mobile-item">
|
||||||
|
<span>✏️</span> <span>Rezept bearbeiten</span>
|
||||||
|
</a>
|
||||||
|
<button type="button" onclick="window.print()" class="cb-mobile-item">
|
||||||
|
<span>🖨️</span> <span>Drucken & PDF</span>
|
||||||
|
</button>
|
||||||
|
<hr class="cb-mobile-divider">
|
||||||
|
<a href="/logout" class="cb-mobile-item cb-mobile-logout">
|
||||||
|
<span>🚪</span> <span>Abmelden</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="book-wrapper">
|
||||||
|
|
||||||
|
<!-- EINHEITLICHE REZEPTSEITE -->
|
||||||
|
<article class="page recipe-page">
|
||||||
|
|
||||||
|
<!-- Header (Ohne Kapitelangabe & ohne Quelle im Titel) -->
|
||||||
|
<header class="recipe-header">
|
||||||
|
<h2 class="recipe-title">{{ recipe.title }}</h2>
|
||||||
|
|
||||||
|
<div class="recipe-meta-row">
|
||||||
|
{% if recipe.effort %}
|
||||||
|
<div class="recipe-meta-item">
|
||||||
|
<span>⏱️</span>
|
||||||
|
<span>Aufwand:
|
||||||
|
{% if recipe.effort == 'gering' %}Gering 🟢{% elif recipe.effort == 'hoch' %}Hoch 🔴{% else %}Mittel 🟡{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if recipe.prep_time %}
|
||||||
|
<div class="recipe-meta-item">
|
||||||
|
<span>🔪</span>
|
||||||
|
<span>Zubereitung: {{ recipe.prep_time }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if recipe.cook_time %}
|
||||||
|
<div class="recipe-meta-item">
|
||||||
|
<span>🍳</span>
|
||||||
|
<span>Kochen/Backen: {{ recipe.cook_time }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if recipe.portions %}
|
||||||
|
<div class="recipe-meta-item">
|
||||||
|
<span>👥</span>
|
||||||
|
<span>{{ recipe.portions }} Portionen</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if recipe.source and recipe.source.strip() %}
|
||||||
|
<div class="recipe-meta-item">
|
||||||
|
<span>📖</span>
|
||||||
|
<span>Quelle: {{ recipe.source.strip() }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- 2-Spalten Layout -->
|
||||||
|
<div class="recipe-grid">
|
||||||
|
|
||||||
|
<!-- Linke Spalte: Foto + Zutaten -->
|
||||||
|
<aside class="recipe-side">
|
||||||
|
<div class="recipe-photo-box">
|
||||||
|
{% if recipe.image_url %}
|
||||||
|
<img src="{{ recipe.image_url }}" alt="{{ recipe.title }}">
|
||||||
|
{% else %}
|
||||||
|
<div class="recipe-photo-empty">
|
||||||
|
<span>📸</span>
|
||||||
|
<span>Kein Foto hinterlegt</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Zutatenleiste ohne Portionsangabe (da schon in Kopfzeile) -->
|
||||||
|
<div class="ingredients-box {% if recipe.has_portion_table %}has-portion-matrix{% endif %}">
|
||||||
|
<h4 class="section-hand-title">Zutaten</h4>
|
||||||
|
|
||||||
|
{% if recipe.has_portion_table and recipe.portion_matrix %}
|
||||||
|
<!-- Tabelle für 2/4/6/8 Portionen (z. B. Waffeln / Pancakes) -->
|
||||||
|
<table class="portion-matrix-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="matrix-th-ing">Zutat</th>
|
||||||
|
<th class="matrix-th-p">2 P.</th>
|
||||||
|
<th class="matrix-th-p">4 P.</th>
|
||||||
|
<th class="matrix-th-p">6 P.</th>
|
||||||
|
<th class="matrix-th-p">8 P.</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for row in recipe.portion_matrix %}
|
||||||
|
{% if row.is_header %}
|
||||||
|
<tr class="matrix-header-row">
|
||||||
|
<th colspan="5">{{ row.text }}</th>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr class="matrix-row">
|
||||||
|
<td class="matrix-td-name">{{ row.name }}</td>
|
||||||
|
<td class="matrix-td-amt">{{ row.amounts[2] }}</td>
|
||||||
|
<td class="matrix-td-amt highlight">{{ row.amounts[4] }}</td>
|
||||||
|
<td class="matrix-td-amt">{{ row.amounts[6] }}</td>
|
||||||
|
<td class="matrix-td-amt">{{ row.amounts[8] }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<!-- Normale kompakte Zutatenliste (kein Abschneiden im Druck) -->
|
||||||
|
<ul class="ingredients-list">
|
||||||
|
{% for line in recipe.ingredients.split('\n') %}
|
||||||
|
{% if line.strip() %}
|
||||||
|
{% set raw_line = line.strip() %}
|
||||||
|
{% if raw_line.endswith(':') or raw_line.lower().startswith('für ') %}
|
||||||
|
<li class="ing-subheading">{{ raw_line }}</li>
|
||||||
|
{% else %}
|
||||||
|
<li>{{ raw_line }}</li>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Rechte Spalte: Zubereitung + Notizen + Quelle -->
|
||||||
|
<section class="recipe-main">
|
||||||
|
<div>
|
||||||
|
<h4 class="section-hand-title">Zubereitung</h4>
|
||||||
|
<div class="instructions-steps">
|
||||||
|
{% for step in recipe.instructions.split('\n') %}
|
||||||
|
{% if step.strip() %}
|
||||||
|
<div class="step-card">
|
||||||
|
<span class="step-badge">{{ loop.index }}</span>
|
||||||
|
<div class="step-text">{{ step.strip() }}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if recipe.notes and recipe.notes.strip() %}
|
||||||
|
<div class="notes-box">
|
||||||
|
<div class="notes-box-title">💡 Tipp & Notiz</div>
|
||||||
|
<div>{{ recipe.notes.strip() }}</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="notes-box notes-box-empty print-only">
|
||||||
|
<div class="notes-box-title">💡 Eigene Notizen & Tipps</div>
|
||||||
|
<div class="notes-blank-lines">
|
||||||
|
<div class="notes-blank-line"></div>
|
||||||
|
<div class="notes-blank-line"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</article>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function toggleMobileMenu(e) {
|
||||||
|
if (e) e.stopPropagation();
|
||||||
|
const menu = document.getElementById('cb-mobile-menu');
|
||||||
|
if (menu) menu.classList.toggle('is-open');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (!e.target.closest('.mobile-nav-wrapper')) {
|
||||||
|
const menu = document.getElementById('cb-mobile-menu');
|
||||||
|
if (menu) menu.classList.remove('is-open');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{% if recipe %}Rezept bearbeiten{% else %}Neues Rezept eintragen{% endif %} – Mein Kochbuch</title>
|
||||||
|
<link rel="stylesheet" href="/static/cookbook.css?v=4.4">
|
||||||
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>📝</text></svg>">
|
||||||
|
</head>
|
||||||
|
<body class="cookbook-body">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="cb-nav">
|
||||||
|
<a href="/cookbook" class="cb-nav-brand">
|
||||||
|
<span class="cb-brand-icon">📖</span>
|
||||||
|
<h1 class="cb-nav-title">Kochbuch</h1>
|
||||||
|
</a>
|
||||||
|
<!-- Desktop Actions -->
|
||||||
|
<div class="cb-nav-actions desktop-nav-actions">
|
||||||
|
<a href="/cookbook" class="cb-btn cb-btn-creme" style="background: var(--tannengruen); color: var(--creme);">
|
||||||
|
<span>📖</span> <span>Zum Kochbuch</span>
|
||||||
|
</a>
|
||||||
|
<a href="/logout" class="cb-nav-btn cb-nav-btn-logout" title="Abmelden">
|
||||||
|
<span>🚪</span> <span>Abmelden</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile 3-Dots Kebab Menu -->
|
||||||
|
<div class="mobile-nav-wrapper">
|
||||||
|
<button type="button" class="cb-kebab-btn" id="mobileMenuBtn" aria-label="Menü öffnen" onclick="toggleMobileMenu()">
|
||||||
|
⋮
|
||||||
|
</button>
|
||||||
|
<div class="cb-mobile-dropdown" id="mobileDropdown">
|
||||||
|
<a href="/cookbook" class="cb-mobile-item">
|
||||||
|
<span>📖</span> Zum Kochbuch
|
||||||
|
</a>
|
||||||
|
<div class="cb-mobile-divider"></div>
|
||||||
|
<a href="/logout" class="cb-mobile-item" style="color: #c53030;">
|
||||||
|
<span>🚪</span> Abmelden
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="book-wrapper" style="max-width: 780px;">
|
||||||
|
|
||||||
|
<div class="form-card">
|
||||||
|
<h2 class="form-title">
|
||||||
|
{% if recipe %}✏️ Rezept bearbeiten{% else %}➕ Neues Rezept eintragen{% endif %}
|
||||||
|
</h2>
|
||||||
|
<p class="form-subtitle">
|
||||||
|
Trage dein Lieblingsrezept ein – mit Foto, Zutaten und Zubereitungsschritten.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form id="recipe-form" enctype="multipart/form-data">
|
||||||
|
{% if recipe %}
|
||||||
|
<input type="hidden" id="recipe-id" value="{{ recipe.id }}">
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Titel -->
|
||||||
|
<div class="cb-form-group">
|
||||||
|
<label for="title" class="cb-label">Name des Rezepts *</label>
|
||||||
|
<input type="text" id="title" name="title" class="cb-input"
|
||||||
|
value="{{ recipe.title if recipe else '' }}"
|
||||||
|
placeholder="z. B. Klassische Spaghetti Carbonara" required autocomplete="off">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Kategorie / Kapitel -->
|
||||||
|
<div class="cb-form-group">
|
||||||
|
<label for="chapter_number" class="cb-label">Kategorie / Kapitel *</label>
|
||||||
|
<select id="chapter_number" name="chapter_number" class="cb-select" required>
|
||||||
|
<option value="" disabled {% if not recipe and not selected_chapter %}selected{% endif %}>Kapitel wählen...</option>
|
||||||
|
{% for chap in chapters %}
|
||||||
|
<option value="{{ chap.number }}"
|
||||||
|
{% if (recipe and recipe.chapter_number == chap.number) or (not recipe and selected_chapter == chap.number) %}selected{% endif %}>
|
||||||
|
Kapitel {{ chap.number }}: {{ chap.title }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Metadaten: Aufwand, Zeiten, Portionen -->
|
||||||
|
<div class="cb-row">
|
||||||
|
<div class="cb-col">
|
||||||
|
<div class="cb-form-group">
|
||||||
|
<label for="effort" class="cb-label">Arbeitsaufwand *</label>
|
||||||
|
<select id="effort" name="effort" class="cb-select" required>
|
||||||
|
<option value="gering" {% if recipe and recipe.effort == 'gering' %}selected{% endif %}>Gering 🟢 (≤ 20 Min)</option>
|
||||||
|
<option value="mittel" {% if not recipe or recipe.effort == 'mittel' %}selected{% endif %}>Mittel 🟡 (~ 45 Min)</option>
|
||||||
|
<option value="hoch" {% if recipe and recipe.effort == 'hoch' %}selected{% endif %}>Hoch 🔴 (> 60 Min)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="cb-col">
|
||||||
|
<div class="cb-form-group">
|
||||||
|
<label for="portions" class="cb-label">Portionen</label>
|
||||||
|
<input type="number" id="portions" name="portions" class="cb-input"
|
||||||
|
value="{{ recipe.portions if recipe else 4 }}" min="1" max="50">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cb-row">
|
||||||
|
<div class="cb-col">
|
||||||
|
<div class="cb-form-group">
|
||||||
|
<label for="prep_time" class="cb-label">Zubereitungszeit</label>
|
||||||
|
<input type="text" id="prep_time" name="prep_time" class="cb-input"
|
||||||
|
value="{{ recipe.prep_time if recipe else '' }}" placeholder="z. B. 15 Min">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="cb-col">
|
||||||
|
<div class="cb-form-group">
|
||||||
|
<label for="cook_time" class="cb-label">Koch- / Backzeit</label>
|
||||||
|
<input type="text" id="cook_time" name="cook_time" class="cb-input"
|
||||||
|
value="{{ recipe.cook_time if recipe else '' }}" placeholder="z. B. 30 Min">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Checkbox: Multi-Portionen-Tabelle -->
|
||||||
|
<div class="cb-form-group" style="margin-top: 4px; margin-bottom: 20px; background: rgba(44, 74, 52, 0.05); padding: 10px 14px; border-radius: 8px; border: 1px dashed var(--tannengruen);">
|
||||||
|
<label style="display: flex; align-items: center; gap: 10px; cursor: pointer; font-size: 0.95rem; font-weight: 600; color: var(--dunkelgrau); margin-bottom: 0;">
|
||||||
|
<input type="checkbox" id="has_portion_table" name="has_portion_table" value="1"
|
||||||
|
{% if recipe and recipe.has_portion_table %}checked{% endif %}
|
||||||
|
style="width: 18px; height: 18px; accent-color: var(--tannengruen);">
|
||||||
|
<span>🔢 Zutatentabelle für 2 / 4 / 6 / 8 Portionen berechnen (z. B. für Waffeln, Pfannkuchen, Teige)</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Foto-Upload -->
|
||||||
|
<div class="cb-form-group">
|
||||||
|
<label class="cb-label">Foto des Gerichts 📸</label>
|
||||||
|
<div class="upload-dropzone" id="dropzone" onclick="document.getElementById('image-upload').click();">
|
||||||
|
<span style="font-size: 2.2rem;">📷</span>
|
||||||
|
<p style="font-family: var(--font-hand); font-size: 1.15rem; margin: 8px 0 4px 0; color: var(--tannengruen);">
|
||||||
|
Hier klicken, um ein Foto auszuwählen oder mit dem Smartphone aufzunehmen
|
||||||
|
</p>
|
||||||
|
<span style="font-size: 0.85rem; color: #777;">(JPG, PNG, WebP)</span>
|
||||||
|
<input type="file" id="image-upload" name="image_file" accept="image/*" style="display: none;">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="preview-container" id="preview-box" {% if recipe and recipe.image_url %}style="display: block;"{% endif %}>
|
||||||
|
<img id="image-preview" src="{{ recipe.image_url if recipe and recipe.image_url else '' }}" alt="Vorschau">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Zutaten -->
|
||||||
|
<div class="cb-form-group">
|
||||||
|
<label for="ingredients" class="cb-label">Zutaten * (eine Zutat pro Zeile)</label>
|
||||||
|
<textarea id="ingredients" name="ingredients" class="cb-textarea" rows="8"
|
||||||
|
placeholder="500g Pasta 150g Schinken oder Speck 4 Eigelb 50g Parmesan gerieben Salz und frisch gemahlener Pfeffer" required>{{ recipe.ingredients if recipe else '' }}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Zubereitungsschritte -->
|
||||||
|
<div class="cb-form-group">
|
||||||
|
<label for="instructions" class="cb-label">Zubereitung * (Absätze werden nummeriert)</label>
|
||||||
|
<textarea id="instructions" name="instructions" class="cb-textarea" rows="10"
|
||||||
|
placeholder="Reichlich Salzwasser in einem großen Topf zum Kochen bringen. Den Schinken in Streifen schneiden und knusprig anbraten. Die Eigelbe mit dem geriebenen Käse und Pfeffer cremig rühren. Pasta al dente kochen und mit etwas Nudelwasser vermengen..." required>{{ recipe.instructions if recipe else '' }}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Notizen & Tipps -->
|
||||||
|
<div class="cb-form-group">
|
||||||
|
<label for="notes" class="cb-label">💡 Tipps, Notizen & Kniffe (optional)</label>
|
||||||
|
<textarea id="notes" name="notes" class="cb-textarea" rows="3"
|
||||||
|
placeholder="Wichtig: Den Herd vor dem Zugeben der Eier-Käse-Mischung ausschalten, damit das Ei nicht stockt!">{{ recipe.notes if recipe else '' }}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Credits / Copyright / Quelle -->
|
||||||
|
<div class="cb-form-group">
|
||||||
|
<label for="source" class="cb-label">© Quelle / Credits / Copyright (optional)</label>
|
||||||
|
<input type="text" id="source" name="source" class="cb-input"
|
||||||
|
value="{{ recipe.source if recipe and recipe.source else '' }}"
|
||||||
|
placeholder="z. B. Von Oma Anna, Chefkoch.de, Jamie Oliver, Familienrezept">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Checkbox: Auch als Zufallsgenerator-Gericht anlegen -->
|
||||||
|
{% if not recipe %}
|
||||||
|
<div class="cb-form-group" style="margin-top: 10px;">
|
||||||
|
<label style="display: flex; align-items: center; gap: 10px; cursor: pointer; font-family: var(--font-hand); font-size: 1.1rem;">
|
||||||
|
<input type="checkbox" id="add_to_dishes" name="add_to_dishes" value="true" checked style="width: 20px; height: 20px; accent-color: var(--tannengruen);">
|
||||||
|
<span>Auch als Gericht für den Zufallsgenerator hinzufügen</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Error Feedback Banner -->
|
||||||
|
<div id="form-error-banner" style="display: none; background: #fff0f0; border: 2px solid #e53e3e; color: #9b1c1c; padding: 12px 16px; border-radius: 8px; margin-top: 15px; font-weight: 600;"></div>
|
||||||
|
|
||||||
|
<!-- Submit Button -->
|
||||||
|
<div style="display: flex; justify-content: flex-end; gap: 12px; margin-top: 24px;">
|
||||||
|
<a href="/cookbook" class="cb-btn cb-btn-outline" style="color: var(--schwarz); border-color: rgba(34,34,34,0.3);">
|
||||||
|
Abbrechen
|
||||||
|
</a>
|
||||||
|
<button type="submit" id="btn-submit" class="cb-btn cb-btn-creme" style="background: var(--tannengruen); color: var(--creme); font-size: 1.15rem; padding: 12px 24px;">
|
||||||
|
<span>💾</span> <span>Rezept speichern</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Live Image Preview & Compression Helper
|
||||||
|
const fileInput = document.getElementById('image-upload');
|
||||||
|
const previewBox = document.getElementById('preview-box');
|
||||||
|
const previewImg = document.getElementById('image-preview');
|
||||||
|
const errorBanner = document.getElementById('form-error-banner');
|
||||||
|
let preparedImageFile = null;
|
||||||
|
|
||||||
|
fileInput.addEventListener('change', async function() {
|
||||||
|
const file = this.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
// Vorschau anzeigen
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = function(e) {
|
||||||
|
previewImg.src = e.target.result;
|
||||||
|
previewBox.style.display = 'block';
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
|
||||||
|
// Foto für schnellen Upload komprimieren (max 1600px Breite/Höhe, JPEG 85%)
|
||||||
|
try {
|
||||||
|
preparedImageFile = await compressImageFile(file, 1600, 1600, 0.85);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Komprimierung fehlgeschlagen, Originaldatei wird verwendet:', err);
|
||||||
|
preparedImageFile = file;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function compressImageFile(file, maxWidth, maxHeight, quality) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
// Bei SVG oder WebP/GIF direkt original belassen
|
||||||
|
if (file.type === 'image/svg+xml' || file.type === 'image/gif') {
|
||||||
|
return resolve(file);
|
||||||
|
}
|
||||||
|
const img = new Image();
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
img.onload = () => {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
let w = img.width;
|
||||||
|
let h = img.height;
|
||||||
|
if (w > maxWidth || h > maxHeight) {
|
||||||
|
if (w > h) {
|
||||||
|
h = Math.round((h * maxWidth) / w);
|
||||||
|
w = maxWidth;
|
||||||
|
} else {
|
||||||
|
w = Math.round((w * maxHeight) / h);
|
||||||
|
h = maxHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = w;
|
||||||
|
canvas.height = h;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
ctx.drawImage(img, 0, 0, w, h);
|
||||||
|
canvas.toBlob((blob) => {
|
||||||
|
if (!blob) return resolve(file);
|
||||||
|
const safeName = file.name.replace(/\.[^/.]+$/, "") + ".jpg";
|
||||||
|
resolve(new File([blob], safeName, { type: "image/jpeg" }));
|
||||||
|
}, 'image/jpeg', quality);
|
||||||
|
};
|
||||||
|
img.onerror = () => {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
resolve(file);
|
||||||
|
};
|
||||||
|
img.src = url;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Form Submit (AJAX multipart)
|
||||||
|
const form = document.getElementById('recipe-form');
|
||||||
|
const btnSubmit = document.getElementById('btn-submit');
|
||||||
|
const recipeIdEl = document.getElementById('recipe-id');
|
||||||
|
|
||||||
|
form.addEventListener('submit', async function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
errorBanner.style.display = 'none';
|
||||||
|
errorBanner.textContent = '';
|
||||||
|
btnSubmit.disabled = true;
|
||||||
|
btnSubmit.innerHTML = '<span>⏳</span> Speichern...';
|
||||||
|
|
||||||
|
const formData = new FormData(form);
|
||||||
|
|
||||||
|
// Wenn ein Bild komprimiert vorliegt, dieses im FormData setzen
|
||||||
|
if (preparedImageFile) {
|
||||||
|
formData.set('image_file', preparedImageFile);
|
||||||
|
} else if (fileInput.files.length === 0) {
|
||||||
|
formData.delete('image_file');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Checkbox Wert explizit absichern
|
||||||
|
const addDishCb = document.getElementById('add_to_dishes');
|
||||||
|
if (addDishCb) {
|
||||||
|
formData.set('add_to_dishes', addDishCb.checked ? 'true' : 'false');
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasPortionTableCb = document.getElementById('has_portion_table');
|
||||||
|
if (hasPortionTableCb) {
|
||||||
|
formData.set('has_portion_table', hasPortionTableCb.checked ? '1' : '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
const isEdit = !!recipeIdEl;
|
||||||
|
const url = isEdit ? `/api/recipes/${recipeIdEl.value}` : '/api/recipes';
|
||||||
|
const method = isEdit ? 'PUT' : 'POST';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: method,
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
let data;
|
||||||
|
const textResponse = await res.text();
|
||||||
|
try {
|
||||||
|
data = JSON.parse(textResponse);
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`Serverfehler (${res.status}): ${textResponse.slice(0, 150)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const detailMsg = (typeof data.detail === 'string')
|
||||||
|
? data.detail
|
||||||
|
: (Array.isArray(data.detail) ? data.detail.map(d => d.msg || d).join(', ') : 'Fehler beim Speichern');
|
||||||
|
throw new Error(detailMsg);
|
||||||
|
}
|
||||||
|
|
||||||
|
const recipeId = data.recipe ? data.recipe.id : (recipeIdEl ? recipeIdEl.value : '');
|
||||||
|
window.location.href = `/cookbook#rezept-${recipeId}`;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Fehler beim Speichern:', err);
|
||||||
|
errorBanner.textContent = `❌ ${err.message}`;
|
||||||
|
errorBanner.style.display = 'block';
|
||||||
|
btnSubmit.disabled = false;
|
||||||
|
btnSubmit.innerHTML = '<span>💾</span> Rezept speichern';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mobile Kebab Menu Functions
|
||||||
|
function toggleMobileMenu() {
|
||||||
|
const dd = document.getElementById('mobileDropdown');
|
||||||
|
if (dd) dd.classList.toggle('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
const wrap = document.querySelector('.mobile-nav-wrapper');
|
||||||
|
if (wrap && !wrap.contains(e.target)) {
|
||||||
|
const dd = document.getElementById('mobileDropdown');
|
||||||
|
if (dd) dd.classList.remove('show');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||