diff --git a/app/database.py b/app/database.py
index a92eadd..0d47ef0 100755
--- a/app/database.py
+++ b/app/database.py
@@ -1,8 +1,9 @@
import os
import re
import sqlite3
-from datetime import datetime, date
-from typing import List, Dict, Any, Optional
+import secrets
+from datetime import datetime, date, timedelta
+from typing import List, Dict, Any, Optional, Tuple
try:
from dotenv import load_dotenv
@@ -524,6 +525,21 @@ def init_db():
except Exception:
pass
+ cursor.execute("""
+ CREATE TABLE IF NOT EXISTS recipe_shares (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ recipe_id INT NOT NULL,
+ token VARCHAR(64) NOT NULL UNIQUE,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ expires_at DATETIME NULL DEFAULT NULL,
+ views_count INT DEFAULT 0,
+ revoked TINYINT DEFAULT 0,
+ label VARCHAR(100) DEFAULT '',
+ INDEX idx_share_token (token),
+ INDEX idx_share_recipe (recipe_id)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+ """)
+
cursor.execute("DELETE FROM dishes")
else:
cursor = conn.cursor()
@@ -631,6 +647,21 @@ def init_db():
if "note" not in existing_shop_cols:
cursor.execute("ALTER TABLE shopping_list ADD COLUMN note TEXT DEFAULT ''")
+ cursor.execute("""
+ CREATE TABLE IF NOT EXISTS recipe_shares (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ recipe_id INTEGER NOT NULL,
+ token TEXT NOT NULL UNIQUE,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ expires_at DATETIME NULL,
+ views_count INTEGER DEFAULT 0,
+ revoked INTEGER DEFAULT 0,
+ label TEXT DEFAULT ''
+ )
+ """)
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_share_token ON recipe_shares (token)")
+ cursor.execute("CREATE INDEX IF NOT EXISTS idx_share_recipe ON recipe_shares (recipe_id)")
+
cursor.execute("DELETE FROM dishes")
conn.commit()
finally:
@@ -1633,3 +1664,140 @@ def get_meal_plan_ingredients(year: int, week: int) -> List[Dict[str, Any]]:
+
+
+# --- Rezept-Freigaben (Share Links) ---
+
+def parse_db_datetime(dt_val: Any) -> Optional[datetime]:
+ if not dt_val:
+ return None
+ if isinstance(dt_val, datetime):
+ return dt_val
+ try:
+ return datetime.fromisoformat(str(dt_val))
+ except Exception:
+ try:
+ return datetime.strptime(str(dt_val).split(".")[0], "%Y-%m-%d %H:%M:%S")
+ except Exception:
+ return None
+
+
+def format_share_expiry(dt_val: Any) -> Tuple[str, str, bool]:
+ """
+ Gibt (formatted_date, relative_status, is_expired) zurück.
+ """
+ dt = parse_db_datetime(dt_val)
+ if not dt:
+ return ("Unbegrenzt", "Unbegrenzt gültig", False)
+
+ now = datetime.now()
+ is_expired = now > dt
+ date_str = dt.strftime("%d.%m.%Y %H:%M")
+
+ if is_expired:
+ return (date_str, f"Abgelaufen am {date_str} Uhr", True)
+
+ delta = dt - now
+ if delta.days > 0:
+ days = delta.days
+ rel = f"Noch {days} {'Tag' if days == 1 else 'Tage'} gültig"
+ else:
+ hours = max(1, int(delta.total_seconds() // 3600))
+ rel = f"Noch {hours} {'Stunde' if hours == 1 else 'Stunden'} gültig"
+
+ return (date_str, rel, False)
+
+
+def create_recipe_share(recipe_id: int, expires_in_seconds: Optional[int] = None, label: str = "") -> Dict[str, Any]:
+ token = secrets.token_urlsafe(16)
+ expires_at_dt = None
+ expires_at_sql = None
+ if expires_in_seconds is not None and expires_in_seconds > 0:
+ expires_at_dt = datetime.now() + timedelta(seconds=expires_in_seconds)
+ expires_at_sql = expires_at_dt.strftime("%Y-%m-%d %H:%M:%S")
+
+ res = execute_query(
+ "INSERT INTO recipe_shares (recipe_id, token, expires_at, label) VALUES (?, ?, ?, ?)",
+ (recipe_id, token, expires_at_sql, label.strip()),
+ fetch="none",
+ commit=True
+ )
+ last_id = res.get("lastrowid") if isinstance(res, dict) else None
+
+ fmt_date, rel_status, is_exp = format_share_expiry(expires_at_dt)
+ return {
+ "id": last_id,
+ "recipe_id": recipe_id,
+ "token": token,
+ "created_at": datetime.now().isoformat(),
+ "expires_at": expires_at_dt.isoformat() if expires_at_dt else None,
+ "expires_at_formatted": fmt_date,
+ "relative_status": rel_status,
+ "is_expired": is_exp,
+ "views_count": 0,
+ "revoked": False,
+ "label": label.strip()
+ }
+
+
+def get_recipe_share_by_token(token: str, record_view: bool = False) -> Optional[Dict[str, Any]]:
+ row = execute_query(
+ "SELECT * FROM recipe_shares WHERE token = ? LIMIT 1",
+ (token.strip(),),
+ fetch="one"
+ )
+ if not row:
+ return None
+
+ fmt_date, rel_status, is_exp = format_share_expiry(row.get("expires_at"))
+ is_revoked = bool(row.get("revoked", 0))
+
+ if record_view and not is_revoked and not is_exp:
+ execute_query("UPDATE recipe_shares SET views_count = views_count + 1 WHERE id = ?", (row["id"],), commit=True)
+ row["views_count"] = row.get("views_count", 0) + 1
+
+ row["is_expired"] = is_exp
+ row["revoked"] = is_revoked
+ row["expires_at_formatted"] = fmt_date
+ row["relative_status"] = rel_status
+ row["is_active"] = (not is_revoked and not is_exp)
+ return row
+
+
+def get_recipe_shares_for_recipe(recipe_id: int) -> List[Dict[str, Any]]:
+ rows = execute_query(
+ "SELECT * FROM recipe_shares WHERE recipe_id = ? ORDER BY id DESC",
+ (recipe_id,),
+ fetch="all"
+ )
+ results = []
+ for r in (rows or []):
+ fmt_date, rel_status, is_exp = format_share_expiry(r.get("expires_at"))
+ is_revoked = bool(r.get("revoked", 0))
+ r["is_expired"] = is_exp
+ r["revoked"] = is_revoked
+ r["expires_at_formatted"] = fmt_date
+ r["relative_status"] = rel_status
+ r["is_active"] = (not is_revoked and not is_exp)
+ results.append(r)
+ return results
+
+
+def delete_recipe_share(share_id: int, recipe_id: int) -> bool:
+ res = execute_query(
+ "DELETE FROM recipe_shares WHERE id = ? AND recipe_id = ?",
+ (share_id, recipe_id),
+ fetch="none",
+ commit=True
+ )
+ return bool(res and res.get("rowcount", 0) > 0)
+
+
+def revoke_recipe_share(share_id: int, recipe_id: int) -> bool:
+ res = execute_query(
+ "UPDATE recipe_shares SET revoked = 1 WHERE id = ? AND recipe_id = ?",
+ (share_id, recipe_id),
+ fetch="none",
+ commit=True
+ )
+ return bool(res and res.get("rowcount", 0) > 0)
diff --git a/app/main.py b/app/main.py
index 22afaa1..09c60e6 100755
--- a/app/main.py
+++ b/app/main.py
@@ -60,7 +60,12 @@ from .database import (
reset_default_shopping_categories,
update_shopping_item_category,
update_shopping_item,
- SHELF_TYPES
+ SHELF_TYPES,
+ create_recipe_share,
+ get_recipe_share_by_token,
+ get_recipe_shares_for_recipe,
+ delete_recipe_share,
+ revoke_recipe_share
)
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
@@ -110,7 +115,7 @@ app = FastAPI(title="What To Cook? - Zufallsgenerator & Kochbuch", lifespan=life
@app.middleware("http")
async def auth_middleware(request: Request, call_next):
path = request.url.path
- if path.startswith("/static/") or path in PUBLIC_PATHS:
+ if path.startswith("/static/") or path.startswith("/shared/") or path in PUBLIC_PATHS:
return await call_next(request)
token = request.cookies.get(COOKIE_NAME)
@@ -512,6 +517,44 @@ async def recipe_detail_view(request: Request, recipe_id: int):
}
)
+
+@app.get("/shared/recipe/{token}", response_class=HTMLResponse)
+async def shared_recipe_view(request: Request, token: str):
+ """Öffentlich freigegebene Rezeptseite (mit Gültigkeitsprüfung)"""
+ share = get_recipe_share_by_token(token, record_view=True)
+ if not share or share.get("is_expired") or share.get("revoked"):
+ return templates.TemplateResponse(
+ request=request,
+ name="shared_expired.html",
+ context={
+ "share": share,
+ "reason": "revoked" if (share and share.get("revoked")) else ("expired" if (share and share.get("is_expired")) else "not_found")
+ },
+ status_code=410 if (share and (share.get("is_expired") or share.get("revoked"))) else 404
+ )
+
+ recipe = get_recipe(share["recipe_id"])
+ if not recipe:
+ raise HTTPException(status_code=404, detail="Rezept nicht gefunden.")
+
+ is_matrix = bool(recipe.get("has_portion_table")) or any(k in (recipe.get("title") or "").lower() for k in ["waffel", "pancake", "pfannkuchen"])
+ recipe["has_portion_table"] = is_matrix
+ if is_matrix:
+ parsed = parse_ingredients_block(recipe.get("ingredients", ""))
+ recipe["portion_matrix"] = calculate_portion_matrix(parsed, base_portions=recipe.get("portions") or 4, targets=(2, 4, 6, 8))
+
+ return templates.TemplateResponse(
+ request=request,
+ name="recipe_detail.html",
+ context={
+ "recipe": recipe,
+ "chapters": CHAPTERS,
+ "is_shared": True,
+ "share": share
+ }
+ )
+
+
@app.get("/cookbook/new", response_class=HTMLResponse)
async def recipe_new_view(request: Request, chapter: Optional[int] = Query(None)):
"""Eingabemaske für neues Rezept"""
@@ -720,6 +763,70 @@ def api_toggle_recipe_printed(recipe_id: int):
raise HTTPException(status_code=404, detail=str(e))
+# --- Rezept-Share API ---
+
+class CreateShareRequest(BaseModel):
+ expires_in: Optional[str] = "7d"
+ label: Optional[str] = ""
+
+def parse_expiry_to_seconds(val: Optional[str]) -> Optional[int]:
+ if not val or val == "unlimited" or val == "0" or val == "never":
+ return None
+ val = val.lower().strip()
+ if val.endswith("h"):
+ return int(float(val[:-1]) * 3600)
+ elif val.endswith("d"):
+ return int(float(val[:-1]) * 86400)
+ elif val.endswith("w"):
+ return int(float(val[:-1]) * 7 * 86400)
+ elif val.endswith("m"):
+ return int(float(val[:-1]) * 30 * 86400)
+ try:
+ sec = int(val)
+ return sec if sec > 0 else None
+ except ValueError:
+ return 7 * 86400
+
+def get_base_url_for_request(request: Request) -> str:
+ scheme = request.headers.get("x-forwarded-proto", request.url.scheme)
+ host = request.headers.get("x-forwarded-host", request.headers.get("host", request.url.netloc))
+ return f"{scheme}://{host}".rstrip("/")
+
+@app.get("/api/recipes/{recipe_id}/shares")
+def api_get_recipe_shares(request: Request, recipe_id: int):
+ recipe = get_recipe(recipe_id)
+ if not recipe:
+ raise HTTPException(status_code=404, detail="Rezept nicht gefunden.")
+ shares = get_recipe_shares_for_recipe(recipe_id)
+ base_url = get_base_url_for_request(request)
+ for s in shares:
+ s["share_url"] = f"{base_url}/shared/recipe/{s['token']}"
+ s["share_path"] = f"/shared/recipe/{s['token']}"
+ if isinstance(s.get("created_at"), (datetime, date)):
+ s["created_at"] = s["created_at"].strftime("%d.%m.%Y %H:%M")
+ return {"status": "ok", "shares": shares}
+
+@app.post("/api/recipes/{recipe_id}/shares")
+def api_create_recipe_share(request: Request, recipe_id: int, payload: CreateShareRequest):
+ recipe = get_recipe(recipe_id)
+ if not recipe:
+ raise HTTPException(status_code=404, detail="Rezept nicht gefunden.")
+ sec = parse_expiry_to_seconds(payload.expires_in)
+ share = create_recipe_share(recipe_id, expires_in_seconds=sec, label=payload.label or "")
+ base_url = get_base_url_for_request(request)
+ share["share_url"] = f"{base_url}/shared/recipe/{share['token']}"
+ share["share_path"] = f"/shared/recipe/{share['token']}"
+ return {"status": "ok", "share": share}
+
+@app.delete("/api/recipes/{recipe_id}/shares/{share_id}")
+def api_delete_recipe_share(recipe_id: int, share_id: int):
+ success = delete_recipe_share(share_id, recipe_id)
+ if not success:
+ raise HTTPException(status_code=404, detail="Share-Link nicht gefunden oder bereits gelöscht.")
+ return {"status": "ok", "message": "Share-Link wurde erfolgreich gelöscht."}
+
+
+
# ==========================================
# API: ZUFALLSGENERATOR & GERICHTE
# ==========================================
diff --git a/app/static/cookbook.css b/app/static/cookbook.css
index 5c5cd5e..e0343be 100755
--- a/app/static/cookbook.css
+++ b/app/static/cookbook.css
@@ -1,2692 +1,2991 @@
-/* ==========================================================================
- Mein Kochbuch - Digital & A4 Print Layout Theme
- Farben & Typografie inspiriert vom Design-Prototyp
- ========================================================================== */
-
-@import url('https://fonts.googleapis.com/css2?family=Architects+Daughter&family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap');
-
-:root {
- --creme: #FAF6F0;
- --tannengruen: #1E4D3B;
- --tannengruen-light: rgba(30, 77, 59, 0.12);
- --altrosa: #D29B9B;
- --altrosa-light: rgba(210, 155, 155, 0.2);
- --schwarz: #222222;
- --grau-bg: #e6e2dc;
- --border-hand: 2px solid var(--schwarz);
- --font-hand: 'Architects Daughter', cursive, sans-serif;
- --font-sans: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
- --radius-recipe: 14px;
-}
-
-/* Base Body for Cookbook view */
-.cookbook-body {
- margin: 0;
- padding: 0;
- background-color: var(--grau-bg);
- font-family: var(--font-sans);
- color: var(--schwarz);
- min-height: 100vh;
- display: flex;
- flex-direction: column;
-}
-
-@media screen {
- html {
- overflow-x: clip;
- }
- .cookbook-body {
- overflow-x: clip;
- width: 100%;
- }
-}
-
-/* Cookbook Navigation Bar (Fest / Sticky beim Scrollen) */
-.cb-nav {
- position: sticky;
- top: 0;
- z-index: 1000;
- background: var(--tannengruen);
- color: var(--creme);
- padding: 6px 14px;
- min-height: 48px;
- box-shadow: 0 4px 15px rgba(0,0,0,0.25);
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 8px;
- width: 100%;
- box-sizing: border-box;
-}
-
-.cb-nav-brand {
- display: flex;
- align-items: center;
- gap: 8px;
- text-decoration: none;
- color: var(--creme);
- flex-shrink: 0;
-}
-
-.cb-brand-icon {
- font-size: 1.5rem;
- line-height: 1;
-}
-
-.cb-nav-title {
- font-family: var(--font-hand);
- font-size: 1.35rem;
- letter-spacing: 0.02em;
- margin: 0;
- white-space: nowrap;
-}
-
-/* Recipe Search Bar */
-.cb-search-box {
- display: flex;
- align-items: center;
- position: relative;
- flex: 0 1 240px;
- max-width: 280px;
- min-width: 130px;
- background: rgba(250, 246, 240, 0.18);
- border: 1.5px solid rgba(250, 246, 240, 0.45);
- border-radius: 20px;
- padding: 3px 10px;
- transition: all 0.2s ease;
- margin: 0 6px;
-}
-
-.cb-search-box:focus-within {
- background: var(--creme);
- border-color: var(--creme);
- box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.35);
-}
-
-.cb-search-icon {
- font-size: 0.95rem;
- margin-right: 6px;
- opacity: 0.85;
- user-select: none;
-}
-
-.cb-search-box:focus-within .cb-search-icon {
- filter: brightness(0.2);
-}
-
-.cb-search-input {
- background: transparent;
- border: none;
- outline: none;
- color: var(--creme);
- font-family: var(--font-hand);
- font-size: 1rem;
- width: 100%;
-}
-
-.cb-search-box:focus-within .cb-search-input {
- color: var(--schwarz);
-}
-
-.cb-search-input::placeholder {
- color: rgba(250, 246, 240, 0.7);
-}
-
-.cb-search-box:focus-within .cb-search-input::placeholder {
- color: rgba(34, 34, 34, 0.55);
-}
-
-.cb-search-clear {
- background: none;
- border: none;
- color: inherit;
- cursor: pointer;
- padding: 0 4px;
- font-size: 0.85rem;
- opacity: 0.75;
- line-height: 1;
-}
-
-.cb-search-clear:hover {
- opacity: 1;
-}
-
-.recipe-page.search-hidden,
-.divider.search-hidden {
- display: none !important;
-}
-
-#search-empty-state {
- text-align: center;
- padding: 40px 20px;
- background: rgba(255, 255, 255, 0.7);
- border: 2px dashed rgba(34, 34, 34, 0.25);
- border-radius: 12px;
- margin: 20px auto;
- max-width: 500px;
-}
-
-.cb-nav-actions {
- display: flex;
- align-items: center;
- gap: 6px;
- flex-shrink: 0;
-}
-
-.cb-btn {
- font-family: var(--font-hand);
- font-size: 1rem;
- font-weight: 700;
- padding: 8px 14px;
- border-radius: 8px;
- border: 2px solid transparent;
- cursor: pointer;
- text-decoration: none;
- display: inline-flex;
- align-items: center;
- gap: 6px;
- transition: all 0.2s ease;
-}
-
-/* 100% einheitliche Navigationsleisten-Buttons */
-.cb-nav-btn {
- background: var(--creme);
- color: var(--tannengruen);
- border: 2px solid var(--creme);
- border-radius: 8px;
- font-family: var(--font-hand);
- font-weight: 700;
- font-size: 0.98rem;
- padding: 6px 14px;
- cursor: pointer;
- text-decoration: none;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- gap: 6px;
- transition: all 0.18s ease;
- box-shadow: 2px 2px 0px rgba(0, 0, 0, 0.15);
- white-space: nowrap;
- line-height: 1.2;
-}
-
-.cb-nav-btn:hover {
- background: #ffffff;
- border-color: #ffffff;
- color: var(--tannengruen);
- transform: translateY(-1px);
- box-shadow: 2px 3px 0px rgba(0, 0, 0, 0.25);
-}
-
-.cb-nav-btn:active {
- transform: translateY(0);
- box-shadow: 1px 1px 0px rgba(0, 0, 0, 0.15);
-}
-
-.cb-btn-creme {
- background: var(--creme);
- color: var(--tannengruen);
- border-color: var(--creme);
-}
-
-.cb-btn-creme:hover {
- background: #fff;
- transform: translateY(-1px);
-}
-
-.cb-btn-outline {
- background: transparent;
- color: var(--creme);
- border-color: var(--creme);
-}
-
-.cb-btn-outline:hover {
- background: rgba(250, 246, 240, 0.18);
- border-color: #ffffff;
- color: #ffffff;
-}
-
-.cb-btn-green {
- background: #143729;
- color: var(--creme) !important;
- border-color: var(--creme);
- box-shadow: none;
-}
-
-.cb-btn-green:hover {
- background: #0f2c1f;
- color: #ffffff !important;
- border-color: #ffffff;
- transform: translateY(-1px);
-}
-
-/* Filter Bar: Schlank & horizontal scrollbar (links-rechts) */
-.cb-filter-bar {
- position: sticky;
- top: 48px;
- z-index: 990;
- background: var(--creme);
- border-bottom: 2px solid rgba(34, 34, 34, 0.15);
- padding: 6px 12px;
- display: flex;
- align-items: center;
- overflow-x: auto;
- white-space: nowrap;
- scrollbar-width: thin;
- -webkit-overflow-scrolling: touch;
- width: 100%;
- box-sizing: border-box;
- box-shadow: 0 2px 6px rgba(0,0,0,0.06);
-}
-
-.cb-filter-scroll-container {
- display: flex;
- align-items: center;
- gap: 8px;
- flex-wrap: nowrap;
- width: max-content;
- padding: 2px 4px;
-}
-
-.cb-chip {
- flex-shrink: 0;
- white-space: nowrap;
- font-family: var(--font-hand);
- font-size: 0.9rem;
- padding: 5px 13px;
- border-radius: 20px;
- background: var(--grau-bg);
- border: 1.5px solid rgba(34, 34, 34, 0.2);
- color: var(--schwarz);
- cursor: pointer;
- text-decoration: none;
- transition: all 0.2s;
- display: inline-flex;
- align-items: center;
-}
-
-.cb-chip.active, .cb-chip:hover {
- background: var(--tannengruen);
- color: var(--creme);
- border-color: var(--tannengruen);
-}
-
-/* Page Container (Virtual Book / Scroll view) */
-.book-wrapper {
- max-width: 960px;
- width: 100%;
- margin: 24px auto;
- padding: 0 16px;
- display: flex;
- flex-direction: column;
- gap: 36px;
- box-sizing: border-box;
-}
-
-/* --- A4 Page Container --- */
-.page {
- background-color: var(--creme);
- box-shadow: 0 6px 20px rgba(0,0,0,0.18);
- position: relative;
- overflow: hidden;
- box-sizing: border-box;
- border-radius: 6px;
- min-height: 580px;
-}
-
-/* (Druckstile zentral in cookbook.html
+
+
+
+
+
+ 📖
+
Mein Kochbuch
+
+
+
+
+
+
+ {% if reason == 'revoked' %}🔒{% else %}⏳{% endif %}
+
+
+
+ {% if reason == 'revoked' %}
+ Freigabe wurde deaktiviert
+ {% elif reason == 'expired' %}
+ Rezept-Link ist abgelaufen
+ {% else %}
+ Link nicht gefunden
+ {% endif %}
+
+
+
+ {% if reason == 'revoked' %}
+ Dieser Share-Link wurde vom Ersteller des Rezepts vorzeitig zurückgezogen.
+ {% elif reason == 'expired' %}
+ Die zeitliche Gültigkeit für diesen geteilten Rezept-Link ist leider abgelaufen. Bitte frage die Person, die das Rezept geteilt hat, nach einem neuen Link.
+ {% else %}
+ Der aufgerufene Share-Link ist ungültig oder existiert nicht mehr.
+ {% endif %}
+
+
+ {% if share and share.expires_at_formatted and reason == 'expired' %}
+
+ Ablaufzeitpunkt: {{ share.expires_at_formatted }} Uhr
+
+ {% endif %}
+
+
+
+
+
+
+