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:
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")
else:
cursor = conn.cursor()
@@ -421,6 +431,8 @@ def init_db():
existing_shop_cols = [c[1] for c in cursor.fetchall()]
if "category" not in existing_shop_cols:
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")
conn.commit()
@@ -1039,6 +1051,36 @@ def reset_default_shopping_categories() -> List[Dict[str, Any]]:
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]]:
"""Ändert die Kategorie/den Bereich eines einzelnen Listeneintrags."""
clean_cat = category.strip()
@@ -1048,6 +1090,7 @@ def update_shopping_item_category(item_id: int, category: str) -> Optional[Dict[
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)):
@@ -1058,7 +1101,7 @@ def update_shopping_item_category(item_id: int, category: str) -> Optional[Dict[
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, category, created_at, updated_at
SELECT id, item_name, amount, unit, checked, source, category, note, created_at, updated_at
FROM shopping_list
ORDER BY checked ASC, id DESC
"""
@@ -1068,6 +1111,7 @@ def get_shopping_list() -> List[Dict[str, Any]]:
if not r.get("category"):
r["category"] = guess_category_for_ingredient(r.get("item_name", ""))
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)):
r["created_at"] = r["created_at"].isoformat()
if isinstance(r.get("updated_at"), (datetime, date)):
@@ -1075,19 +1119,28 @@ def get_shopping_list() -> List[Dict[str, Any]]:
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."""
cat = (category or "").strip()
if not cat:
cat = guess_category_for_ingredient(item_name)
clean_src = clean_source_label(source)
query = "INSERT INTO shopping_list (item_name, amount, unit, source, category, checked) VALUES (?, ?, ?, ?, ?, 0)"
res = execute_query(query, (item_name.strip(), amount.strip(), unit.strip(), clean_src, cat), fetch="none", commit=True)
clean_note = (note or "").strip()
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")
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)):
@@ -1111,8 +1164,9 @@ def add_shopping_items_bulk(items: List[Dict[str, Any]]) -> int:
cat = str(it.get("category") or "").strip()
if not cat:
cat = guess_category_for_ingredient(name)
query = "INSERT INTO shopping_list (item_name, amount, unit, source, category, checked) VALUES (?, ?, ?, ?, ?, 0)"
execute_query(query, (name, amt, unit, src, cat), fetch="none", commit=True)
note = str(it.get("note") or "").strip()
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
return count
+117 -16
View File
@@ -9,7 +9,7 @@ import hashlib
from typing import List, Optional, Dict, Any
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException, Request, Query, Form, File, UploadFile
from fastapi import FastAPI, HTTPException, Request, Query, Form, File, UploadFile, WebSocket, WebSocketDisconnect
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
@@ -57,7 +57,8 @@ from .database import (
delete_shopping_category,
reorder_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__))
@@ -194,6 +195,14 @@ class ShoppingItemCreate(BaseModel):
unit: Optional[str] = ""
source: Optional[str] = ""
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):
items: List[ShoppingItemCreate]
@@ -945,6 +954,46 @@ def api_clear_mealplan_week(year: int = Query(...), week: int = Query(...)):
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
# ==========================================
@@ -957,7 +1006,7 @@ def api_get_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():
raise HTTPException(status_code=400, detail="Name des Eintrags darf nicht leer sein.")
item = add_shopping_item(
@@ -965,49 +1014,75 @@ def api_add_shopping_item(payload: ShoppingItemCreate):
amount=payload.amount or "",
unit=payload.unit 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}
@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")
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]
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}
@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)
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/{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)
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.delete("/api/shopping-list/checked")
def api_delete_checked_shopping():
async def api_delete_checked_shopping():
count = delete_checked_shopping_items()
await shopping_ws_manager.broadcast({"action": "items_updated", "items": get_shopping_list()})
return {"status": "ok", "deleted_count": count}
@app.delete("/api/shopping-list/all")
def api_clear_shopping_list():
async def api_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}
@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)
await shopping_ws_manager.broadcast({"action": "items_updated", "items": get_shopping_list()})
return {"status": "ok", "deleted": success}
@@ -1022,37 +1097,63 @@ def api_get_shopping_categories():
@app.post("/api/shopping-categories")
def api_add_shopping_category(payload: ShoppingCategoryCreate):
async def api_add_shopping_category(payload: ShoppingCategoryCreate):
try:
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}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@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)
if not cat:
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}
@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)
await shopping_ws_manager.broadcast({
"action": "categories_updated",
"categories": get_shopping_categories(),
"items": get_shopping_list()
})
return {"status": "ok", "deleted": success}
@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)
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")
def api_reset_default_shopping_categories():
async def api_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}
+149 -206
View File
@@ -4,17 +4,17 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<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>">
<style>
:root {
--font-hand: 'Caveat', cursive, sans-serif;
--font-serif: 'Lora', Georgia, serif;
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
--tannengruen: #1E4D3B;
--altrosa: #D9A0A0;
--creme: #FDFBF7;
--schwarz: #222222;
--gold: #D4AF37;
--gelb: #FFF9D2;
}
* {
@@ -28,8 +28,8 @@
min-height: 100dvh;
background-color: var(--grau-bg, #ebe7e0);
background-image:
radial-gradient(circle at 50% 50%, rgba(30, 77, 59, 0.04) 1px, transparent 1px);
background-size: 20px 20px;
radial-gradient(circle at 50% 50%, rgba(30, 77, 59, 0.05) 1.5px, transparent 1.5px);
background-size: 24px 24px;
font-family: var(--font-sans);
color: var(--schwarz);
display: flex;
@@ -37,14 +37,14 @@
justify-content: center;
align-items: center;
position: relative;
padding: 24px 16px;
padding: 30px 20px;
}
/* Dezenter Logout-Button oben rechts */
/* Logout-Button oben rechts */
.home-top-bar {
position: absolute;
top: 16px;
right: 18px;
top: 18px;
right: 20px;
z-index: 10;
}
@@ -52,228 +52,202 @@
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: rgba(255, 255, 255, 0.8);
border: 1.5px solid rgba(34, 34, 34, 0.2);
padding: 7px 14px;
background: rgba(255, 255, 255, 0.9);
border: 1.5px solid rgba(34, 34, 34, 0.25);
border-radius: 20px;
font-size: 0.82rem;
font-size: 0.85rem;
font-weight: 600;
color: #666;
color: #555;
text-decoration: none;
transition: all 0.15s ease;
backdrop-filter: blur(4px);
box-shadow: 2px 2px 0px rgba(0,0,0,0.06);
}
.home-logout-pill:hover {
color: #b00;
border-color: #b00;
background: #ffffff;
transform: translateY(-1px);
box-shadow: 2px 3px 0px rgba(0,0,0,0.12);
}
/* Kacheln Hauptcontainer */
/* Zentraler Hub */
.home-hub {
width: 100%;
max-width: 820px;
max-width: 760px;
display: flex;
flex-direction: column;
align-items: center;
gap: 22px;
gap: 28px;
margin: auto 0;
}
/* Das 5-Kachel-Grid */
.tile-grid {
.home-header {
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%;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 16px;
grid-template-columns: repeat(2, 1fr);
gap: 24px;
}
/* Wenn 5 Kacheln auf Desktop: Schöne Verteilung */
@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 {
.portal-card {
background: #FFFDF9;
border: 2.5px solid var(--schwarz);
border-radius: 16px;
padding: 22px 18px;
border: 3px solid var(--schwarz);
border-radius: 20px;
padding: 34px 24px;
text-decoration: none;
color: var(--schwarz);
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 10px;
box-shadow: 4px 5px 0px rgba(34, 34, 34, 0.9);
transition: transform 0.15s ease, box-shadow 0.15s ease, background-color 0.15s ease;
gap: 14px;
box-shadow: 6px 7px 0px var(--schwarz);
transition: all 0.16s cubic-bezier(0.175, 0.885, 0.32, 1.275);
position: relative;
user-select: none;
-webkit-tap-highlight-color: transparent;
}
.tile-card:hover {
transform: translateY(-3px);
box-shadow: 5px 8px 0px rgba(34, 34, 34, 0.95);
.portal-card:hover {
transform: translateY(-4px);
box-shadow: 8px 11px 0px var(--schwarz);
background: #ffffff;
}
.tile-card:active {
transform: translate(2px, 3px);
box-shadow: 1px 2px 0px rgba(34, 34, 34, 0.9);
.portal-card:active {
transform: translate(3px, 4px);
box-shadow: 2px 2px 0px var(--schwarz);
}
/* Icon Kreis */
.tile-icon-box {
width: 62px;
height: 62px;
.portal-icon-box {
width: 82px;
height: 82px;
border-radius: 50%;
border: 2px solid var(--schwarz);
border: 2.5px solid var(--schwarz);
display: flex;
align-items: center;
justify-content: center;
font-size: 2rem;
box-shadow: 2px 2px 0px rgba(0,0,0,0.12);
transition: transform 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275);
font-size: 2.7rem;
box-shadow: 3px 3px 0px rgba(0,0,0,0.12);
transition: transform 0.2s ease;
}
.tile-card:hover .tile-icon-box {
.portal-card:hover .portal-icon-box {
transform: scale(1.08) rotate(-4deg);
}
/* Spezifische Akzentfarben pro Kachel */
.tile-shuffle .tile-icon-box {
background: #FFE885;
.portal-cookbook .portal-icon-box {
background: #EAF4EE;
}
.tile-cookbook .tile-icon-box {
background: #E8F2EC;
}
.tile-mealplan .tile-icon-box {
background: #F8EFEA;
}
.tile-shopping .tile-icon-box {
background: #FFF9D2;
}
.tile-recipe .tile-icon-box {
background: #EAF4F4;
.portal-shopping .portal-icon-box {
background: #FFF8D6;
}
.tile-title {
.portal-title {
font-family: var(--font-hand);
font-size: 1.75rem;
font-size: 2.4rem;
font-weight: 700;
color: var(--tannengruen);
margin: 0;
line-height: 1.1;
line-height: 1;
}
.tile-subtitle {
font-size: 0.85rem;
.portal-subtitle {
font-size: 0.92rem;
color: #666;
margin: 0;
line-height: 1.3;
line-height: 1.35;
font-weight: 500;
max-width: 240px;
}
/* Optionaler Badge (z. B. offene Einkäufe) */
.tile-badge {
.portal-badge {
display: inline-block;
font-size: 0.74rem;
font-size: 0.82rem;
font-weight: 700;
padding: 2px 8px;
border-radius: 12px;
background: rgba(30, 77, 59, 0.1);
padding: 4px 12px;
border-radius: 20px;
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);
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 */
.home-recipe-count {
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) {
/* Mobile Responsive */
@media (max-width: 640px) {
body.home-clean-body {
padding: 50px 14px 20px 14px;
padding: 50px 16px 24px 16px;
justify-content: flex-start;
}
.tile-grid {
grid-template-columns: repeat(2, 1fr);
gap: 12px;
.home-main-title {
font-size: 2.3rem;
}
.tile-card {
padding: 16px 10px;
border-width: 2px;
.portal-grid {
grid-template-columns: 1fr;
gap: 16px;
}
.tile-icon-box {
width: 50px;
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;
.portal-card {
padding: 24px 20px;
border-radius: 16px;
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;
gap: 18px;
box-shadow: 4px 5px 0px var(--schwarz);
}
.home-recipe-count {
font-size: 1.35rem;
padding: 5px 16px;
.portal-card:hover {
transform: translateY(-2px);
box-shadow: 5px 7px 0px var(--schwarz);
}
.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>
</head>
<body class="home-clean-body">
<!-- Unaufdringlicher Logout-Button oben rechts -->
<!-- Logout-Button oben rechts -->
<div class="home-top-bar">
<a href="/logout" class="home-logout-pill" title="Sitzung beenden">
<span>Abmelden</span>
@@ -281,67 +255,36 @@
</a>
</div>
<!-- Zentrale Menü-Kacheln -->
<!-- Zentrale Portale -->
<main class="home-hub">
<div class="tile-grid">
<!-- 1. ZUFALLSGENERATOR -->
<a href="/cookbook?open=generator" class="tile-card tile-shuffle" title="Essens-Zufallsgenerator starten">
<div class="tile-icon-box">🎲</div>
<div>
<h2 class="tile-title">Zufallsgenerator</h2>
<p class="tile-subtitle">Gerichte auslosen</p>
</div>
</a>
<!-- 2. KOCHBUCH -->
<a href="/cookbook" class="tile-card tile-cookbook" title="Zum Kochbuch">
<div class="tile-icon-box">📖</div>
<div>
<h2 class="tile-title">Kochbuch</h2>
<p class="tile-subtitle">Rezepte & Druckansicht</p>
</div>
</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 class="home-header">
<h1 class="home-main-title">What To Cook?</h1>
<p class="home-main-subtitle">Kochen, Planen & Einkaufen</p>
</div>
<!-- Rezeptanzahl direkt unter den Kacheln -->
<div class="home-recipe-count">
📖 {{ recipe_count }} Rezepte im Kochbuch
<div class="portal-grid">
<!-- PORTAL 1: KOCHBUCH -->
<a href="/cookbook" class="portal-card portal-cookbook" title="Zum Kochbuch & Essensplaner">
<div class="portal-icon-box">📖</div>
<div>
<h2 class="portal-title">Kochbuch</h2>
<p class="portal-subtitle">Rezepte durchstöbern • Zufallsgenerator • Wochenmenüplan</p>
<span class="portal-badge">📖 {{ recipe_count }} Rezepte</span>
</div>
</a>
<!-- PORTAL 2: EINKAUFSLISTE -->
<a href="/shopping-list" class="portal-card portal-shopping" title="Zur Einkaufsliste">
<div class="portal-icon-box">🛒</div>
<div>
<h2 class="portal-title">Einkaufsliste</h2>
<p class="portal-subtitle">Supermarkt-Laufweg • Live-Sync • Wochenplan-Import</p>
<span class="portal-badge">🛒 {{ open_shopping_count }} offene Artikel</span>
</div>
</a>
</div>
</main>
File diff suppressed because it is too large Load Diff