Redesign shopping list app, 2-portal home, real-time WebSocket sync, and edit modals

This commit is contained in:
2026-10-15 20:40:41 +02:00
parent 111966c3ae
commit 55b2823113
4 changed files with 1727 additions and 1230 deletions
+60 -6
View File
@@ -335,6 +335,16 @@ def init_db():
except Exception: except Exception:
pass pass
try:
cursor.execute("""
SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = %s AND TABLE_NAME = 'shopping_list' AND COLUMN_NAME = 'note'
""", (MARIADB_NAME,))
if not cursor.fetchone():
cursor.execute("ALTER TABLE shopping_list ADD COLUMN note VARCHAR(255) DEFAULT '' AFTER category")
except Exception:
pass
cursor.execute("DELETE FROM dishes") cursor.execute("DELETE FROM dishes")
else: else:
cursor = conn.cursor() cursor = conn.cursor()
@@ -421,6 +431,8 @@ def init_db():
existing_shop_cols = [c[1] for c in cursor.fetchall()] existing_shop_cols = [c[1] for c in cursor.fetchall()]
if "category" not in existing_shop_cols: if "category" not in existing_shop_cols:
cursor.execute("ALTER TABLE shopping_list ADD COLUMN category TEXT DEFAULT 'Drogerie & Sonstiges'") cursor.execute("ALTER TABLE shopping_list ADD COLUMN category TEXT DEFAULT 'Drogerie & Sonstiges'")
if "note" not in existing_shop_cols:
cursor.execute("ALTER TABLE shopping_list ADD COLUMN note TEXT DEFAULT ''")
cursor.execute("DELETE FROM dishes") cursor.execute("DELETE FROM dishes")
conn.commit() conn.commit()
@@ -1039,6 +1051,36 @@ def reset_default_shopping_categories() -> List[Dict[str, Any]]:
return get_shopping_categories() return get_shopping_categories()
def update_shopping_item(
item_id: int,
item_name: str,
amount: str = "",
unit: str = "",
category: Optional[str] = None,
note: str = ""
) -> Optional[Dict[str, Any]]:
"""Aktualisiert alle Eigenschaften eines Eintrags in der Einkaufsliste."""
cat = (category or "").strip()
if not cat:
cat = guess_category_for_ingredient(item_name)
query = """
UPDATE shopping_list
SET item_name = ?, amount = ?, unit = ?, category = ?, note = ?
WHERE id = ?
"""
execute_query(query, (item_name.strip(), amount.strip(), unit.strip(), cat, (note or "").strip(), 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"))
row["display_source"] = clean_source_label(row.get("source", ""))
row["note"] = str(row.get("note") or "").strip()
if isinstance(row.get("created_at"), (datetime, date)):
row["created_at"] = row["created_at"].isoformat()
if isinstance(row.get("updated_at"), (datetime, date)):
row["updated_at"] = row["updated_at"].isoformat()
return row
def update_shopping_item_category(item_id: int, category: str) -> Optional[Dict[str, Any]]: def update_shopping_item_category(item_id: int, category: str) -> Optional[Dict[str, Any]]:
"""Ändert die Kategorie/den Bereich eines einzelnen Listeneintrags.""" """Ändert die Kategorie/den Bereich eines einzelnen Listeneintrags."""
clean_cat = category.strip() clean_cat = category.strip()
@@ -1048,6 +1090,7 @@ def update_shopping_item_category(item_id: int, category: str) -> Optional[Dict[
if row: if row:
row["checked"] = bool(row.get("checked")) row["checked"] = bool(row.get("checked"))
row["display_source"] = clean_source_label(row.get("source", "")) row["display_source"] = clean_source_label(row.get("source", ""))
row["note"] = str(row.get("note") or "").strip()
if isinstance(row.get("created_at"), (datetime, date)): if isinstance(row.get("created_at"), (datetime, date)):
row["created_at"] = row["created_at"].isoformat() row["created_at"] = row["created_at"].isoformat()
if isinstance(row.get("updated_at"), (datetime, date)): if isinstance(row.get("updated_at"), (datetime, date)):
@@ -1058,7 +1101,7 @@ def update_shopping_item_category(item_id: int, category: str) -> Optional[Dict[
def get_shopping_list() -> List[Dict[str, Any]]: def get_shopping_list() -> List[Dict[str, Any]]:
"""Gibt alle Einträge der Einkaufsliste zurück, sortiert nach unvollständig/erledigt.""" """Gibt alle Einträge der Einkaufsliste zurück, sortiert nach unvollständig/erledigt."""
query = """ query = """
SELECT id, item_name, amount, unit, checked, source, category, created_at, updated_at SELECT id, item_name, amount, unit, checked, source, category, note, created_at, updated_at
FROM shopping_list FROM shopping_list
ORDER BY checked ASC, id DESC ORDER BY checked ASC, id DESC
""" """
@@ -1068,6 +1111,7 @@ def get_shopping_list() -> List[Dict[str, Any]]:
if not r.get("category"): if not r.get("category"):
r["category"] = guess_category_for_ingredient(r.get("item_name", "")) r["category"] = guess_category_for_ingredient(r.get("item_name", ""))
r["display_source"] = clean_source_label(r.get("source", "")) r["display_source"] = clean_source_label(r.get("source", ""))
r["note"] = str(r.get("note") or "").strip()
if isinstance(r.get("created_at"), (datetime, date)): if isinstance(r.get("created_at"), (datetime, date)):
r["created_at"] = r["created_at"].isoformat() r["created_at"] = r["created_at"].isoformat()
if isinstance(r.get("updated_at"), (datetime, date)): if isinstance(r.get("updated_at"), (datetime, date)):
@@ -1075,19 +1119,28 @@ def get_shopping_list() -> List[Dict[str, Any]]:
return rows return rows
def add_shopping_item(item_name: str, amount: str = "", unit: str = "", source: str = "", category: Optional[str] = None) -> Dict[str, Any]: def add_shopping_item(
item_name: str,
amount: str = "",
unit: str = "",
source: str = "",
category: Optional[str] = None,
note: str = ""
) -> Dict[str, Any]:
"""Fügt einen einzelnen Eintrag zur Einkaufsliste hinzu.""" """Fügt einen einzelnen Eintrag zur Einkaufsliste hinzu."""
cat = (category or "").strip() cat = (category or "").strip()
if not cat: if not cat:
cat = guess_category_for_ingredient(item_name) cat = guess_category_for_ingredient(item_name)
clean_src = clean_source_label(source) clean_src = clean_source_label(source)
query = "INSERT INTO shopping_list (item_name, amount, unit, source, category, checked) VALUES (?, ?, ?, ?, ?, 0)" clean_note = (note or "").strip()
res = execute_query(query, (item_name.strip(), amount.strip(), unit.strip(), clean_src, cat), fetch="none", commit=True) query = "INSERT INTO shopping_list (item_name, amount, unit, source, category, note, checked) VALUES (?, ?, ?, ?, ?, ?, 0)"
res = execute_query(query, (item_name.strip(), amount.strip(), unit.strip(), clean_src, cat, clean_note), fetch="none", commit=True)
item_id = res.get("lastrowid") item_id = res.get("lastrowid")
row = execute_query("SELECT * FROM shopping_list WHERE id = ?", (item_id,), fetch="one") row = execute_query("SELECT * FROM shopping_list WHERE id = ?", (item_id,), fetch="one")
if row: if row:
row["checked"] = bool(row.get("checked")) row["checked"] = bool(row.get("checked"))
row["display_source"] = clean_source_label(row.get("source", "")) row["display_source"] = clean_source_label(row.get("source", ""))
row["note"] = str(row.get("note") or "").strip()
if isinstance(row.get("created_at"), (datetime, date)): if isinstance(row.get("created_at"), (datetime, date)):
row["created_at"] = row["created_at"].isoformat() row["created_at"] = row["created_at"].isoformat()
if isinstance(row.get("updated_at"), (datetime, date)): if isinstance(row.get("updated_at"), (datetime, date)):
@@ -1111,8 +1164,9 @@ def add_shopping_items_bulk(items: List[Dict[str, Any]]) -> int:
cat = str(it.get("category") or "").strip() cat = str(it.get("category") or "").strip()
if not cat: if not cat:
cat = guess_category_for_ingredient(name) cat = guess_category_for_ingredient(name)
query = "INSERT INTO shopping_list (item_name, amount, unit, source, category, checked) VALUES (?, ?, ?, ?, ?, 0)" note = str(it.get("note") or "").strip()
execute_query(query, (name, amt, unit, src, cat), fetch="none", commit=True) query = "INSERT INTO shopping_list (item_name, amount, unit, source, category, note, checked) VALUES (?, ?, ?, ?, ?, ?, 0)"
execute_query(query, (name, amt, unit, src, cat, note), fetch="none", commit=True)
count += 1 count += 1
return count return count
+117 -16
View File
@@ -9,7 +9,7 @@ import hashlib
from typing import List, Optional, Dict, Any from typing import List, Optional, Dict, Any
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException, Request, Query, Form, File, UploadFile from fastapi import FastAPI, HTTPException, Request, Query, Form, File, UploadFile, WebSocket, WebSocketDisconnect
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
@@ -57,7 +57,8 @@ from .database import (
delete_shopping_category, delete_shopping_category,
reorder_shopping_categories, reorder_shopping_categories,
reset_default_shopping_categories, reset_default_shopping_categories,
update_shopping_item_category update_shopping_item_category,
update_shopping_item
) )
BASE_DIR = os.path.dirname(os.path.abspath(__file__)) BASE_DIR = os.path.dirname(os.path.abspath(__file__))
@@ -194,6 +195,14 @@ class ShoppingItemCreate(BaseModel):
unit: Optional[str] = "" unit: Optional[str] = ""
source: Optional[str] = "" source: Optional[str] = ""
category: Optional[str] = None category: Optional[str] = None
note: Optional[str] = ""
class ShoppingItemUpdate(BaseModel):
item_name: str
amount: Optional[str] = ""
unit: Optional[str] = ""
category: Optional[str] = None
note: Optional[str] = ""
class ShoppingBulkCreate(BaseModel): class ShoppingBulkCreate(BaseModel):
items: List[ShoppingItemCreate] items: List[ShoppingItemCreate]
@@ -945,6 +954,46 @@ def api_clear_mealplan_week(year: int = Query(...), week: int = Query(...)):
return {"status": "ok", "cleared_count": count} return {"status": "ok", "cleared_count": count}
# ==========================================
# WEBSOCKET CONNECTION MANAGER FOR LIVE SYNC
# ==========================================
class ShoppingWebSocketManager:
def __init__(self):
self.active_connections: List[WebSocket] = []
async def connect(self, websocket: WebSocket):
await websocket.accept()
self.active_connections.append(websocket)
def disconnect(self, websocket: WebSocket):
if websocket in self.active_connections:
self.active_connections.remove(websocket)
async def broadcast(self, message: dict):
for connection in list(self.active_connections):
try:
await connection.send_json(message)
except Exception:
if connection in self.active_connections:
self.active_connections.remove(connection)
shopping_ws_manager = ShoppingWebSocketManager()
@app.websocket("/ws/shopping-list")
async def websocket_shopping_list_endpoint(websocket: WebSocket):
await shopping_ws_manager.connect(websocket)
try:
while True:
data = await websocket.receive_text()
if data == "ping":
await websocket.send_text("pong")
except WebSocketDisconnect:
shopping_ws_manager.disconnect(websocket)
except Exception:
shopping_ws_manager.disconnect(websocket)
# ========================================== # ==========================================
# SHOPPING LIST API # SHOPPING LIST API
# ========================================== # ==========================================
@@ -957,7 +1006,7 @@ def api_get_shopping_list():
@app.post("/api/shopping-list") @app.post("/api/shopping-list")
def api_add_shopping_item(payload: ShoppingItemCreate): async def api_add_shopping_item(payload: ShoppingItemCreate):
if not payload.item_name.strip(): if not payload.item_name.strip():
raise HTTPException(status_code=400, detail="Name des Eintrags darf nicht leer sein.") raise HTTPException(status_code=400, detail="Name des Eintrags darf nicht leer sein.")
item = add_shopping_item( item = add_shopping_item(
@@ -965,49 +1014,75 @@ def api_add_shopping_item(payload: ShoppingItemCreate):
amount=payload.amount or "", amount=payload.amount or "",
unit=payload.unit or "", unit=payload.unit or "",
source=payload.source or "", source=payload.source or "",
category=payload.category category=payload.category,
note=payload.note or ""
) )
await shopping_ws_manager.broadcast({"action": "items_updated", "items": get_shopping_list()})
return {"status": "ok", "item": item} return {"status": "ok", "item": item}
@app.put("/api/shopping-list/{item_id}")
async def api_update_shopping_item(item_id: int, payload: ShoppingItemUpdate):
if not payload.item_name.strip():
raise HTTPException(status_code=400, detail="Name darf nicht leer sein.")
updated = update_shopping_item(
item_id=item_id,
item_name=payload.item_name,
amount=payload.amount or "",
unit=payload.unit or "",
category=payload.category,
note=payload.note or ""
)
if not updated:
raise HTTPException(status_code=404, detail="Eintrag nicht gefunden.")
await shopping_ws_manager.broadcast({"action": "items_updated", "items": get_shopping_list()})
return {"status": "ok", "item": updated}
@app.post("/api/shopping-list/bulk") @app.post("/api/shopping-list/bulk")
def api_add_shopping_bulk(payload: ShoppingBulkCreate): async def api_add_shopping_bulk(payload: ShoppingBulkCreate):
items_dicts = [it.model_dump() for it in payload.items] items_dicts = [it.model_dump() for it in payload.items]
count = add_shopping_items_bulk(items_dicts) count = add_shopping_items_bulk(items_dicts)
await shopping_ws_manager.broadcast({"action": "items_updated", "items": get_shopping_list()})
return {"status": "ok", "added_count": count} return {"status": "ok", "added_count": count}
@app.post("/api/shopping-list/{item_id}/toggle") @app.post("/api/shopping-list/{item_id}/toggle")
def api_toggle_shopping_item(item_id: int): async def api_toggle_shopping_item(item_id: int):
updated = toggle_shopping_item(item_id) updated = toggle_shopping_item(item_id)
if not updated: if not updated:
raise HTTPException(status_code=404, detail="Eintrag nicht gefunden.") raise HTTPException(status_code=404, detail="Eintrag nicht gefunden.")
await shopping_ws_manager.broadcast({"action": "items_updated", "items": get_shopping_list()})
return {"status": "ok", "item": updated} return {"status": "ok", "item": updated}
@app.post("/api/shopping-list/{item_id}/category") @app.post("/api/shopping-list/{item_id}/category")
def api_update_item_category(item_id: int, payload: ShoppingItemCategoryUpdate): async def api_update_item_category(item_id: int, payload: ShoppingItemCategoryUpdate):
updated = update_shopping_item_category(item_id, payload.category) updated = update_shopping_item_category(item_id, payload.category)
if not updated: if not updated:
raise HTTPException(status_code=404, detail="Eintrag nicht gefunden.") raise HTTPException(status_code=404, detail="Eintrag nicht gefunden.")
await shopping_ws_manager.broadcast({"action": "items_updated", "items": get_shopping_list()})
return {"status": "ok", "item": updated} return {"status": "ok", "item": updated}
@app.delete("/api/shopping-list/checked") @app.delete("/api/shopping-list/checked")
def api_delete_checked_shopping(): async def api_delete_checked_shopping():
count = delete_checked_shopping_items() count = delete_checked_shopping_items()
await shopping_ws_manager.broadcast({"action": "items_updated", "items": get_shopping_list()})
return {"status": "ok", "deleted_count": count} return {"status": "ok", "deleted_count": count}
@app.delete("/api/shopping-list/all") @app.delete("/api/shopping-list/all")
def api_clear_shopping_list(): async def api_clear_shopping_list():
count = clear_shopping_list() count = clear_shopping_list()
await shopping_ws_manager.broadcast({"action": "items_updated", "items": get_shopping_list()})
return {"status": "ok", "cleared_count": count} return {"status": "ok", "cleared_count": count}
@app.delete("/api/shopping-list/{item_id}") @app.delete("/api/shopping-list/{item_id}")
def api_delete_shopping_item(item_id: int): async def api_delete_shopping_item(item_id: int):
success = delete_shopping_item(item_id) success = delete_shopping_item(item_id)
await shopping_ws_manager.broadcast({"action": "items_updated", "items": get_shopping_list()})
return {"status": "ok", "deleted": success} return {"status": "ok", "deleted": success}
@@ -1022,37 +1097,63 @@ def api_get_shopping_categories():
@app.post("/api/shopping-categories") @app.post("/api/shopping-categories")
def api_add_shopping_category(payload: ShoppingCategoryCreate): async def api_add_shopping_category(payload: ShoppingCategoryCreate):
try: try:
cat = add_shopping_category(payload.name, payload.icon or "🛒", payload.sort_order) cat = add_shopping_category(payload.name, payload.icon or "🛒", payload.sort_order)
await shopping_ws_manager.broadcast({
"action": "categories_updated",
"categories": get_shopping_categories(),
"items": get_shopping_list()
})
return {"status": "ok", "category": cat} return {"status": "ok", "category": cat}
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
@app.put("/api/shopping-categories/{cat_id}") @app.put("/api/shopping-categories/{cat_id}")
def api_update_shopping_category(cat_id: int, payload: ShoppingCategoryUpdate): async def api_update_shopping_category(cat_id: int, payload: ShoppingCategoryUpdate):
cat = update_shopping_category(cat_id, payload.name, payload.icon or "🛒", payload.sort_order) cat = update_shopping_category(cat_id, payload.name, payload.icon or "🛒", payload.sort_order)
if not cat: if not cat:
raise HTTPException(status_code=404, detail="Bereich nicht gefunden.") raise HTTPException(status_code=404, detail="Bereich nicht gefunden.")
await shopping_ws_manager.broadcast({
"action": "categories_updated",
"categories": get_shopping_categories(),
"items": get_shopping_list()
})
return {"status": "ok", "category": cat} return {"status": "ok", "category": cat}
@app.delete("/api/shopping-categories/{cat_id}") @app.delete("/api/shopping-categories/{cat_id}")
def api_delete_shopping_category(cat_id: int): async def api_delete_shopping_category(cat_id: int):
success = delete_shopping_category(cat_id) success = delete_shopping_category(cat_id)
await shopping_ws_manager.broadcast({
"action": "categories_updated",
"categories": get_shopping_categories(),
"items": get_shopping_list()
})
return {"status": "ok", "deleted": success} return {"status": "ok", "deleted": success}
@app.post("/api/shopping-categories/reorder") @app.post("/api/shopping-categories/reorder")
def api_reorder_shopping_categories(payload: ShoppingReorderRequest): async def api_reorder_shopping_categories(payload: ShoppingReorderRequest):
reorder_shopping_categories(payload.category_ids) reorder_shopping_categories(payload.category_ids)
return {"status": "ok", "categories": get_shopping_categories()} cats = get_shopping_categories()
await shopping_ws_manager.broadcast({
"action": "categories_updated",
"categories": cats,
"items": get_shopping_list()
})
return {"status": "ok", "categories": cats}
@app.post("/api/shopping-categories/reset-defaults") @app.post("/api/shopping-categories/reset-defaults")
def api_reset_default_shopping_categories(): async def api_reset_default_shopping_categories():
cats = reset_default_shopping_categories() cats = reset_default_shopping_categories()
await shopping_ws_manager.broadcast({
"action": "categories_updated",
"categories": cats,
"items": get_shopping_list()
})
return {"status": "ok", "categories": cats} return {"status": "ok", "categories": cats}
+142 -199
View File
@@ -4,17 +4,17 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>What To Cook? – Menü</title> <title>What To Cook? – Menü</title>
<link rel="stylesheet" href="/static/cookbook.css?v=4.6.0"> <link rel="stylesheet" href="/static/cookbook.css?v=4.6.1">
<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>"> <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> <style>
:root { :root {
--font-hand: 'Caveat', cursive, sans-serif; --font-hand: 'Caveat', cursive, sans-serif;
--font-serif: 'Lora', Georgia, serif;
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
--tannengruen: #1E4D3B; --tannengruen: #1E4D3B;
--altrosa: #D9A0A0;
--creme: #FDFBF7; --creme: #FDFBF7;
--schwarz: #222222; --schwarz: #222222;
--gold: #D4AF37; --gelb: #FFF9D2;
} }
* { * {
@@ -28,8 +28,8 @@
min-height: 100dvh; min-height: 100dvh;
background-color: var(--grau-bg, #ebe7e0); background-color: var(--grau-bg, #ebe7e0);
background-image: background-image:
radial-gradient(circle at 50% 50%, rgba(30, 77, 59, 0.04) 1px, transparent 1px); radial-gradient(circle at 50% 50%, rgba(30, 77, 59, 0.05) 1.5px, transparent 1.5px);
background-size: 20px 20px; background-size: 24px 24px;
font-family: var(--font-sans); font-family: var(--font-sans);
color: var(--schwarz); color: var(--schwarz);
display: flex; display: flex;
@@ -37,14 +37,14 @@
justify-content: center; justify-content: center;
align-items: center; align-items: center;
position: relative; position: relative;
padding: 24px 16px; padding: 30px 20px;
} }
/* Dezenter Logout-Button oben rechts */ /* Logout-Button oben rechts */
.home-top-bar { .home-top-bar {
position: absolute; position: absolute;
top: 16px; top: 18px;
right: 18px; right: 20px;
z-index: 10; z-index: 10;
} }
@@ -52,228 +52,202 @@
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
padding: 6px 12px; padding: 7px 14px;
background: rgba(255, 255, 255, 0.8); background: rgba(255, 255, 255, 0.9);
border: 1.5px solid rgba(34, 34, 34, 0.2); border: 1.5px solid rgba(34, 34, 34, 0.25);
border-radius: 20px; border-radius: 20px;
font-size: 0.82rem; font-size: 0.85rem;
font-weight: 600; font-weight: 600;
color: #666; color: #555;
text-decoration: none; text-decoration: none;
transition: all 0.15s ease; transition: all 0.15s ease;
backdrop-filter: blur(4px); box-shadow: 2px 2px 0px rgba(0,0,0,0.06);
} }
.home-logout-pill:hover { .home-logout-pill:hover {
color: #b00; color: #b00;
border-color: #b00; border-color: #b00;
background: #ffffff; background: #ffffff;
transform: translateY(-1px); transform: translateY(-1px);
box-shadow: 2px 3px 0px rgba(0,0,0,0.12);
} }
/* Kacheln Hauptcontainer */ /* Zentraler Hub */
.home-hub { .home-hub {
width: 100%; width: 100%;
max-width: 820px; max-width: 760px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 22px; gap: 28px;
margin: auto 0; margin: auto 0;
} }
/* Das 5-Kachel-Grid */ .home-header {
.tile-grid { text-align: center;
}
.home-main-title {
font-family: var(--font-hand);
font-size: 2.8rem;
font-weight: 700;
color: var(--tannengruen);
margin: 0 0 4px 0;
line-height: 1;
}
.home-main-subtitle {
font-size: 1rem;
color: #666;
margin: 0;
font-family: var(--font-serif);
font-style: italic;
}
/* Das 2-Kachel-Portal */
.portal-grid {
width: 100%; width: 100%;
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); grid-template-columns: repeat(2, 1fr);
gap: 16px; gap: 24px;
} }
/* Wenn 5 Kacheln auf Desktop: Schöne Verteilung */ .portal-card {
@media (min-width: 768px) {
.tile-grid {
grid-template-columns: repeat(6, 1fr);
}
/* Obere Reihe: 3 Kacheln je 2 Spalten */
.tile-card:nth-child(1) { grid-column: span 2; }
.tile-card:nth-child(2) { grid-column: span 2; }
.tile-card:nth-child(3) { grid-column: span 2; }
/* Untere Reihe: 2 Kacheln je 3 Spalten (zentriert) */
.tile-card:nth-child(4) { grid-column: 2 / span 2; }
.tile-card:nth-child(5) { grid-column: 4 / span 2; }
}
/* Einzelne interaktive Kachel */
.tile-card {
background: #FFFDF9; background: #FFFDF9;
border: 2.5px solid var(--schwarz); border: 3px solid var(--schwarz);
border-radius: 16px; border-radius: 20px;
padding: 22px 18px; padding: 34px 24px;
text-decoration: none; text-decoration: none;
color: var(--schwarz); color: var(--schwarz);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
text-align: center; text-align: center;
gap: 10px; gap: 14px;
box-shadow: 4px 5px 0px rgba(34, 34, 34, 0.9); box-shadow: 6px 7px 0px var(--schwarz);
transition: transform 0.15s ease, box-shadow 0.15s ease, background-color 0.15s ease; transition: all 0.16s cubic-bezier(0.175, 0.885, 0.32, 1.275);
position: relative; position: relative;
user-select: none; user-select: none;
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
} }
.tile-card:hover { .portal-card:hover {
transform: translateY(-3px); transform: translateY(-4px);
box-shadow: 5px 8px 0px rgba(34, 34, 34, 0.95); box-shadow: 8px 11px 0px var(--schwarz);
background: #ffffff; background: #ffffff;
} }
.tile-card:active { .portal-card:active {
transform: translate(2px, 3px); transform: translate(3px, 4px);
box-shadow: 1px 2px 0px rgba(34, 34, 34, 0.9); box-shadow: 2px 2px 0px var(--schwarz);
} }
/* Icon Kreis */ .portal-icon-box {
.tile-icon-box { width: 82px;
width: 62px; height: 82px;
height: 62px;
border-radius: 50%; border-radius: 50%;
border: 2px solid var(--schwarz); border: 2.5px solid var(--schwarz);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 2rem; font-size: 2.7rem;
box-shadow: 2px 2px 0px rgba(0,0,0,0.12); box-shadow: 3px 3px 0px rgba(0,0,0,0.12);
transition: transform 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275); transition: transform 0.2s ease;
} }
.portal-card:hover .portal-icon-box {
.tile-card:hover .tile-icon-box {
transform: scale(1.08) rotate(-4deg); transform: scale(1.08) rotate(-4deg);
} }
/* Spezifische Akzentfarben pro Kachel */ .portal-cookbook .portal-icon-box {
.tile-shuffle .tile-icon-box { background: #EAF4EE;
background: #FFE885;
} }
.tile-cookbook .tile-icon-box { .portal-shopping .portal-icon-box {
background: #E8F2EC; background: #FFF8D6;
}
.tile-mealplan .tile-icon-box {
background: #F8EFEA;
}
.tile-shopping .tile-icon-box {
background: #FFF9D2;
}
.tile-recipe .tile-icon-box {
background: #EAF4F4;
} }
.tile-title { .portal-title {
font-family: var(--font-hand); font-family: var(--font-hand);
font-size: 1.75rem; font-size: 2.4rem;
font-weight: 700; font-weight: 700;
color: var(--tannengruen); color: var(--tannengruen);
margin: 0; margin: 0;
line-height: 1.1; line-height: 1;
} }
.tile-subtitle { .portal-subtitle {
font-size: 0.85rem; font-size: 0.92rem;
color: #666; color: #666;
margin: 0; margin: 0;
line-height: 1.3; line-height: 1.35;
font-weight: 500; font-weight: 500;
max-width: 240px;
} }
/* Optionaler Badge (z. B. offene Einkäufe) */ .portal-badge {
.tile-badge {
display: inline-block; display: inline-block;
font-size: 0.74rem; font-size: 0.82rem;
font-weight: 700; font-weight: 700;
padding: 2px 8px; padding: 4px 12px;
border-radius: 12px; border-radius: 20px;
background: rgba(30, 77, 59, 0.1); border: 1.5px solid var(--schwarz);
margin-top: 6px;
box-shadow: 2px 2px 0px rgba(0,0,0,0.1);
}
.portal-cookbook .portal-badge {
background: #E8F2EC;
color: var(--tannengruen); color: var(--tannengruen);
border: 1px solid rgba(30, 77, 59, 0.25); }
margin-top: 2px; .portal-shopping .portal-badge {
background: var(--gelb);
color: #7A5C00;
} }
/* Rezeptanzahl direkt unter den Kacheln */ /* Mobile Responsive */
.home-recipe-count { @media (max-width: 640px) {
font-family: var(--font-hand);
font-size: 1.55rem;
font-weight: 700;
color: var(--tannengruen);
background: rgba(255, 255, 255, 0.85);
border: 1.5px solid rgba(34, 34, 34, 0.3);
border-radius: 30px;
padding: 6px 20px;
box-shadow: 2px 3px 0px rgba(0,0,0,0.1);
letter-spacing: 0.3px;
}
/* Mobile Anpassung: Schönes 2-Spalten-Grid */
@media (max-width: 540px) {
body.home-clean-body { body.home-clean-body {
padding: 50px 14px 20px 14px; padding: 50px 16px 24px 16px;
justify-content: flex-start; justify-content: flex-start;
} }
.home-main-title {
.tile-grid { font-size: 2.3rem;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
} }
.portal-grid {
.tile-card { grid-template-columns: 1fr;
padding: 16px 10px; gap: 16px;
border-width: 2px;
} }
.portal-card {
.tile-icon-box { padding: 24px 20px;
width: 50px; border-radius: 16px;
height: 50px;
font-size: 1.6rem;
}
.tile-title {
font-size: 1.4rem;
}
.tile-subtitle {
font-size: 0.78rem;
}
/* 5. Kachel über die volle Breite auf Mobile */
.tile-card:nth-child(5) {
grid-column: span 2;
flex-direction: row; flex-direction: row;
justify-content: center;
gap: 14px;
padding: 14px 18px;
}
.tile-card:nth-child(5) .tile-icon-box {
width: 44px;
height: 44px;
font-size: 1.4rem;
}
.tile-card:nth-child(5) div {
text-align: left; text-align: left;
gap: 18px;
box-shadow: 4px 5px 0px var(--schwarz);
} }
.portal-card:hover {
.home-recipe-count { transform: translateY(-2px);
font-size: 1.35rem; box-shadow: 5px 7px 0px var(--schwarz);
padding: 5px 16px; }
.portal-card:active {
transform: translate(2px, 2px);
box-shadow: 1px 1px 0px var(--schwarz);
}
.portal-icon-box {
width: 68px;
height: 68px;
font-size: 2.2rem;
flex-shrink: 0;
}
.portal-title {
font-size: 1.95rem;
}
.portal-subtitle {
font-size: 0.85rem;
} }
} }
</style> </style>
</head> </head>
<body class="home-clean-body"> <body class="home-clean-body">
<!-- Unaufdringlicher Logout-Button oben rechts --> <!-- Logout-Button oben rechts -->
<div class="home-top-bar"> <div class="home-top-bar">
<a href="/logout" class="home-logout-pill" title="Sitzung beenden"> <a href="/logout" class="home-logout-pill" title="Sitzung beenden">
<span>Abmelden</span> <span>Abmelden</span>
@@ -281,67 +255,36 @@
</a> </a>
</div> </div>
<!-- Zentrale Menü-Kacheln --> <!-- Zentrale Portale -->
<main class="home-hub"> <main class="home-hub">
<div class="tile-grid"> <div class="home-header">
<h1 class="home-main-title">What To Cook?</h1>
<p class="home-main-subtitle">Kochen, Planen & Einkaufen</p>
</div>
<!-- 1. ZUFALLSGENERATOR --> <div class="portal-grid">
<a href="/cookbook?open=generator" class="tile-card tile-shuffle" title="Essens-Zufallsgenerator starten">
<div class="tile-icon-box">🎲</div> <!-- PORTAL 1: KOCHBUCH -->
<a href="/cookbook" class="portal-card portal-cookbook" title="Zum Kochbuch & Essensplaner">
<div class="portal-icon-box">📖</div>
<div> <div>
<h2 class="tile-title">Zufallsgenerator</h2> <h2 class="portal-title">Kochbuch</h2>
<p class="tile-subtitle">Gerichte auslosen</p> <p class="portal-subtitle">Rezepte durchstöbern • Zufallsgenerator • Wochenmenüplan</p>
<span class="portal-badge">📖 {{ recipe_count }} Rezepte</span>
</div> </div>
</a> </a>
<!-- 2. KOCHBUCH --> <!-- PORTAL 2: EINKAUFSLISTE -->
<a href="/cookbook" class="tile-card tile-cookbook" title="Zum Kochbuch"> <a href="/shopping-list" class="portal-card portal-shopping" title="Zur Einkaufsliste">
<div class="tile-icon-box">📖</div> <div class="portal-icon-box">🛒</div>
<div> <div>
<h2 class="tile-title">Kochbuch</h2> <h2 class="portal-title">Einkaufsliste</h2>
<p class="tile-subtitle">Rezepte & Druckansicht</p> <p class="portal-subtitle">Supermarkt-Laufweg • Live-Sync • Wochenplan-Import</p>
<span class="portal-badge">🛒 {{ open_shopping_count }} offene Artikel</span>
</div> </div>
</a> </a>
<!-- 3. WOCHENMENÜPLAN -->
<a href="/cookbook?open=mealplan" class="tile-card tile-mealplan" title="Wochenmenüplan öffnen">
<div class="tile-icon-box">📅</div>
<div>
<h2 class="tile-title">Wochenmenüplan</h2>
<p class="tile-subtitle">KW {{ current_week }} planen</p>
</div>
</a>
<!-- 4. EINKAUFSLISTE -->
<a href="/shopping-list" class="tile-card tile-shopping" title="Zur Einkaufsliste">
<div class="tile-icon-box">🛒</div>
<div>
<h2 class="tile-title">Einkaufsliste</h2>
<p class="tile-subtitle">
{% if open_shopping_count > 0 %}
<span class="tile-badge">{{ open_shopping_count }} offen</span>
{% else %}
Zutaten & Notizen
{% endif %}
</p>
</div>
</a>
<!-- 5. REZEPT HINZUFÜGEN -->
<a href="/cookbook?open=new-recipe" class="tile-card tile-recipe" title="Neues Rezept erfassen">
<div class="tile-icon-box">➕</div>
<div>
<h2 class="tile-title">Rezept hinzufügen</h2>
<p class="tile-subtitle">Neues Gericht eintragen</p>
</div>
</a>
</div>
<!-- Rezeptanzahl direkt unter den Kacheln -->
<div class="home-recipe-count">
📖 {{ recipe_count }} Rezepte im Kochbuch
</div> </div>
</main> </main>
File diff suppressed because it is too large Load Diff