Feature: Einstiegsseite, Einkaufsliste, Wochenplan-Live-Suche & Zufallsgenerator-Optimierung

This commit is contained in:
2026-10-15 13:05:08 +02:00
parent 2b8c4a35cf
commit 0d6d0deb69
6 changed files with 2240 additions and 67 deletions
+173 -1
View File
@@ -193,6 +193,20 @@ def init_db():
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS shopping_list (
id INT AUTO_INCREMENT PRIMARY KEY,
item_name VARCHAR(255) NOT NULL,
amount VARCHAR(50) DEFAULT '',
unit VARCHAR(50) DEFAULT '',
checked TINYINT(1) NOT NULL DEFAULT 0,
source VARCHAR(255) DEFAULT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_shop_checked (checked)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
""")
cursor.execute("DELETE FROM dishes")
else:
cursor = conn.cursor()
@@ -251,6 +265,19 @@ def init_db():
)
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS shopping_list (
id INTEGER PRIMARY KEY AUTOINCREMENT,
item_name TEXT NOT NULL,
amount TEXT DEFAULT '',
unit TEXT DEFAULT '',
checked INTEGER NOT NULL DEFAULT 0,
source TEXT DEFAULT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
cursor.execute("DELETE FROM dishes")
conn.commit()
finally:
@@ -461,7 +488,9 @@ def query_recipe_candidates(chapter_number: Optional[int] = None, efforts: Optio
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)
# Standard beim Zufallsgenerator: Kategorien Desserts (9), Kuchen & Gebäck (10) und Plätzchen (11) ausschließen
EXCLUDED_SHUFFLE_CHAPTERS = {9, 10, 11}
pool = [r for r in all_recipes if r.get("chapter_number") not in EXCLUDED_SHUFFLE_CHAPTERS]
if not pool:
return []
@@ -752,3 +781,146 @@ def clear_meal_plan_week(year: int, week: int) -> int:
return res.get("rowcount", 0)
# --- Shopping List (Einkaufsliste) ---
def get_shopping_list() -> List[Dict[str, Any]]:
"""Gibt alle Einträge der Einkaufsliste zurück, sortiert nach unvollständig/erledigt."""
query = """
SELECT id, item_name, amount, unit, checked, source, created_at, updated_at
FROM shopping_list
ORDER BY checked ASC, id DESC
"""
rows = execute_query(query, fetch="all")
for r in rows:
r["checked"] = bool(r.get("checked"))
return rows
def add_shopping_item(item_name: str, amount: str = "", unit: str = "", source: str = "") -> Dict[str, Any]:
"""Fügt einen einzelnen Eintrag zur Einkaufsliste hinzu."""
query = "INSERT INTO shopping_list (item_name, amount, unit, source, checked) VALUES (?, ?, ?, ?, 0)"
res = execute_query(query, (item_name.strip(), amount.strip(), unit.strip(), source.strip()), fetch="none", commit=True)
item_id = res.get("lastrowid")
row = execute_query("SELECT * FROM shopping_list WHERE id = ?", (item_id,), fetch="one")
if row:
row["checked"] = bool(row.get("checked"))
return row
def add_shopping_items_bulk(items: List[Dict[str, Any]]) -> int:
"""Fügt mehrere Zutaten/Einträge auf einmal zur Einkaufsliste hinzu."""
if not items:
return 0
count = 0
for it in items:
name = (it.get("item_name") or it.get("name") or it.get("raw") or "").strip()
if not name:
continue
amt = str(it.get("amount") or "").strip()
unit = str(it.get("unit") or "").strip()
src = str(it.get("source") or it.get("recipe_title") or "").strip()
query = "INSERT INTO shopping_list (item_name, amount, unit, source, checked) VALUES (?, ?, ?, ?, 0)"
execute_query(query, (name, amt, unit, src), fetch="none", commit=True)
count += 1
return count
def toggle_shopping_item(item_id: int) -> Optional[Dict[str, Any]]:
"""Schaltet den Erledigt-Status eines Eintrags um."""
query = "UPDATE shopping_list SET checked = CASE WHEN checked = 1 THEN 0 ELSE 1 END WHERE id = ?"
execute_query(query, (item_id,), fetch="none", commit=True)
row = execute_query("SELECT * FROM shopping_list WHERE id = ?", (item_id,), fetch="one")
if row:
row["checked"] = bool(row.get("checked"))
return row
def delete_shopping_item(item_id: int) -> bool:
"""Löscht einen einzelnen Eintrag aus der Einkaufsliste."""
query = "DELETE FROM shopping_list WHERE id = ?"
res = execute_query(query, (item_id,), fetch="none", commit=True)
return res.get("rowcount", 0) > 0
def delete_checked_shopping_items() -> int:
"""Löscht alle bereits abgehakten Einträge aus der Einkaufsliste."""
query = "DELETE FROM shopping_list WHERE checked = 1"
res = execute_query(query, fetch="none", commit=True)
return res.get("rowcount", 0)
def clear_shopping_list() -> int:
"""Leert die gesamte Einkaufsliste."""
query = "DELETE FROM shopping_list"
res = execute_query(query, fetch="none", commit=True)
return res.get("rowcount", 0)
def get_meal_plan_ingredients(year: int, week: int) -> List[Dict[str, Any]]:
"""Gibt alle Zutaten aller Rezepte des Wochenplans strukturiert für die Einkaufsliste zurück."""
slots = get_meal_plan(year, week)
if not slots:
return []
from .ingredients import parse_ingredients_block
all_recipes = {r["id"]: r for r in get_all_recipes()}
day_names = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"]
meal_labels = {"frueh": "Früh", "mittag": "Mittag", "kaffee": "Kaffee & Kuchen", "abend": "Abend"}
recipe_groups = []
seen_slots = set()
for s in slots:
r_id = s.get("recipe_id")
if not r_id or r_id not in all_recipes:
continue
recipe = all_recipes[r_id]
d_idx = s.get("day_of_week", 0)
day_str = day_names[d_idx] if 0 <= d_idx < 7 else f"Tag {d_idx}"
m_type = s.get("meal_type", "")
m_label = meal_labels.get(m_type, m_type.capitalize())
slot_key = f"{d_idx}_{m_type}_{r_id}"
if slot_key in seen_slots:
continue
seen_slots.add(slot_key)
slot_label = f"{day_str} {m_label}"
parsed = parse_ingredients_block(recipe.get("ingredients", ""))
items = []
for line in parsed:
if line.get("is_header"):
continue
raw_text = line.get("raw", "").strip()
if not raw_text:
continue
name = line.get("name") or raw_text
amt = str(line.get("amount") or "") if line.get("amount") is not None else ""
unit = line.get("unit") or ""
items.append({
"recipe_id": recipe["id"],
"recipe_title": recipe["title"],
"slot_label": slot_label,
"raw": raw_text,
"name": name,
"amount": amt,
"unit": unit
})
if items:
recipe_groups.append({
"recipe_id": recipe["id"],
"recipe_title": recipe["title"],
"slot_label": slot_label,
"items": items
})
return recipe_groups
+134 -5
View File
@@ -41,7 +41,15 @@ from .database import (
get_meal_plan,
set_meal_plan_slot,
delete_meal_plan_slot,
clear_meal_plan_week
clear_meal_plan_week,
get_shopping_list,
add_shopping_item,
add_shopping_items_bulk,
toggle_shopping_item,
delete_shopping_item,
delete_checked_shopping_items,
clear_shopping_list,
get_meal_plan_ingredients
)
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
@@ -172,6 +180,15 @@ class MealPlanSlotRequest(BaseModel):
recipe_id: Optional[int] = None
custom_text: Optional[str] = None
class ShoppingItemCreate(BaseModel):
item_name: str
amount: Optional[str] = ""
unit: Optional[str] = ""
source: Optional[str] = ""
class ShoppingBulkCreate(BaseModel):
items: List[ShoppingItemCreate]
# ==========================================
# AUTH VIEWS
@@ -238,10 +255,43 @@ async def logout():
# VIEWS
# ==========================================
@app.get("/")
async def index_view():
"""Hauptroute leitet direkt auf das Kochbuch um"""
return RedirectResponse(url="/cookbook", status_code=302)
@app.get("/", response_class=HTMLResponse)
async def home_view(request: Request):
"""Einstiegsseite: Ausgeklapptes Menü / Hub"""
all_recipes = get_all_recipes()
today = date.today()
iso_year, iso_week, _ = today.isocalendar()
shopping_items = get_shopping_list()
open_shopping_count = sum(1 for it in shopping_items if not it.get("checked"))
return templates.TemplateResponse(
request=request,
name="home.html",
context={
"recipe_count": len(all_recipes),
"current_year": iso_year,
"current_week": iso_week,
"open_shopping_count": open_shopping_count,
"chapters": CHAPTERS
}
)
@app.get("/shopping-list", response_class=HTMLResponse)
@app.get("/groceries", response_class=HTMLResponse)
async def shopping_list_view(request: Request):
"""Einkaufsliste Übersicht"""
items = get_shopping_list()
today = date.today()
iso_year, iso_week, _ = today.isocalendar()
return templates.TemplateResponse(
request=request,
name="shopping_list.html",
context={
"items": items,
"current_year": iso_year,
"current_week": iso_week
}
)
@app.get("/cookbook", response_class=HTMLResponse)
async def cookbook_view(
@@ -854,3 +904,82 @@ def api_clear_mealplan_week(year: int = Query(...), week: int = Query(...)):
count = clear_meal_plan_week(year, week)
return {"status": "ok", "cleared_count": count}
# ==========================================
# SHOPPING LIST API
# ==========================================
@app.get("/api/shopping-list")
def api_get_shopping_list():
items = get_shopping_list()
return {"status": "ok", "items": items}
@app.post("/api/shopping-list")
def api_add_shopping_item(payload: ShoppingItemCreate):
if not payload.item_name.strip():
raise HTTPException(status_code=400, detail="Name des Eintrags darf nicht leer sein.")
item = add_shopping_item(
item_name=payload.item_name,
amount=payload.amount or "",
unit=payload.unit or "",
source=payload.source or ""
)
return {"status": "ok", "item": item}
@app.post("/api/shopping-list/bulk")
def api_add_shopping_bulk(payload: ShoppingBulkCreate):
items_dicts = [it.model_dump() for it in payload.items]
count = add_shopping_items_bulk(items_dicts)
return {"status": "ok", "added_count": count}
@app.post("/api/shopping-list/{item_id}/toggle")
def api_toggle_shopping_item(item_id: int):
updated = toggle_shopping_item(item_id)
if not updated:
raise HTTPException(status_code=404, detail="Eintrag nicht gefunden.")
return {"status": "ok", "item": updated}
@app.delete("/api/shopping-list/checked")
def api_delete_checked_shopping():
count = delete_checked_shopping_items()
return {"status": "ok", "deleted_count": count}
@app.delete("/api/shopping-list/all")
def api_clear_shopping_list():
count = clear_shopping_list()
return {"status": "ok", "cleared_count": count}
@app.delete("/api/shopping-list/{item_id}")
def api_delete_shopping_item(item_id: int):
success = delete_shopping_item(item_id)
return {"status": "ok", "deleted": success}
# ==========================================
# MEAL PLAN INGREDIENTS API
# ==========================================
@app.get("/api/mealplan/ingredients")
def api_get_mealplan_ingredients(
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
groups = get_meal_plan_ingredients(y, w)
return {
"status": "ok",
"year": y,
"week": w,
"groups": groups
}
+194
View File
@@ -2120,4 +2120,198 @@ body.modal-open {
/* Hinweis: A4 Druckregeln befinden sich zentral in app/templates/cookbook.html (<style id="print-engine-styles">) */
/* ==========================================
ZURÜCK ZUM SHUFFLE FLOATING BANNER
========================================== */
.shuffle-return-banner {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 1500;
display: flex;
align-items: center;
background: var(--rostrot, #c53030);
color: #ffffff;
border-radius: 50px;
padding: 6px 14px 6px 18px;
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.35);
transition: transform 0.2s ease, opacity 0.2s ease;
border: 2px solid #ffffff;
}
.shuffle-return-banner:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
}
.shuffle-return-btn {
background: transparent;
border: none;
color: #ffffff;
font-family: var(--font-sans);
font-size: 0.95rem;
font-weight: 700;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
}
.shuffle-return-dismiss {
background: transparent;
border: none;
color: rgba(255, 255, 255, 0.75);
font-size: 1.15rem;
font-weight: bold;
cursor: pointer;
margin-left: 10px;
padding: 2px 6px;
border-radius: 50%;
line-height: 1;
}
.shuffle-return-dismiss:hover {
color: #ffffff;
background: rgba(255, 255, 255, 0.25);
}
/* ==========================================
SLOT-EDITOR REZEPT LIVE-SUCHE
========================================== */
.slot-recipe-search-container {
position: relative;
margin-bottom: 14px;
}
.slot-search-input-wrapper {
position: relative;
display: flex;
align-items: center;
}
.slot-search-dropdown {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
max-height: 240px;
overflow-y: auto;
background: #ffffff;
border: 2px solid var(--tannengruen);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
z-index: 2400;
padding: 4px 0;
-webkit-overflow-scrolling: touch;
}
.slot-search-item {
padding: 8px 12px;
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
transition: background 0.12s ease;
}
.slot-search-item:last-child {
border-bottom: none;
}
.slot-search-item:hover,
.slot-search-item.is-selected {
background: rgba(30, 77, 59, 0.08);
}
.slot-search-item-title {
font-weight: 600;
font-size: 0.95rem;
color: var(--schwarz);
}
.slot-search-item-meta {
font-size: 0.78rem;
color: #666;
display: flex;
align-items: center;
gap: 6px;
}
/* ==========================================
WOCHENPLAN ZUTATEN CHECKLISTE
========================================== */
.mpi-group {
margin-bottom: 16px;
background: rgba(30, 77, 59, 0.04);
border: 1.5px solid rgba(30, 77, 59, 0.15);
border-radius: 10px;
padding: 12px 14px;
}
.mpi-group-header {
font-family: var(--font-hand);
font-size: 1.25rem;
font-weight: 700;
color: var(--tannengruen);
margin-bottom: 8px;
display: flex;
justify-content: space-between;
align-items: center;
}
.mpi-group-badge {
font-family: var(--font-sans);
font-size: 0.75rem;
font-weight: 600;
background: var(--tannengruen);
color: var(--creme);
padding: 2px 8px;
border-radius: 12px;
}
.mpi-item {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 8px;
background: #ffffff;
border: 1px solid rgba(34, 34, 34, 0.1);
border-radius: 6px;
margin-bottom: 5px;
cursor: pointer;
transition: all 0.12s;
}
.mpi-item:hover {
background: rgba(30, 77, 59, 0.05);
border-color: var(--tannengruen);
}
.mpi-item.is-unchecked {
opacity: 0.55;
background: #f8f8f8;
}
.mpi-checkbox {
width: 18px;
height: 18px;
accent-color: var(--tannengruen);
cursor: pointer;
}
.mpi-item-name {
font-size: 0.95rem;
font-weight: 500;
flex: 1;
}
.mpi-item-qty {
font-weight: 700;
color: var(--tannengruen);
font-size: 0.9rem;
}
+589 -61
View File
@@ -488,6 +488,12 @@
<!-- Desktop Buttons (auf Bildschirmen > 768px) -->
<div class="cb-nav-actions desktop-nav-actions">
<a href="/" class="cb-nav-btn" title="Menü-Übersicht öffnen">
<span>🏠</span> <span>Menü</span>
</a>
<a href="/shopping-list" class="cb-nav-btn" title="Einkaufsliste öffnen">
<span>🛒</span> <span>Einkaufsliste</span>
</a>
<button type="button" onclick="openMealPlanModal(event)" class="cb-nav-btn" title="Wochenmenü-Plan öffnen">
<span>📅</span> <span>Wochenplan</span>
</button>
@@ -511,6 +517,12 @@
<span>⋮</span>
</button>
<div id="cb-mobile-menu" class="cb-mobile-dropdown">
<a href="/" class="cb-mobile-item">
<span>🏠</span> <span>Menü-Übersicht</span>
</a>
<a href="/shopping-list" class="cb-mobile-item">
<span>🛒</span> <span>Einkaufsliste</span>
</a>
<button type="button" onclick="handleMobileMenuAction(openMealPlanModal)" class="cb-mobile-item">
<span>📅</span> <span>Wochenmenü-Plan</span>
</button>
@@ -873,13 +885,27 @@
</main>
<!-- Floating Return Banner: Zurück zum Zufallsgenerator (wenn von dort weitergeleitet) -->
<div id="shuffle-return-banner" class="shuffle-return-banner no-print" style="display: none;">
<button type="button" class="shuffle-return-btn" onclick="returnToShuffle()">
<span style="font-size: 1.2rem;">🎲</span>
<span>Zurück zum Zufallsgenerator</span>
</button>
<button type="button" class="shuffle-return-dismiss" onclick="dismissShuffleBanner()" title="Banner ausblenden">✕</button>
</div>
<!-- ========================================== -->
<!-- MODAL 0: WOCHENMENÜ-PLAN (no-print) -->
<!-- ========================================== -->
<div id="modal-mealplan" class="cb-modal-backdrop no-print" onclick="onBackdropClick(event, 'modal-mealplan')">
<div class="cb-modal-card" style="max-width: 960px;">
<div class="cb-modal-header">
<h3 class="cb-modal-title"><span>📅</span> Wochenmenü-Plan</h3>
<div style="display: flex; align-items: center; gap: 10px;">
<h3 class="cb-modal-title" style="margin: 0;"><span>📅</span> Wochenmenü-Plan</h3>
<button type="button" id="mp-btn-back-to-shuffle" class="cb-btn cb-btn-outline" onclick="returnToShuffleFromMealPlan()" style="display: none; color: var(--rostrot); border-color: var(--rostrot); font-size: 0.82rem; padding: 4px 10px;">
🎲 Zurück zum Shuffle
</button>
</div>
<button type="button" class="cb-modal-close" onclick="closeMealPlanModal()" title="Schließen">&times;</button>
</div>
<div class="cb-modal-body">
@@ -904,7 +930,10 @@
<span id="mp-today-pill" class="mealplan-today-tag" style="display: none;">Aktuelle Woche</span>
</div>
<div style="display: flex; gap: 6px;">
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
<button type="button" class="cb-btn cb-btn-creme" style="background: var(--tannengruen); color: var(--creme); font-size: 0.85rem; padding: 5px 12px; display: inline-flex; align-items: center; gap: 6px;" onclick="openMealPlanIngredientsModal()" title="Zutaten dieser Woche auf die Einkaufsliste setzen">
<span>🛒</span> <span>Zutaten auf Einkaufsliste</span>
</button>
<button type="button" class="cb-btn cb-btn-outline" style="color: #666; border-color: #ccc; font-size: 0.85rem; padding: 5px 10px;" onclick="clearCurrentMealPlanWeek()" title="Ganze Woche leeren">
🗑️ Woche leeren
</button>
@@ -931,7 +960,7 @@
<input type="hidden" id="se-day">
<input type="hidden" id="se-type" value="mittag">
<!-- Mahlzeittyp-Auswahl: Früh, Mittag, Abend -->
<!-- Mahlzeittyp-Auswahl: Früh, Mittag, Kaffee, Abend -->
<div style="margin-bottom: 14px;">
<label class="cb-label">Mahlzeit:</label>
<div class="slot-type-chips">
@@ -942,18 +971,37 @@
</div>
</div>
<div style="margin-bottom: 14px;">
<label class="cb-label">Rezept aus dem Kochbuch wählen:</label>
<select id="se-recipe-select" class="cb-select" onchange="onSlotRecipeSelect(this.value)">
<option value="">-- Kein Rezept / Freitext nutzen --</option>
</select>
<!-- Live-Suchfeld für Rezepte statt starrer Dropdown -->
<div class="slot-recipe-search-container">
<label class="cb-label" for="se-recipe-search-input">Rezept aus dem Kochbuch suchen:</label>
<div class="slot-search-input-wrapper">
<input type="search" id="se-recipe-search-input" class="cb-input"
placeholder="🔍 Rezept suchen (z. B. Waffeln, Chili, Suppe...)"
oninput="onSlotRecipeSearchInput(this.value)"
onfocus="onSlotRecipeSearchFocus()"
autocomplete="off">
<input type="hidden" id="se-recipe-id" value="">
</div>
<!-- Badge für ausgewähltes Rezept -->
<div id="se-selected-recipe-badge" style="display: none; margin-top: 8px; background: rgba(30, 77, 59, 0.08); border: 1.5px solid var(--tannengruen); border-radius: 6px; padding: 6px 12px; justify-content: space-between; align-items: center;">
<span style="font-weight: 700; color: var(--tannengruen); font-size: 0.92rem;">
✅ Ausgewählt: <span id="se-selected-recipe-name"></span>
</span>
<button type="button" onclick="clearSlotRecipeSelection()" style="background: transparent; border: none; color: #c53030; font-weight: 700; cursor: pointer; font-size: 0.85rem;">
✕ Entfernen
</button>
</div>
<!-- Live Suchergebnisse Dropdown -->
<div id="se-recipe-search-dropdown" class="slot-search-dropdown" style="display: none;"></div>
</div>
<div style="text-align: center; margin: 10px 0; font-family: var(--font-hand); color: #888;">— ODER —</div>
<div style="margin-bottom: 16px;">
<label class="cb-label">Freitext (z. B. Restaurant, Reste, Brotzeit):</label>
<input type="text" id="se-custom-text" class="cb-input" placeholder="z. B. Essen gehen, Brotzeit, Pizza" autocomplete="off">
<input type="text" id="se-custom-text" class="cb-input" placeholder="z. B. Essen gehen, Brotzeit, Pizza" autocomplete="off" oninput="onSlotCustomTextInput(this.value)">
</div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-top: 18px; padding-top: 12px; border-top: 1.5px dashed rgba(34,34,34,0.15); flex-wrap: wrap; gap: 8px;">
@@ -978,6 +1026,94 @@
</div>
</div>
<!-- ========================================== -->
<!-- MODAL: WOCHENPLAN ZUTATEN AUF EINKAUFSLISTE-->
<!-- ========================================== -->
<div id="modal-mealplan-ingredients" class="cb-modal-backdrop no-print" onclick="onBackdropClick(event, 'modal-mealplan-ingredients')" style="z-index: 2300;">
<div class="cb-modal-card" style="max-width: 640px;">
<div class="cb-modal-header">
<h3 class="cb-modal-title"><span>🛒</span> Zutaten für die Einkaufsliste</h3>
<button type="button" class="cb-modal-close" onclick="closeMealPlanIngredientsModal()" title="Schließen">&times;</button>
</div>
<div class="cb-modal-body">
<p style="font-size: 0.92rem; color: #555; margin: 0 0 12px 0;">
Wähle die Zutaten der geplanten Gerichte für <strong id="mpi-week-label">diese Woche</strong> aus, die du auf die Einkaufsliste übertragen möchtest:
</p>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; padding: 8px 12px; background: #fff; border: 1.5px solid rgba(34,34,34,0.15); border-radius: 8px;">
<label style="display: flex; align-items: center; gap: 8px; font-size: 0.9rem; font-weight: 700; cursor: pointer; color: var(--tannengruen); margin: 0;">
<input type="checkbox" id="mpi-toggle-all" checked onchange="toggleAllMpiCheckboxes(this.checked)" style="width: 18px; height: 18px; accent-color: var(--tannengruen);">
<span>Alle auswählen / abwählen</span>
</label>
<span id="mpi-total-count" style="font-size: 0.85rem; color: #666; font-weight: 600;">0 Zutaten</span>
</div>
<!-- Scrollbare Zutaten-Liste -->
<div id="mpi-ingredients-container" style="max-height: 380px; overflow-y: auto; padding-right: 4px;">
<!-- Dynamisch via JS -->
</div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-top: 18px; padding-top: 12px; border-top: 1.5px dashed rgba(34,34,34,0.15); flex-wrap: wrap; gap: 8px;">
<button type="button" onclick="closeMealPlanIngredientsModal()" class="cb-btn cb-btn-modal-cancel">
Abbrechen
</button>
<div style="display: flex; gap: 8px;">
<a href="/shopping-list" class="cb-btn cb-btn-outline" style="color: var(--schwarz); border-color: rgba(34,34,34,0.3); font-size: 0.9rem;">
<span>🛒</span> Zur Einkaufsliste
</a>
<button type="button" id="mpi-btn-transfer" onclick="transferMpiToShoppingList()" class="cb-btn cb-btn-creme" style="background: var(--tannengruen); color: var(--creme); font-weight: 700; font-size: 0.95rem; padding: 8px 18px;">
<span>➕</span> <span>Ausgewählte übertragen</span>
</button>
</div>
</div>
</div>
</div>
</div>
<!-- ========================================== -->
<!-- MODAL: SHUFFLE-GERICHT IN WOCHENPLAN -->
<!-- ========================================== -->
<div id="modal-shuffle-assign" class="cb-modal-backdrop no-print" onclick="onBackdropClick(event, 'modal-shuffle-assign')" style="z-index: 2250;">
<div class="cb-modal-card" style="max-width: 440px;">
<div class="cb-modal-header">
<h4 class="cb-modal-title"><span>📅</span> In Wochenplan einfügen</h4>
<button type="button" class="cb-modal-close" onclick="closeShuffleAssignModal()">&times;</button>
</div>
<div class="cb-modal-body">
<input type="hidden" id="sa-recipe-id">
<div style="margin-bottom: 14px; background: rgba(30,77,59,0.06); padding: 10px 14px; border-radius: 8px; border: 1.5px solid rgba(30,77,59,0.2);">
<span style="font-size: 0.8rem; color: #666; display: block; text-transform: uppercase; letter-spacing: 0.5px; font-weight: 700;">Rezept:</span>
<span id="sa-recipe-title" style="font-size: 1.15rem; font-weight: 700; color: var(--tannengruen); display: block; margin-top: 2px;"></span>
</div>
<div class="cb-form-group">
<label class="cb-label">Wochentag wählen:</label>
<select id="sa-day-select" class="cb-select">
<option value="0">Montag</option>
<option value="1">Dienstag</option>
<option value="2">Mittwoch</option>
<option value="3">Donnerstag</option>
<option value="4">Freitag</option>
<option value="5">Samstag</option>
<option value="6">Sonntag</option>
</select>
</div>
<div class="cb-form-group">
<label class="cb-label">Mahlzeit:</label>
<select id="sa-meal-type-select" class="cb-select">
<option value="mittag" selected>☀️ Mittag</option>
<option value="abend">🌙 Abend</option>
<option value="kaffee">☕ Kaffee & Kuchen</option>
<option value="frueh">🌅 Früh</option>
</select>
</div>
<div style="display: flex; justify-content: flex-end; gap: 8px; margin-top: 18px; padding-top: 12px; border-top: 1px solid #ddd;">
<button type="button" onclick="closeShuffleAssignModal()" class="cb-btn cb-btn-modal-cancel">Abbrechen</button>
<button type="button" onclick="saveShuffleToMealPlan()" class="cb-btn cb-btn-modal-submit">💾 Eintragen</button>
</div>
</div>
</div>
</div>
<!-- ========================================== -->
<!-- MODAL 1: ESSENS-ZUFALLSGENERATOR (no-print)-->
<!-- ========================================== -->
@@ -1438,11 +1574,15 @@
if (modalId === 'modal-generator') closeGeneratorModal();
if (modalId === 'modal-recipe') closeRecipeModal();
if (modalId === 'modal-print') closePrintModal();
if (modalId === 'modal-mealplan-ingredients') closeMealPlanIngredientsModal();
if (modalId === 'modal-shuffle-assign') closeShuffleAssignModal();
}
}
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
closeShuffleAssignModal();
closeMealPlanIngredientsModal();
closeSlotEditor();
closeMealPlanModal();
closeGeneratorModal();
@@ -1476,6 +1616,10 @@
];
const TOTAL_RECIPES = {{ total_recipes }};
// Filter für Zufallsgenerator: Kategorien Desserts (9), Kuchen & Gebäck (10) und Plätzchen (11) ausschließen
const SHUFFLE_CHAPTERS = GEN_CHAPTERS.filter(c => c.number !== 9 && c.number !== 10 && c.number !== 11);
const SHUFFLE_TOTAL_RECIPES = SHUFFLE_CHAPTERS.reduce((s, c) => s + (c.count || 0), 0);
let genSlotCount = 2;
let genSlots = [
{ category: "", efforts: [] },
@@ -1486,15 +1630,55 @@
];
let genCurrentResults = [];
function saveShuffleStateToStorage() {
try {
sessionStorage.setItem('wtc_shuffle_results', JSON.stringify(genCurrentResults || []));
sessionStorage.setItem('wtc_shuffle_slots', JSON.stringify(genSlots || []));
sessionStorage.setItem('wtc_shuffle_count', String(genSlotCount));
} catch (e) {
console.warn('Fehler beim Speichern des Shuffle-Zustands:', e);
}
}
function restoreShuffleStateFromStorage() {
try {
const storedResults = sessionStorage.getItem('wtc_shuffle_results');
const storedSlots = sessionStorage.getItem('wtc_shuffle_slots');
const storedCount = sessionStorage.getItem('wtc_shuffle_count');
if (storedSlots) {
genSlots = JSON.parse(storedSlots);
}
if (storedCount) {
genSlotCount = parseInt(storedCount, 10) || 2;
document.querySelectorAll('.gen-count-btn').forEach((btn, idx) => {
btn.classList.toggle('active', idx + 1 === genSlotCount);
});
}
if (storedResults) {
genCurrentResults = JSON.parse(storedResults);
if (genCurrentResults.length > 0) {
renderGenResults(false);
}
}
} catch (e) {
console.warn('Fehler beim Wiederherstellen des Shuffle-Zustands:', e);
}
}
function setGenSlotCount(count) {
genSlotCount = count;
document.querySelectorAll('.gen-count-btn').forEach((btn, idx) => {
btn.classList.toggle('active', idx + 1 === count);
});
renderGenSlots();
saveShuffleStateToStorage();
}
function initGeneratorSlots() {
if (genCurrentResults.length === 0 && sessionStorage.getItem('wtc_shuffle_results')) {
restoreShuffleStateFromStorage();
}
renderGenSlots();
}
@@ -1503,7 +1687,7 @@
let html = '';
for (let i = 0; i < genSlotCount; i++) {
const slot = genSlots[i];
const selectedChap = slot.category ? GEN_CHAPTERS.find(c => c.number == slot.category) : null;
const selectedChap = slot.category ? SHUFFLE_CHAPTERS.find(c => c.number == slot.category) : null;
const isAllEfforts = !slot.efforts || slot.efforts.length === 0;
const isGering = slot.efforts && slot.efforts.includes('gering');
const isMittel = slot.efforts && slot.efforts.includes('mittel');
@@ -1515,7 +1699,7 @@
<span>🍽️ Gericht ${i + 1}</span>
</div>
<div class="cb-row" style="gap: 12px; align-items: flex-start;">
<!-- Kapitel Dropdown ohne "Kapitel X" Präfix -->
<!-- Kapitel Dropdown ohne "Kapitel X" Präfix & ohne Desserts/Kuchen -->
<div class="cb-col" style="flex: 1.6; min-width: 180px;">
<label style="font-size: 0.85rem; color: #555; font-weight: 700; margin-bottom: 4px; display: block;">Kategorie / Kapitel:</label>
<div class="custom-chapter-select" id="custom-chapter-select-${i}">
@@ -1526,7 +1710,7 @@
<span class="chapter-select-btn-title">${selectedChap.title}</span>
` : `
<span style="font-size: 1.25rem; line-height: 1;">🎲</span>
<span class="chapter-select-btn-title">Alle Kapitel (Beliebig)</span>
<span class="chapter-select-btn-title">Alle Hauptgerichte (Beliebig)</span>
`}
</div>
<span class="chapter-select-arrow">▼</span>
@@ -1535,12 +1719,12 @@
<div class="chapter-option ${!slot.category ? 'is-selected' : ''}" onclick="selectSlotChapter(${i}, '')">
<span style="font-size: 1.3rem; line-height: 1; width: 32px; text-align: center;">🎲</span>
<div class="chapter-option-text">
<div class="chapter-option-title">Alle Kapitel / Beliebig</div>
<div style="font-size: 0.8rem; color: #666;">Zufall aus dem gesamten Kochbuch</div>
<div class="chapter-option-title">Alle Hauptgerichte / Beliebig</div>
<div style="font-size: 0.8rem; color: #666;">Zufall aus allen herzhaften Kategorien</div>
</div>
<span class="chapter-recipe-count">${TOTAL_RECIPES} Rezepte</span>
<span class="chapter-recipe-count">${SHUFFLE_TOTAL_RECIPES} Rezepte</span>
</div>
${GEN_CHAPTERS.map(c => `
${SHUFFLE_CHAPTERS.map(c => `
<div class="chapter-option ${slot.category == c.number ? 'is-selected' : ''}" onclick="selectSlotChapter(${i}, ${c.number})">
<img src="${c.illustration}" class="chapter-option-thumb" alt="${c.title}">
<div class="chapter-option-text">
@@ -1682,11 +1866,15 @@
}
}
function renderGenResults() {
function renderGenResults(saveState = true) {
const sec = document.getElementById('gen-results-section');
const list = document.getElementById('gen-results-list');
sec.style.display = 'block';
if (saveState) {
saveShuffleStateToStorage();
}
if (!genCurrentResults || genCurrentResults.length === 0) {
list.innerHTML = '<p style="text-align:center; color:#666;">Keine Rezepte ausgelost.</p>';
return;
@@ -1743,11 +1931,15 @@
🔄 Neu
</button>
</div>
<div class="gen-actions">
<div class="gen-actions" style="display: flex; gap: 8px; flex-wrap: wrap;">
<a href="#rezept-${d.id}" onclick="jumpToRecipe(${d.id}, event)" class="cb-btn cb-btn-creme"
style="background: var(--tannengruen); color: var(--creme); font-size: 0.95rem; padding: 6px 16px;">
📖 Direkt zum Rezept im Buch
</a>
<button type="button" onclick="openShuffleToMealPlanModal(${d.id}, '${escapeHtml(d.title || d.name)}', event)" class="cb-btn cb-btn-outline"
style="color: var(--tannengruen); border-color: var(--tannengruen); font-size: 0.95rem; padding: 6px 14px; display: inline-flex; align-items: center; gap: 6px;">
<span>📅</span> <span>In Wochenplan</span>
</button>
</div>
</div>
`;
@@ -1756,6 +1948,8 @@
function jumpToRecipe(recipeId, e) {
if (e) e.preventDefault();
sessionStorage.setItem('wtc_from_shuffle', 'true');
checkShuffleReturnState();
closeGeneratorModal();
const el = document.getElementById(`rezept-${recipeId}`);
if (el) {
@@ -1768,6 +1962,114 @@
}
}
// ==========================================
// SHUFFLE RETENTION & WOCHENPLAN ÜBERTRAGUNG
// ==========================================
function checkShuffleReturnState() {
const isFromShuffle = sessionStorage.getItem('wtc_from_shuffle') === 'true';
const hasResults = !!sessionStorage.getItem('wtc_shuffle_results');
const banner = document.getElementById('shuffle-return-banner');
const mpBtn = document.getElementById('mp-btn-back-to-shuffle');
if (isFromShuffle && hasResults) {
if (banner) banner.style.display = 'flex';
if (mpBtn) mpBtn.style.display = 'inline-flex';
} else {
if (banner) banner.style.display = 'none';
if (mpBtn) mpBtn.style.display = 'none';
}
}
function returnToShuffle() {
closeMealPlanIngredientsModal();
closeShuffleAssignModal();
closeSlotEditor();
closeMealPlanModal();
closeRecipeModal();
closePrintModal();
openGeneratorModal();
restoreShuffleStateFromStorage();
renderGenSlots();
}
function returnToShuffleFromMealPlan() {
closeMealPlanModal();
returnToShuffle();
}
function dismissShuffleBanner() {
sessionStorage.removeItem('wtc_from_shuffle');
checkShuffleReturnState();
}
function openShuffleToMealPlanModal(recipeId, recipeTitle, e) {
if (e) e.stopPropagation();
document.getElementById('sa-recipe-id').value = recipeId;
document.getElementById('sa-recipe-title').textContent = recipeTitle || 'Rezept';
const jsDay = new Date().getDay();
const isoDay = jsDay === 0 ? 6 : jsDay - 1;
document.getElementById('sa-day-select').value = String(isoDay);
document.getElementById('sa-meal-type-select').value = 'mittag';
document.getElementById('modal-shuffle-assign').classList.add('active');
document.body.classList.add('modal-open');
}
function closeShuffleAssignModal() {
document.getElementById('modal-shuffle-assign').classList.remove('active');
if (!document.getElementById('modal-generator').classList.contains('active') &&
!document.getElementById('modal-mealplan').classList.contains('active')) {
document.body.classList.remove('modal-open');
}
}
async function saveShuffleToMealPlan() {
const recipeId = parseInt(document.getElementById('sa-recipe-id').value, 10);
const dayIdx = parseInt(document.getElementById('sa-day-select').value, 10);
const mealType = document.getElementById('sa-meal-type-select').value;
const dayName = document.getElementById('sa-day-select').options[document.getElementById('sa-day-select').selectedIndex].text;
const mealName = document.getElementById('sa-meal-type-select').options[document.getElementById('sa-meal-type-select').selectedIndex].text;
let year = mpCurrentYear;
let week = mpCurrentWeek;
if (!year || !week) {
const now = new Date();
const d = new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()));
const dayNum = d.getUTCDay() || 7;
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
week = Math.ceil((((d - yearStart) / 86400000) + 1) / 7);
year = d.getUTCFullYear();
}
const payload = {
year: year,
week: week,
day_of_week: dayIdx,
meal_type: mealType,
recipe_id: recipeId,
custom_text: null
};
try {
const res = await fetch('/api/mealplan/slot', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) throw new Error('Speichern im Wochenplan fehlgeschlagen');
closeShuffleAssignModal();
sessionStorage.setItem('wtc_from_shuffle', 'true');
checkShuffleReturnState();
showToast(`📅 In Wochenplan eingetragen (${dayName}, ${mealName})!`);
} catch (err) {
alert(err.message);
}
}
// ==========================================
// REZEPT-MODAL FORM SUBMIT & BILD-KOMPRIMIERUNG
// ==========================================
@@ -2022,7 +2324,9 @@
.replace(/"/g, '&quot;');
}
// Slot Editor
// ==========================================
// SLOT EDITOR & REZEPT LIVE-SUCHE
// ==========================================
function openSlotEditor(dayIndex, mealType, dayName) {
document.getElementById('se-day').value = dayIndex;
@@ -2037,9 +2341,6 @@
document.getElementById('slot-editor-title').textContent = `🍽️ ${dayName || 'Tag'}: Mahlzeit planen`;
// Rezept-Dropdown initialisieren
populateSlotRecipeDropdown();
// Mahlzeittyp setzen & Felder füllen
selectSlotMealType(mealType, false);
@@ -2052,11 +2353,7 @@
// Aktiven Chip hervorheben
document.querySelectorAll('.slot-type-chip').forEach(btn => {
if (btn.getAttribute('data-type') === mealType) {
btn.classList.add('active');
} else {
btn.classList.remove('active');
}
btn.classList.toggle('active', btn.getAttribute('data-type') === mealType);
});
// Bestehende Werte für diesen Tag + mealType prüfen
@@ -2064,48 +2361,123 @@
const slotKey = `${dayIndex}_${mealType}`;
const current = mpData && mpData.slots ? mpData.slots[slotKey] : null;
const sel = document.getElementById('se-recipe-select');
const customInput = document.getElementById('se-custom-text');
const delBtn = document.getElementById('se-btn-delete');
if (current && (current.recipe_title || (current.custom_text && current.custom_text.trim()))) {
sel.value = current.recipe_id || '';
if (current.recipe_id && current.recipe_title) {
selectSlotRecipe(current.recipe_id, current.recipe_title);
} else {
clearSlotRecipeSelection();
}
customInput.value = current.custom_text || '';
if (delBtn) delBtn.style.display = 'inline-block';
} else {
if (userClicked) {
sel.value = '';
clearSlotRecipeSelection();
customInput.value = '';
}
if (delBtn) delBtn.style.display = 'none';
}
}
function populateSlotRecipeDropdown() {
const sel = document.getElementById('se-recipe-select');
if (sel.options.length > 1) return; // bereits befüllt
function onSlotRecipeSearchInput(val) {
const dropdown = document.getElementById('se-recipe-search-dropdown');
const term = (val || '').trim().toLowerCase();
sel.innerHTML = '<option value="">-- Kein Rezept / Freitext nutzen --</option>';
if (mpData && mpData.recipes) {
const cats = {};
mpData.recipes.forEach(r => {
if (!cats[r.category]) cats[r.category] = [];
cats[r.category].push(r);
});
Object.keys(cats).sort().forEach(cat => {
const optGroup = document.createElement('optgroup');
optGroup.label = cat;
cats[cat].sort((a,b) => a.title.localeCompare(b.title)).forEach(r => {
const opt = document.createElement('option');
opt.value = r.id;
opt.textContent = `${r.title} (${r.effort})`;
optGroup.appendChild(opt);
});
sel.appendChild(optGroup);
});
if (!mpData || !mpData.recipes) return;
let matches = [];
if (!term) {
matches = mpData.recipes.slice(0, 15);
} else {
matches = mpData.recipes.filter(r => {
const titleMatch = (r.title || '').toLowerCase().includes(term);
const catMatch = (r.category || '').toLowerCase().includes(term);
return titleMatch || catMatch;
}).slice(0, 20);
}
renderSlotRecipeSearchResults(matches);
}
function onSlotRecipeSearchFocus() {
const input = document.getElementById('se-recipe-search-input');
onSlotRecipeSearchInput(input.value);
}
function renderSlotRecipeSearchResults(matches) {
const dropdown = document.getElementById('se-recipe-search-dropdown');
if (!matches || matches.length === 0) {
dropdown.innerHTML = '<div style="padding: 10px; color: #888; text-align: center; font-size: 0.9rem;">Kein Rezept gefunden</div>';
dropdown.style.display = 'block';
return;
}
const effortBadge = (eff) => {
if (eff === 'gering') return '🟢 Gering';
if (eff === 'hoch') return '🔴 Hoch';
return '🟡 Mittel';
};
let html = '';
matches.forEach(r => {
html += `
<div class="slot-search-item" onclick="selectSlotRecipe(${r.id}, '${escapeHtml(r.title)}')">
<div style="flex: 1; min-width: 0;">
<div class="slot-search-item-title">${escapeHtml(r.title)}</div>
<div class="slot-search-item-meta">
<span>${escapeHtml(r.category || '')}</span>
<span>•</span>
<span>${effortBadge(r.effort)}</span>
${r.cook_time ? `<span>• ⏱️ ${escapeHtml(r.cook_time)}</span>` : ''}
</div>
</div>
<span style="font-size: 0.85rem; color: var(--tannengruen); font-weight: 700; margin-left: 8px;">Wählen ➔</span>
</div>
`;
});
dropdown.innerHTML = html;
dropdown.style.display = 'block';
}
function selectSlotRecipe(recipeId, recipeTitle) {
if (!recipeTitle && mpData && mpData.recipes) {
const found = mpData.recipes.find(r => r.id === recipeId);
if (found) recipeTitle = found.title;
}
document.getElementById('se-recipe-id').value = recipeId;
document.getElementById('se-recipe-search-input').value = recipeTitle || '';
document.getElementById('se-selected-recipe-name').textContent = recipeTitle || '';
document.getElementById('se-selected-recipe-badge').style.display = 'flex';
document.getElementById('se-recipe-search-dropdown').style.display = 'none';
// Freitext leeren
document.getElementById('se-custom-text').value = '';
}
function clearSlotRecipeSelection() {
document.getElementById('se-recipe-id').value = '';
document.getElementById('se-recipe-search-input').value = '';
document.getElementById('se-selected-recipe-badge').style.display = 'none';
document.getElementById('se-recipe-search-dropdown').style.display = 'none';
}
function onSlotCustomTextInput(val) {
if (val && val.trim().length > 0) {
clearSlotRecipeSelection();
}
}
document.addEventListener('click', (e) => {
if (!e.target.closest('.slot-recipe-search-container')) {
const dropdown = document.getElementById('se-recipe-search-dropdown');
if (dropdown) dropdown.style.display = 'none';
}
});
function closeSlotEditor() {
document.getElementById('modal-slot-editor').classList.remove('active');
if (!document.getElementById('modal-mealplan').classList.contains('active')) {
@@ -2113,25 +2485,17 @@
}
}
function onSlotRecipeSelect(val) {
if (val) {
document.getElementById('se-custom-text').value = '';
}
}
function rollRandomRecipeForSlot() {
if (!mpData || !mpData.recipes || mpData.recipes.length === 0) return;
const rand = mpData.recipes[Math.floor(Math.random() * mpData.recipes.length)];
const sel = document.getElementById('se-recipe-select');
sel.value = rand.id;
document.getElementById('se-custom-text').value = '';
selectSlotRecipe(rand.id, rand.title);
showToast(`🎲 Ausgewählt: ${rand.title}`);
}
async function saveSlotEditor() {
const dayIndex = parseInt(document.getElementById('se-day').value, 10);
const mealType = document.getElementById('se-type').value;
const recipeVal = document.getElementById('se-recipe-select').value;
const recipeVal = document.getElementById('se-recipe-id').value;
const customVal = document.getElementById('se-custom-text').value.trim();
const payload = {
@@ -2189,6 +2553,170 @@
alert(err.message);
}
}
// ==========================================
// WOCHENPLAN ZUTATEN AUF EINKAUFSLISTE
// ==========================================
let mpiIngredientsData = [];
async function openMealPlanIngredientsModal() {
if (!mpCurrentYear || !mpCurrentWeek) {
showToast('Wochenplan noch nicht geladen');
return;
}
const container = document.getElementById('mpi-ingredients-container');
const countBadge = document.getElementById('mpi-total-count');
const weekLabel = document.getElementById('mpi-week-label');
weekLabel.textContent = `KW ${mpCurrentWeek}`;
container.innerHTML = '<div style="text-align: center; padding: 30px; color: #777;"><span>⏳</span> Zutaten werden zusammengestellt...</div>';
countBadge.textContent = 'Laden...';
document.getElementById('modal-mealplan-ingredients').classList.add('active');
document.body.classList.add('modal-open');
try {
const res = await fetch(`/api/mealplan/ingredients?year=${mpCurrentYear}&week=${mpCurrentWeek}`);
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Fehler beim Laden der Zutaten');
const groups = data.groups || [];
mpiIngredientsData = groups;
if (groups.length === 0) {
container.innerHTML = `
<div style="text-align: center; padding: 30px; color: #666;">
<span style="font-size: 2.5rem; display: block; margin-bottom: 8px;">🍽️</span>
<h4 style="margin: 0 0 6px 0; color: var(--tannengruen); font-family: var(--font-hand); font-size: 1.4rem;">Keine Rezepte mit Zutaten geplant</h4>
<p style="font-size: 0.95rem; margin: 0;">Füge im Wochenplan Rezepte aus dem Kochbuch hinzu, um deren Zutaten hier automatisch auf deine Einkaufsliste zu setzen.</p>
</div>
`;
countBadge.textContent = '0 Zutaten';
return;
}
let totalItemCount = 0;
let html = '';
groups.forEach((grp, gIdx) => {
totalItemCount += grp.items.length;
html += `
<div class="mpi-group">
<div class="mpi-group-header">
<span>📖 ${escapeHtml(grp.recipe_title)}</span>
<span class="mpi-group-badge">${escapeHtml(grp.slot_label)}</span>
</div>
<div class="mpi-group-items">
`;
grp.items.forEach((it, iIdx) => {
const qtyStr = ((it.amount || '') + (it.unit ? ' ' + it.unit : '')).trim();
const itemId = `mpi_cb_${gIdx}_${iIdx}`;
html += `
<label class="mpi-item" id="mpi-label-${itemId}">
<input type="checkbox" class="mpi-checkbox" id="${itemId}" checked
data-recipe="${escapeHtml(grp.recipe_title)}"
data-name="${escapeHtml(it.name)}"
data-amount="${escapeHtml(it.amount || '')}"
data-unit="${escapeHtml(it.unit || '')}"
onchange="onMpiItemToggle(this, '${itemId}')">
<span class="mpi-item-name">${escapeHtml(it.name)}</span>
${qtyStr ? `<span class="mpi-item-qty">${escapeHtml(qtyStr)}</span>` : ''}
</label>
`;
});
html += `
</div>
</div>
`;
});
container.innerHTML = html;
countBadge.textContent = `${totalItemCount} ${totalItemCount === 1 ? 'Zutat' : 'Zutaten'}`;
document.getElementById('mpi-toggle-all').checked = true;
} catch (err) {
container.innerHTML = `<div style="text-align: center; padding: 20px; color: #c53030;">❌ ${err.message}</div>`;
}
}
function closeMealPlanIngredientsModal() {
document.getElementById('modal-mealplan-ingredients').classList.remove('active');
if (!document.getElementById('modal-mealplan').classList.contains('active')) {
document.body.classList.remove('modal-open');
}
}
function toggleAllMpiCheckboxes(checked) {
document.querySelectorAll('.mpi-checkbox').forEach(cb => {
cb.checked = checked;
const parent = cb.closest('.mpi-item');
if (parent) parent.classList.toggle('is-unchecked', !checked);
});
}
function onMpiItemToggle(cb, id) {
const parent = cb.closest('.mpi-item');
if (parent) parent.classList.toggle('is-unchecked', !cb.checked);
}
async function transferMpiToShoppingList() {
const btn = document.getElementById('mpi-btn-transfer');
const checkedBoxes = Array.from(document.querySelectorAll('.mpi-checkbox:checked'));
if (checkedBoxes.length === 0) {
alert('Bitte wähle mindestens eine Zutat aus.');
return;
}
btn.disabled = true;
btn.innerHTML = '<span>⏳</span> <span>Wird übertragen...</span>';
const items = checkedBoxes.map(cb => ({
item_name: cb.getAttribute('data-name'),
amount: cb.getAttribute('data-amount') || '',
unit: cb.getAttribute('data-unit') || '',
source: `Wochenplan KW ${mpCurrentWeek} (${cb.getAttribute('data-recipe')})`
}));
try {
const res = await fetch('/api/shopping-list/bulk', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items: items })
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Fehler beim Übertragen');
closeMealPlanIngredientsModal();
showToast(`🛒 ${data.added_count} Zutaten auf die Einkaufsliste gesetzt!`);
} catch (err) {
alert(err.message);
} finally {
btn.disabled = false;
btn.innerHTML = '<span>➕</span> <span>Ausgewählte übertragen</span>';
}
}
// ==========================================
// INITIALISIERUNG BEIM SEITENAUFRUF
// ==========================================
document.addEventListener('DOMContentLoaded', () => {
// 1. Prüfe Rückkehr-Banner vom Shuffle
checkShuffleReturnState();
// 2. Prüfe Query-Parameter open=...
const urlParams = new URLSearchParams(window.location.search);
const openParam = urlParams.get('open');
if (openParam === 'generator' || openParam === 'shuffle') {
openGeneratorModal();
} else if (openParam === 'mealplan' || openParam === 'wochenplan') {
openMealPlanModal();
} else if (openParam === 'new-recipe' || openParam === 'recipe') {
openRecipeModal();
}
});
</script>
</body>
</html>
+511
View File
@@ -0,0 +1,511 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>What To Cook? – Hauptmenü</title>
<link rel="stylesheet" href="/static/cookbook.css?v=4.5.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>">
<style>
/* Styling für das ausgeklappte Menü / Hub Dashboard */
body.home-body {
background-color: var(--grau-bg, #e6e2dc);
background-image: radial-gradient(rgba(30, 77, 59, 0.06) 1px, transparent 1px);
background-size: 20px 20px;
min-height: 100vh;
margin: 0;
padding: 0;
font-family: var(--font-sans);
color: var(--schwarz);
display: flex;
flex-direction: column;
}
.home-header {
background-color: var(--creme);
border-bottom: 2px solid var(--schwarz);
padding: 12px 20px;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
position: sticky;
top: 0;
z-index: 100;
}
.home-brand {
display: flex;
align-items: center;
gap: 12px;
text-decoration: none;
color: inherit;
}
.home-brand-icon {
font-size: 2rem;
filter: drop-shadow(1px 2px 2px rgba(0,0,0,0.15));
}
.home-brand-title {
font-family: var(--font-hand);
font-size: 1.85rem;
font-weight: 700;
color: var(--tannengruen);
margin: 0;
line-height: 1;
}
.home-brand-sub {
font-size: 0.82rem;
color: #666;
margin: 2px 0 0 0;
}
.home-header-nav {
display: flex;
align-items: center;
gap: 10px;
}
.home-header-badge {
display: inline-flex;
align-items: center;
gap: 6px;
background: rgba(30, 77, 59, 0.1);
color: var(--tannengruen);
padding: 6px 12px;
border-radius: 20px;
font-size: 0.85rem;
font-weight: 600;
text-decoration: none;
border: 1px solid rgba(30, 77, 59, 0.2);
}
.home-logout-btn {
color: #777;
text-decoration: none;
font-size: 0.85rem;
padding: 6px 10px;
border-radius: 6px;
transition: all 0.2s;
}
.home-logout-btn:hover {
color: #b00;
background: rgba(200, 0, 0, 0.08);
}
/* Der eigentliche ausgeklappte Menü-Container */
.home-container {
max-width: 1080px;
width: 100%;
margin: 32px auto 48px auto;
padding: 0 16px;
box-sizing: border-box;
flex: 1;
}
/* Menü-Tafel Intro */
.menu-board-intro {
text-align: center;
margin-bottom: 28px;
}
.menu-board-tagline {
font-family: var(--font-hand);
font-size: 1.35rem;
color: var(--altrosa);
margin: 0 0 4px 0;
}
.menu-board-heading {
font-family: var(--font-hand);
font-size: 2.8rem;
color: var(--tannengruen);
margin: 0 0 8px 0;
line-height: 1.1;
}
.menu-board-desc {
font-size: 1rem;
color: #555;
max-width: 620px;
margin: 0 auto;
}
/* Das ausgeklappte Speisekarten-Grid */
.menu-spread {
background: var(--creme);
border: 3px solid var(--schwarz);
border-radius: 16px;
box-shadow: 0 12px 36px rgba(0,0,0,0.18), 0 2px 6px rgba(0,0,0,0.1);
padding: 32px 28px;
position: relative;
}
.menu-spread::before {
content: '';
position: absolute;
top: 6px; left: 6px; right: 6px; bottom: 6px;
border: 1.5px dashed rgba(30, 77, 59, 0.35);
border-radius: 12px;
pointer-events: none;
}
.menu-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(290px, 1fr));
gap: 22px;
position: relative;
z-index: 2;
}
/* Einzelne Menükarte */
.menu-card {
background: rgba(255, 255, 255, 0.85);
border: 2px solid var(--schwarz);
border-radius: 12px;
padding: 24px 20px;
display: flex;
flex-direction: column;
justify-content: space-between;
box-shadow: 4px 4px 0px rgba(0, 0, 0, 0.12);
transition: transform 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275), box-shadow 0.2s;
position: relative;
overflow: hidden;
}
.menu-card:hover {
transform: translateY(-4px) scale(1.01);
box-shadow: 6px 8px 0px rgba(0, 0, 0, 0.15);
}
.menu-card.featured {
background: #FFFDF0;
border-color: var(--tannengruen);
border-width: 2.5px;
}
.menu-card-top {
margin-bottom: 18px;
}
.menu-card-icon-wrap {
width: 52px;
height: 52px;
background: var(--creme);
border: 2px solid var(--schwarz);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.8rem;
margin-bottom: 14px;
box-shadow: 2px 2px 0px rgba(0,0,0,0.1);
}
.menu-card-title {
font-family: var(--font-hand);
font-size: 1.7rem;
color: var(--tannengruen);
margin: 0 0 6px 0;
line-height: 1.15;
}
.menu-card-desc {
font-size: 0.92rem;
line-height: 1.45;
color: #4a453f;
margin: 0 0 14px 0;
}
.menu-card-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 12px;
}
.menu-tag {
font-size: 0.76rem;
padding: 3px 8px;
border-radius: 6px;
font-weight: 600;
background: rgba(30, 77, 59, 0.08);
color: var(--tannengruen);
border: 1px solid rgba(30, 77, 59, 0.2);
}
.menu-tag.tag-warning {
background: rgba(210, 155, 155, 0.2);
color: #933;
border-color: rgba(210, 155, 155, 0.4);
}
.menu-card-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
padding: 12px 16px;
font-family: var(--font-sans);
font-size: 1rem;
font-weight: 700;
text-decoration: none;
border-radius: 8px;
border: 2px solid var(--schwarz);
cursor: pointer;
box-sizing: border-box;
transition: all 0.15s ease;
box-shadow: 2px 2px 0px rgba(0,0,0,0.2);
}
.menu-card-btn:active {
transform: translate(2px, 2px);
box-shadow: none;
}
.btn-green {
background: var(--tannengruen);
color: var(--creme);
}
.btn-green:hover {
background: #27624c;
color: #fff;
}
.btn-rose {
background: var(--altrosa);
color: #222;
}
.btn-rose:hover {
background: #daa8a8;
}
.btn-yellow {
background: #FFE885;
color: #4a3e14;
}
.btn-yellow:hover {
background: #ffeb99;
}
.btn-creme {
background: var(--creme);
color: var(--schwarz);
}
.btn-creme:hover {
background: #fff;
}
/* Schnelle Buch-Statistik am Fuß */
.home-footer-stats {
margin-top: 28px;
padding-top: 20px;
border-top: 1.5px dashed rgba(30, 77, 59, 0.3);
display: flex;
justify-content: space-around;
flex-wrap: wrap;
gap: 16px;
text-align: center;
}
.stat-item {
display: flex;
flex-direction: column;
gap: 2px;
}
.stat-val {
font-family: var(--font-hand);
font-size: 1.8rem;
font-weight: 700;
color: var(--tannengruen);
}
.stat-lbl {
font-size: 0.8rem;
color: #666;
text-transform: uppercase;
letter-spacing: 0.5px;
}
@media (max-width: 600px) {
.menu-spread {
padding: 20px 14px;
}
.menu-board-heading {
font-size: 2.2rem;
}
.home-header {
padding: 10px 14px;
}
.home-brand-title {
font-size: 1.5rem;
}
}
</style>
</head>
<body class="home-body">
<!-- Header -->
<header class="home-header">
<a href="/" class="home-brand">
<span class="home-brand-icon">📖</span>
<div>
<h1 class="home-brand-title">What To Cook?</h1>
<p class="home-brand-sub">Privates Kochbuch & Menüplaner</p>
</div>
</a>
<div class="home-header-nav">
<a href="/shopping-list" class="home-header-badge" title="Einkaufsliste aufrufen">
<span>🛒</span>
<span>{% if open_shopping_count > 0 %}{{ open_shopping_count }} offen{% else %}Einkaufsliste{% endif %}</span>
</a>
<a href="/cookbook" class="home-header-badge" title="Direkt ins Kochbuch">
<span>📖</span>
<span>{{ recipe_count }} Rezepte</span>
</a>
<a href="/logout" class="home-logout-btn" title="Abmelden">Abmelden</a>
</div>
</header>
<!-- Hauptbereich: Das ausgeklappte Menü -->
<main class="home-container">
<div class="menu-board-intro">
<div class="menu-board-tagline">✦ Willkommen in deiner Küche ✦</div>
<h2 class="menu-board-heading">Was kochen wir heute?</h2>
<p class="menu-board-desc">
Wähle einen Bereich aus der Menükarte – vom schnellen Zufallsgenerator bis hin zum vollständigen Kochbuch und deiner Einkaufsliste.
</p>
</div>
<div class="menu-spread">
<div class="menu-grid">
<!-- 1. ZUFALLSGENERATOR -->
<div class="menu-card featured">
<div class="menu-card-top">
<div class="menu-card-icon-wrap" style="background: #FFE885;">🎲</div>
<h3 class="menu-card-title">Zufallsgenerator</h3>
<p class="menu-card-desc">
Keine Idee was gekocht werden soll? Bis zu 5 Gerichte nach Aufwand auslosen und direkt in deinen Wochenplan übernehmen.
</p>
<div class="menu-card-tags">
<span class="menu-tag">Hauptgerichte</span>
<span class="menu-tag">Schnelle Auslosung</span>
<span class="menu-tag tag-warning">Ohne Kuchen/Kekse</span>
</div>
</div>
<a href="/cookbook?open=generator" class="menu-card-btn btn-green">
<span>🎲</span> <span>Generator starten</span>
</a>
</div>
<!-- 2. KOCHBUCH -->
<div class="menu-card">
<div class="menu-card-top">
<div class="menu-card-icon-wrap" style="background: #E8F2EC;">📖</div>
<h3 class="menu-card-title">Mein Kochbuch</h3>
<p class="menu-card-desc">
Blättere durch alle 11 Kapitel, finde deine Lieblingsgerichte und nutze die millimetergenaue DIN A4 Buchdruck-Engine.
</p>
<div class="menu-card-tags">
<span class="menu-tag">{{ recipe_count }} Rezepte</span>
<span class="menu-tag">11 Kapitel</span>
<span class="menu-tag">A4 Druckausgabe</span>
</div>
</div>
<a href="/cookbook" class="menu-card-btn btn-creme">
<span>📖</span> <span>Kochbuch aufschlagen</span>
</a>
</div>
<!-- 3. WOCHENMENÜPLAN -->
<div class="menu-card">
<div class="menu-card-top">
<div class="menu-card-icon-wrap" style="background: #F8EFEA;">📅</div>
<h3 class="menu-card-title">Wochenmenüplan</h3>
<p class="menu-card-desc">
Plane die Mahlzeiten der aktuellen Kalenderwoche (Früh, Mittag, Kaffee & Kuchen, Abend) mit schneller Rezept-Live-Suche.
</p>
<div class="menu-card-tags">
<span class="menu-tag">Aktuell: KW {{ current_week }}</span>
<span class="menu-tag">Früh / Mittag / Abend</span>
<span class="menu-tag">Kaffee & Kuchen</span>
</div>
</div>
<a href="/cookbook?open=mealplan" class="menu-card-btn btn-rose">
<span>📅</span> <span>Wochenplan ansehen</span>
</a>
</div>
<!-- 4. EINKAUFSLISTE -->
<div class="menu-card">
<div class="menu-card-top">
<div class="menu-card-icon-wrap" style="background: #FFF9D2;">🛒</div>
<h3 class="menu-card-title">Einkaufsliste</h3>
<p class="menu-card-desc">
Alle benötigten Zutaten aus dem Wochenplan per Klick übernehmen, eigene Notizen hinzufügen und bequem im Supermarkt abhaken.
</p>
<div class="menu-card-tags">
<span class="menu-tag">{% if open_shopping_count > 0 %}{{ open_shopping_count }} offen{% else %}Keine offenen Posten{% endif %}</span>
<span class="menu-tag">Zutatenübernahme</span>
<span class="menu-tag">Mobil optimiert</span>
</div>
</div>
<a href="/shopping-list" class="menu-card-btn btn-yellow">
<span>🛒</span> <span>Zur Einkaufsliste</span>
</a>
</div>
<!-- 5. REZEPT HINZUFÜGEN -->
<div class="menu-card">
<div class="menu-card-top">
<div class="menu-card-icon-wrap" style="background: #F3EBE1;">➕</div>
<h3 class="menu-card-title">Rezept erfassen</h3>
<p class="menu-card-desc">
Füge ein neues Rezept mit Zutatenliste, Zubereitungsschritten, Kochzeit und Portionsangaben zum Kochbuch hinzu.
</p>
<div class="menu-card-tags">
<span class="menu-tag">Zutatenliste</span>
<span class="menu-tag">Portionen</span>
<span class="menu-tag">Foto-Upload</span>
</div>
</div>
<a href="/cookbook?open=new-recipe" class="menu-card-btn btn-creme">
<span>➕</span> <span>Neues Rezept anlegen</span>
</a>
</div>
</div>
<!-- Fußzeile in der Menükarte -->
<div class="home-footer-stats">
<div class="stat-item">
<span class="stat-val">{{ recipe_count }}</span>
<span class="stat-lbl">Gespeicherte Rezepte</span>
</div>
<div class="stat-item">
<span class="stat-val">{{ chapters|length }}</span>
<span class="stat-lbl">Buch-Kapitel</span>
</div>
<div class="stat-item">
<span class="stat-val">KW {{ current_week }}</span>
<span class="stat-lbl">Aktuelle Menüwoche</span>
</div>
<div class="stat-item">
<span class="stat-val">A4</span>
<span class="stat-lbl">Druckfertig gebunden</span>
</div>
</div>
</div>
</main>
</body>
</html>
+639
View File
@@ -0,0 +1,639 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Einkaufsliste – What To Cook?</title>
<link rel="stylesheet" href="/static/cookbook.css?v=4.5.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>">
<style>
body.shopping-body {
background-color: var(--grau-bg, #e6e2dc);
background-image: radial-gradient(rgba(30, 77, 59, 0.06) 1px, transparent 1px);
background-size: 20px 20px;
min-height: 100vh;
margin: 0;
padding: 0;
font-family: var(--font-sans);
color: var(--schwarz);
display: flex;
flex-direction: column;
}
/* Top Nav */
.shop-nav {
background-color: var(--creme);
border-bottom: 2px solid var(--schwarz);
padding: 10px 18px;
display: flex;
justify-content: space-between;
align-items: center;
position: sticky;
top: 0;
z-index: 100;
box-shadow: 0 4px 10px rgba(0,0,0,0.08);
}
.shop-nav-brand {
display: flex;
align-items: center;
gap: 10px;
text-decoration: none;
color: inherit;
}
.shop-nav-title {
font-family: var(--font-hand);
font-size: 1.6rem;
font-weight: 700;
color: var(--tannengruen);
margin: 0;
line-height: 1;
}
.shop-nav-links {
display: flex;
align-items: center;
gap: 8px;
}
.shop-nav-btn {
display: inline-flex;
align-items: center;
gap: 6px;
background: var(--creme);
border: 1.5px solid var(--schwarz);
border-radius: 8px;
padding: 6px 12px;
font-size: 0.88rem;
font-weight: 600;
color: var(--schwarz);
text-decoration: none;
box-shadow: 2px 2px 0px rgba(0,0,0,0.12);
transition: all 0.15s;
}
.shop-nav-btn:hover {
background: #fff;
transform: translateY(-1px);
}
/* Hauptbereich Notizblock */
.shop-main {
max-width: 720px;
width: 100%;
margin: 28px auto 60px auto;
padding: 0 16px;
box-sizing: border-box;
flex: 1;
}
.notepad-card {
background: #FFFDF7;
border: 2.5px solid var(--schwarz);
border-radius: 14px;
box-shadow: 6px 8px 0px rgba(0,0,0,0.15);
position: relative;
overflow: hidden;
padding: 24px 22px;
}
/* Rote Randlinie wie beim klassischen Notizblock */
.notepad-card::before {
content: '';
position: absolute;
top: 0; bottom: 0; left: 44px;
width: 2px;
background-color: rgba(210, 155, 155, 0.4);
pointer-events: none;
}
.notepad-header {
margin-bottom: 20px;
padding-bottom: 14px;
border-bottom: 2px dashed rgba(30, 77, 59, 0.3);
display: flex;
justify-content: space-between;
align-items: flex-start;
flex-wrap: wrap;
gap: 10px;
}
.notepad-title-group h2 {
font-family: var(--font-hand);
font-size: 2.3rem;
color: var(--tannengruen);
margin: 0 0 2px 0;
line-height: 1.1;
}
.notepad-subtitle {
font-size: 0.88rem;
color: #666;
margin: 0;
}
.notepad-counter {
font-family: var(--font-hand);
font-size: 1.15rem;
font-weight: 700;
color: var(--tannengruen);
background: #FFF9D2;
border: 1.5px dashed #D4C165;
padding: 4px 10px;
border-radius: 6px;
}
/* Schnelleingabe */
.shop-add-form {
display: flex;
gap: 8px;
margin-bottom: 22px;
background: #FFF9D2;
border: 2px solid var(--schwarz);
border-radius: 10px;
padding: 8px;
box-shadow: 3px 3px 0px rgba(0,0,0,0.1);
}
.shop-input {
flex: 1;
border: none;
background: transparent;
font-family: var(--font-sans);
font-size: 1rem;
padding: 8px 10px;
outline: none;
color: var(--schwarz);
}
.shop-input::placeholder {
color: #8c8260;
}
.shop-add-btn {
background: var(--tannengruen);
color: var(--creme);
border: 1.5px solid var(--schwarz);
border-radius: 6px;
padding: 8px 16px;
font-weight: 700;
font-size: 0.95rem;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
transition: all 0.15s;
}
.shop-add-btn:hover {
background: #27624c;
}
/* Aktionen Toolbar */
.shop-actions-bar {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px dotted rgba(0,0,0,0.15);
}
.shop-action-btn {
background: transparent;
border: 1px solid rgba(0,0,0,0.25);
border-radius: 6px;
padding: 4px 8px;
font-size: 0.8rem;
color: #555;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 4px;
transition: all 0.15s;
}
.shop-action-btn:hover {
background: rgba(0,0,0,0.05);
color: #222;
}
.shop-action-btn.btn-wochenplan {
background: rgba(30, 77, 59, 0.1);
border-color: rgba(30, 77, 59, 0.3);
color: var(--tannengruen);
font-weight: 600;
}
.shop-action-btn.btn-wochenplan:hover {
background: rgba(30, 77, 59, 0.18);
}
/* Die Einkaufsliste */
.shopping-items-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.shopping-item-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 12px 8px 48px;
border-bottom: 1px solid rgba(0,0,0,0.06);
transition: background 0.15s;
border-radius: 6px;
}
.shopping-item-row:hover {
background: rgba(30, 77, 59, 0.03);
}
.shopping-item-row.is-checked {
opacity: 0.55;
background: rgba(0, 0, 0, 0.02);
}
.shopping-item-row.is-checked .item-text {
text-decoration: line-through;
color: #777;
}
.item-left {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
cursor: pointer;
}
.item-checkbox {
width: 22px;
height: 22px;
border: 2px solid var(--schwarz);
border-radius: 6px;
cursor: pointer;
accent-color: var(--tannengruen);
flex-shrink: 0;
}
.item-text {
font-size: 1.05rem;
font-weight: 500;
color: #222;
line-height: 1.3;
word-break: break-word;
}
.item-source-badge {
font-size: 0.72rem;
background: rgba(30, 77, 59, 0.08);
color: var(--tannengruen);
padding: 2px 6px;
border-radius: 4px;
margin-left: 6px;
display: inline-block;
white-space: nowrap;
}
.item-delete-btn {
background: transparent;
border: none;
color: #aaa;
font-size: 1.1rem;
cursor: pointer;
padding: 4px 6px;
border-radius: 4px;
transition: color 0.15s;
}
.item-delete-btn:hover {
color: #b00;
background: rgba(200, 0, 0, 0.08);
}
/* Leerer Zustand */
.shopping-empty-state {
text-align: center;
padding: 40px 20px;
}
.empty-icon {
font-size: 3rem;
margin-bottom: 8px;
}
.empty-title {
font-family: var(--font-hand);
font-size: 1.7rem;
color: var(--tannengruen);
margin: 0 0 6px 0;
}
.empty-desc {
color: #666;
font-size: 0.95rem;
max-width: 400px;
margin: 0 auto 18px auto;
}
/* Toast Notification */
.shop-toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%) translateY(100px);
background: var(--tannengruen);
color: var(--creme);
padding: 10px 20px;
border-radius: 30px;
font-weight: 600;
font-size: 0.95rem;
box-shadow: 0 6px 16px rgba(0,0,0,0.25);
border: 1.5px solid var(--schwarz);
z-index: 9999;
opacity: 0;
transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
pointer-events: none;
}
.shop-toast.active {
transform: translateX(-50%) translateY(0);
opacity: 1;
}
</style>
</head>
<body class="shopping-body">
<!-- Nav -->
<header class="shop-nav">
<a href="/" class="shop-nav-brand">
<span style="font-size: 1.6rem;">🛒</span>
<div>
<h1 class="shop-nav-title">Einkaufsliste</h1>
</div>
</a>
<div class="shop-nav-links">
<a href="/" class="shop-nav-btn">
<span>🏠</span> <span>Menü</span>
</a>
<a href="/cookbook" class="shop-nav-btn">
<span>📖</span> <span>Kochbuch</span>
</a>
<a href="/cookbook?open=mealplan" class="shop-nav-btn">
<span>📅</span> <span>Wochenplan</span>
</a>
</div>
</header>
<!-- Main Container -->
<main class="shop-main">
<div class="notepad-card">
<div class="notepad-header">
<div class="notepad-title-group">
<h2>Einkaufszettel</h2>
<p class="notepad-subtitle">Woche {{ current_week }} • Zutaten & Besorgungen</p>
</div>
<div class="notepad-counter" id="shop-counter">
<span id="checked-count">0</span> / <span id="total-count">{{ items|length }}</span> erledigt
</div>
</div>
<!-- Schnelleingabe -->
<form class="shop-add-form" id="shop-add-form" onsubmit="onAddItemSubmit(event)">
<input
type="text"
id="shop-add-input"
class="shop-input"
placeholder="✏️ Zutat oder Artikel notieren (z. B. 500g Mehl)..."
autocomplete="off"
required
>
<button type="submit" class="shop-add-btn">
<span>➕</span> <span>Hinzufügen</span>
</button>
</form>
<!-- Toolbar Aktionen -->
<div class="shop-actions-bar">
<div>
<a href="/cookbook?open=mealplan&prompt_transfer=1" class="shop-action-btn btn-wochenplan" title="Zutaten aus dem aktuellen Wochenplan abrufen">
<span>📅</span> <span>Aus Wochenplan übernehmen</span>
</a>
</div>
<div style="display: flex; gap: 6px;">
<button type="button" class="shop-action-btn" onclick="deleteCheckedItems()" title="Abgehakte Einträge löschen">
<span>🧹</span> <span>Erledigte löschen</span>
</button>
<button type="button" class="shop-action-btn" onclick="clearAllItems()" title="Ganze Liste leeren">
<span>🗑️</span> <span>Alles leeren</span>
</button>
</div>
</div>
<!-- Items Liste -->
<ul class="shopping-items-list" id="shopping-items-list">
{% for item in items %}
<li class="shopping-item-row {% if item.checked %}is-checked{% endif %}" id="shop-item-{{ item.id }}" data-id="{{ item.id }}">
<label class="item-left">
<input
type="checkbox"
class="item-checkbox"
{% if item.checked %}checked{% endif %}
onchange="toggleItem({{ item.id }})"
>
<span class="item-text">
{% if item.amount or item.unit %}
<strong>{{ item.amount }} {{ item.unit }}</strong>
{% endif %}
{{ item.item_name }}
{% if item.source %}
<span class="item-source-badge">{{ item.source }}</span>
{% endif %}
</span>
</label>
<button type="button" class="item-delete-btn" onclick="deleteSingleItem({{ item.id }})" title="Löschen">&times;</button>
</li>
{% endfor %}
</ul>
<!-- Leerer Zustand (wenn 0 Items) -->
<div class="shopping-empty-state" id="shopping-empty-state" style="{% if items|length > 0 %}display: none;{% endif %}">
<div class="empty-icon">🎉</div>
<h3 class="empty-title">Alles eingekauft!</h3>
<p class="empty-desc">Deine Einkaufsliste ist momentan leer. Füge oben neue Dinge hinzu oder übernimm Zutaten direkt aus deinem Wochenplan.</p>
<a href="/cookbook?open=mealplan&prompt_transfer=1" class="shop-nav-btn" style="background: var(--tannengruen); color: var(--creme); font-weight: bold; padding: 10px 18px;">
<span>📅</span> <span>Zutaten aus Wochenplan holen</span>
</a>
</div>
</div>
</main>
<div id="shop-toast" class="shop-toast">✓ Gespeichert</div>
<script>
function showToast(msg) {
const toast = document.getElementById('shop-toast');
toast.textContent = msg;
toast.classList.add('active');
setTimeout(() => toast.classList.remove('active'), 2500);
}
function updateCounter() {
const rows = document.querySelectorAll('.shopping-item-row');
const total = rows.length;
const checked = document.querySelectorAll('.shopping-item-row.is-checked').length;
document.getElementById('checked-count').textContent = checked;
document.getElementById('total-count').textContent = total;
const emptyState = document.getElementById('shopping-empty-state');
if (total === 0) {
emptyState.style.display = 'block';
} else {
emptyState.style.display = 'none';
}
}
async function onAddItemSubmit(e) {
e.preventDefault();
const input = document.getElementById('shop-add-input');
const text = input.value.trim();
if (!text) return;
try {
const res = await fetch('/api/shopping-list', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ item_name: text, source: 'Manuell' })
});
const data = await res.json();
if (data.status === 'ok' && data.item) {
renderNewItem(data.item);
input.value = '';
showToast(`✓ "${text}" hinzugefügt`);
updateCounter();
}
} catch (err) {
alert('Fehler beim Hinzufügen: ' + err.message);
}
}
function renderNewItem(item) {
const ul = document.getElementById('shopping-items-list');
const li = document.createElement('li');
li.className = `shopping-item-row ${item.checked ? 'is-checked' : ''}`;
li.id = `shop-item-${item.id}`;
li.setAttribute('data-id', item.id);
const amountStr = (item.amount || item.unit) ? `<strong>${escapeHtml(item.amount || '')} ${escapeHtml(item.unit || '')}</strong> ` : '';
const sourceStr = item.source ? `<span class="item-source-badge">${escapeHtml(item.source)}</span>` : '';
li.innerHTML = `
<label class="item-left">
<input
type="checkbox"
class="item-checkbox"
${item.checked ? 'checked' : ''}
onchange="toggleItem(${item.id})"
>
<span class="item-text">
${amountStr}${escapeHtml(item.item_name)}${sourceStr}
</span>
</label>
<button type="button" class="item-delete-btn" onclick="deleteSingleItem(${item.id})" title="Löschen">&times;</button>
`;
// Vor den abgehakten Items einfügen
const firstChecked = ul.querySelector('.shopping-item-row.is-checked');
if (firstChecked) {
ul.insertBefore(li, firstChecked);
} else {
ul.appendChild(li);
}
}
async function toggleItem(id) {
const row = document.getElementById(`shop-item-${id}`);
if (!row) return;
try {
const res = await fetch(`/api/shopping-list/${id}/toggle`, { method: 'POST' });
const data = await res.json();
if (data.status === 'ok' && data.item) {
if (data.item.checked) {
row.classList.add('is-checked');
// Nach unten schieben
const ul = document.getElementById('shopping-items-list');
ul.appendChild(row);
} else {
row.classList.remove('is-checked');
}
updateCounter();
}
} catch (err) {
console.error(err);
}
}
async function deleteSingleItem(id) {
const row = document.getElementById(`shop-item-${id}`);
if (row) row.remove();
updateCounter();
try {
await fetch(`/api/shopping-list/${id}`, { method: 'DELETE' });
} catch (err) {
console.error(err);
}
}
async function deleteCheckedItems() {
const checkedRows = document.querySelectorAll('.shopping-item-row.is-checked');
if (checkedRows.length === 0) {
showToast('Keine erledigten Einträge vorhanden.');
return;
}
checkedRows.forEach(r => r.remove());
updateCounter();
showToast('✓ Erledigte Einträge gelöscht');
try {
await fetch('/api/shopping-list/checked', { method: 'DELETE' });
} catch (err) {
console.error(err);
}
}
async function clearAllItems() {
const total = document.querySelectorAll('.shopping-item-row').length;
if (total === 0) return;
if (!confirm('Möchtest du wirklich die gesamte Einkaufsliste leeren?')) return;
document.getElementById('shopping-items-list').innerHTML = '';
updateCounter();
showToast('✓ Einkaufsliste geleert');
try {
await fetch('/api/shopping-list/all', { method: 'DELETE' });
} catch (err) {
console.error(err);
}
}
function escapeHtml(str) {
if (!str) return '';
return String(str)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
document.addEventListener('DOMContentLoaded', updateCounter);
</script>
</body>
</html>