Add recipe share links with configurable validity, public guest access, and revocation
This commit is contained in:
+170
-2
@@ -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)
|
||||
|
||||
+109
-2
@@ -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
|
||||
# ==========================================
|
||||
|
||||
@@ -2690,3 +2690,302 @@ body.cook-mode-on .cookmode-badge {
|
||||
|
||||
|
||||
|
||||
|
||||
/* ==========================================================================
|
||||
REZEPT-FREIGABE & SHARE LINKS (v4.7.1)
|
||||
========================================================================== */
|
||||
|
||||
.recipe-shared-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: linear-gradient(135deg, rgba(30, 77, 59, 0.08) 0%, rgba(212, 160, 23, 0.1) 100%);
|
||||
border: 2px dashed var(--tannengruen);
|
||||
border-radius: 12px;
|
||||
padding: 12px 18px;
|
||||
margin-bottom: 20px;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.recipe-shared-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.recipe-shared-icon {
|
||||
font-size: 1.8rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.recipe-shared-main-text {
|
||||
font-family: var(--font-hand);
|
||||
font-size: 1.35rem;
|
||||
font-weight: 700;
|
||||
color: var(--tannengruen);
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.recipe-shared-sub-text {
|
||||
font-size: 0.88rem;
|
||||
color: #4a5d52;
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.recipe-shared-badge {
|
||||
background: var(--tannengruen);
|
||||
color: var(--creme);
|
||||
padding: 4px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.share-create-box {
|
||||
background: rgba(30, 77, 59, 0.04);
|
||||
border: 1.5px solid rgba(30, 77, 59, 0.2);
|
||||
border-radius: 10px;
|
||||
padding: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.share-label {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
color: var(--tannengruen);
|
||||
margin-bottom: 8px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.share-expiry-pills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.expiry-pill {
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.expiry-pill input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.expiry-pill span {
|
||||
display: inline-block;
|
||||
padding: 7px 13px;
|
||||
background: #fff;
|
||||
border: 1.5px solid #ccc;
|
||||
border-radius: 20px;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 500;
|
||||
color: var(--schwarz);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.expiry-pill input:checked + span {
|
||||
background: var(--tannengruen);
|
||||
border-color: var(--tannengruen);
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 6px rgba(30, 77, 59, 0.3);
|
||||
}
|
||||
|
||||
.expiry-pill:hover span {
|
||||
border-color: var(--tannengruen);
|
||||
}
|
||||
|
||||
.share-input-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.share-input {
|
||||
flex: 1 1 200px;
|
||||
padding: 9px 12px;
|
||||
border: 1.5px solid #ccc;
|
||||
border-radius: 8px;
|
||||
font-size: 0.92rem;
|
||||
background: #fff;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.share-input:focus {
|
||||
border-color: var(--tannengruen);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.share-result-box {
|
||||
background: #eef7f2;
|
||||
border: 2px solid var(--tannengruen);
|
||||
border-radius: 10px;
|
||||
padding: 16px;
|
||||
margin-bottom: 20px;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
.share-result-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--tannengruen);
|
||||
}
|
||||
|
||||
.share-result-input-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.share-url-field {
|
||||
flex: 1;
|
||||
padding: 9px 12px;
|
||||
border: 1.5px solid var(--tannengruen);
|
||||
border-radius: 6px;
|
||||
font-family: monospace;
|
||||
font-size: 0.95rem;
|
||||
background: #fff;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.share-result-hint {
|
||||
font-size: 0.82rem;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.share-existing-section {
|
||||
margin-top: 20px;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.share-existing-title {
|
||||
font-family: var(--font-hand);
|
||||
font-size: 1.3rem;
|
||||
color: var(--tannengruen);
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
|
||||
.share-links-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.share-link-card {
|
||||
background: #fff;
|
||||
border: 1.5px solid #e0ded9;
|
||||
border-radius: 8px;
|
||||
padding: 12px 14px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.share-link-card.is-expired {
|
||||
background: #fdf6f6;
|
||||
border-color: #f0d0d0;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.share-card-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.share-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.share-link-label {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.share-status-pill {
|
||||
font-size: 0.78rem;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.share-badge-active {
|
||||
background: #e1f5ec;
|
||||
color: #127c52;
|
||||
}
|
||||
|
||||
.share-badge-expired {
|
||||
background: #fbeae9;
|
||||
color: #c92a2a;
|
||||
}
|
||||
|
||||
.share-card-meta {
|
||||
font-size: 0.78rem;
|
||||
color: #777;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.share-card-url-preview {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.share-mini-url {
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
font-size: 0.76rem;
|
||||
color: #666;
|
||||
background: #f6f5f1;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
padding: 2px 6px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.share-card-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cb-btn-sm {
|
||||
padding: 6px 10px;
|
||||
font-size: 0.82rem;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.cb-btn-danger {
|
||||
background: #fff;
|
||||
border: 1.5px solid #dc3545;
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
.cb-btn-danger:hover {
|
||||
background: #dc3545;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.share-empty-state {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
color: #888;
|
||||
font-size: 0.9rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@@ -11,9 +11,9 @@
|
||||
|
||||
<!-- Navigation Bar -->
|
||||
<header class="cb-nav">
|
||||
<a href="/cookbook" class="cb-nav-brand">
|
||||
<a href="{% if is_shared %}#{% else %}/cookbook{% endif %}" class="cb-nav-brand">
|
||||
<span class="cb-brand-icon">📖</span>
|
||||
<h1 class="cb-nav-title">Kochbuch</h1>
|
||||
<h1 class="cb-nav-title">{% if is_shared %}Mein Kochbuch{% else %}Kochbuch{% endif %}</h1>
|
||||
</a>
|
||||
|
||||
<!-- Desktop Navigation -->
|
||||
@@ -25,18 +25,25 @@
|
||||
<span class="cb-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<a href="/cookbook" class="cb-btn cb-btn-outline">
|
||||
<span>📖</span> <span>Alle Rezepte</span>
|
||||
</a>
|
||||
<a href="/cookbook/edit/{{ recipe.id }}" class="cb-btn cb-btn-green">
|
||||
<span>✏️</span> <span>Bearbeiten</span>
|
||||
</a>
|
||||
{% if not is_shared %}
|
||||
<a href="/cookbook" class="cb-btn cb-btn-outline">
|
||||
<span>📖</span> <span>Alle Rezepte</span>
|
||||
</a>
|
||||
<button type="button" onclick="openShareModal()" class="cb-btn cb-btn-outline" title="Rezept per Link teilen">
|
||||
<span>🔗</span> <span>Teilen</span>
|
||||
</button>
|
||||
<a href="/cookbook/edit/{{ recipe.id }}" class="cb-btn cb-btn-green">
|
||||
<span>✏️</span> <span>Bearbeiten</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
<button onclick="window.print()" class="cb-btn cb-btn-outline" title="Rezeptseite drucken">
|
||||
<span>🖨️</span> <span>Drucken</span>
|
||||
</button>
|
||||
<a href="/logout" class="cb-nav-btn cb-nav-btn-logout" title="Abmelden">
|
||||
<span>🚪</span> <span>Abmelden</span>
|
||||
</a>
|
||||
{% if not is_shared %}
|
||||
<a href="/logout" class="cb-nav-btn cb-nav-btn-logout" title="Abmelden">
|
||||
<span>🚪</span> <span>Abmelden</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Mobile Kebab (3-Punkte) Menü -->
|
||||
@@ -53,19 +60,26 @@
|
||||
</label>
|
||||
</div>
|
||||
<hr class="cb-mobile-divider">
|
||||
<a href="/cookbook" class="cb-mobile-item">
|
||||
<span>📖</span> <span>Zum Kochbuch</span>
|
||||
</a>
|
||||
<a href="/cookbook/edit/{{ recipe.id }}" class="cb-mobile-item">
|
||||
<span>✏️</span> <span>Rezept bearbeiten</span>
|
||||
</a>
|
||||
{% if not is_shared %}
|
||||
<button type="button" onclick="openShareModal()" class="cb-mobile-item">
|
||||
<span>🔗</span> <span>Rezept teilen</span>
|
||||
</button>
|
||||
<a href="/cookbook" class="cb-mobile-item">
|
||||
<span>📖</span> <span>Zum Kochbuch</span>
|
||||
</a>
|
||||
<a href="/cookbook/edit/{{ recipe.id }}" class="cb-mobile-item">
|
||||
<span>✏️</span> <span>Rezept bearbeiten</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
<button type="button" onclick="window.print()" class="cb-mobile-item">
|
||||
<span>🖨️</span> <span>Drucken & PDF</span>
|
||||
</button>
|
||||
<hr class="cb-mobile-divider">
|
||||
<a href="/logout" class="cb-mobile-item cb-mobile-logout">
|
||||
<span>🚪</span> <span>Abmelden</span>
|
||||
</a>
|
||||
{% if not is_shared %}
|
||||
<hr class="cb-mobile-divider">
|
||||
<a href="/logout" class="cb-mobile-item cb-mobile-logout">
|
||||
<span>🚪</span> <span>Abmelden</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -75,6 +89,28 @@
|
||||
<!-- EINHEITLICHE REZEPTSEITE -->
|
||||
<article class="page recipe-page">
|
||||
|
||||
{% if is_shared %}
|
||||
<!-- GAST FREIGABE-BANNER -->
|
||||
<div class="recipe-shared-banner no-print">
|
||||
<div class="recipe-shared-left">
|
||||
<span class="recipe-shared-icon">📤</span>
|
||||
<div class="recipe-shared-text-group">
|
||||
<span class="recipe-shared-main-text">Geteiltes Rezept</span>
|
||||
<span class="recipe-shared-sub-text">
|
||||
{% if share.expires_at %}
|
||||
⏳ {{ share.relative_status }} (Gültig bis {{ share.expires_at_formatted }} Uhr)
|
||||
{% else %}
|
||||
✨ Dauerhafter Link (unbegrenzt gültig)
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="recipe-shared-right">
|
||||
<span class="recipe-shared-badge">Gastansicht</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- KOCHMODUS SCHIEBEREGLER BANNER (Mobil, Tablet & Desktop) -->
|
||||
<div class="recipe-cookmode-banner no-print">
|
||||
<div class="recipe-cookmode-left">
|
||||
@@ -246,6 +282,176 @@
|
||||
|
||||
</article>
|
||||
|
||||
{% if not is_shared %}
|
||||
<!-- SHARE MODAL -->
|
||||
<div id="share-modal" class="cb-modal-backdrop" onclick="closeShareModal(event)">
|
||||
<div class="cb-modal-card share-modal-card" onclick="event.stopPropagation()">
|
||||
<div class="cb-modal-header">
|
||||
<h3 class="cb-modal-title">
|
||||
<span>🔗</span> <span>Rezept teilen</span>
|
||||
</h3>
|
||||
<button type="button" class="cb-modal-close" onclick="closeShareModal()" aria-label="Schließen">×</button>
|
||||
</div>
|
||||
|
||||
<div class="cb-modal-body">
|
||||
<p style="margin-top: 0; color: #4a4a4a; line-height: 1.5; font-size: 0.96rem;">
|
||||
Erstelle einen Link, um <strong>{{ recipe.title }}</strong> mit Freunden oder Familie zu teilen.
|
||||
Empfänger können das Rezept direkt ansehen, kochen und ausdrucken – <em>ohne eigenes Login</em>!
|
||||
</p>
|
||||
|
||||
<!-- Neue Freigabe konfigurieren -->
|
||||
<div class="share-create-box">
|
||||
<label class="share-label">Gültigkeitsdauer des Links:</label>
|
||||
<div class="share-expiry-pills">
|
||||
<label class="expiry-pill">
|
||||
<input type="radio" name="share_expiry" value="24h">
|
||||
<span>24 Stunden</span>
|
||||
</label>
|
||||
<label class="expiry-pill">
|
||||
<input type="radio" name="share_expiry" value="3d">
|
||||
<span>3 Tage</span>
|
||||
</label>
|
||||
<label class="expiry-pill">
|
||||
<input type="radio" name="share_expiry" value="7d" checked>
|
||||
<span>7 Tage (Empfohlen)</span>
|
||||
</label>
|
||||
<label class="expiry-pill">
|
||||
<input type="radio" name="share_expiry" value="30d">
|
||||
<span>30 Tage</span>
|
||||
</label>
|
||||
<label class="expiry-pill">
|
||||
<input type="radio" name="share_expiry" value="unlimited">
|
||||
<span>Unbegrenzt</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="share-input-row" style="margin-top: 14px;">
|
||||
<input type="text" id="share-label-input" class="share-input" placeholder="Optionale Notiz (z. B. WhatsApp, Familie...)" maxlength="50">
|
||||
<button type="button" id="btn-create-share" class="cb-btn cb-btn-green" onclick="submitCreateShare()">
|
||||
<span>✨ Link erstellen</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Neuer erstellter Link (Highlight Box) -->
|
||||
<div id="share-result-box" class="share-result-box" style="display: none;">
|
||||
<div class="share-result-header">
|
||||
<span>✓ Neuer Share-Link bereit:</span>
|
||||
<span id="share-result-expiry-badge" class="share-status-pill share-badge-active"></span>
|
||||
</div>
|
||||
<div class="share-result-input-group">
|
||||
<input type="text" id="share-result-url" class="share-url-field" readonly onclick="this.select()">
|
||||
<button type="button" id="btn-copy-share" class="cb-btn cb-btn-green" onclick="copyCurrentShareUrl()">
|
||||
<span id="copy-btn-icon">📋</span> <span id="copy-btn-text">Kopieren</span>
|
||||
</button>
|
||||
<button type="button" id="btn-native-share" class="cb-btn cb-btn-outline" style="display: none;" onclick="triggerNativeShare()">
|
||||
<span>📤 Teilen</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="share-result-hint">Jeder mit diesem Link hat für den gewählten Zeitraum vollen Lese- und Kochzugriff.</div>
|
||||
</div>
|
||||
|
||||
<!-- Liste existierender Freigaben -->
|
||||
<div class="share-existing-section">
|
||||
<h4 class="share-existing-title">Aktive Freigaben für dieses Rezept</h4>
|
||||
<div id="share-links-list" class="share-links-list">
|
||||
<div class="share-links-loading">Lade aktive Links...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cb-modal-footer" style="padding: 12px 18px; border-top: 1.5px solid #e0ded9; display: flex; justify-content: flex-end; background: #faf6f0;">
|
||||
<button type="button" class="cb-btn cb-btn-outline" onclick="closeShareModal()">Schließen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if not is_shared %}
|
||||
<!-- SHARE MODAL -->
|
||||
<div id="share-modal" class="cb-modal-backdrop" onclick="closeShareModal(event)">
|
||||
<div class="cb-modal-card share-modal-card" onclick="event.stopPropagation()">
|
||||
<div class="cb-modal-header">
|
||||
<h3 class="cb-modal-title">
|
||||
<span>🔗</span> <span>Rezept teilen</span>
|
||||
</h3>
|
||||
<button type="button" class="cb-modal-close" onclick="closeShareModal()" aria-label="Schließen">×</button>
|
||||
</div>
|
||||
|
||||
<div class="cb-modal-body">
|
||||
<p style="margin-top: 0; color: #4a4a4a; line-height: 1.5; font-size: 0.96rem;">
|
||||
Erstelle einen Link, um <strong>{{ recipe.title }}</strong> mit Freunden oder Familie zu teilen.
|
||||
Empfänger können das Rezept direkt ansehen, kochen und ausdrucken – <em>ohne eigenes Login</em>!
|
||||
</p>
|
||||
|
||||
<!-- Neue Freigabe konfigurieren -->
|
||||
<div class="share-create-box">
|
||||
<label class="share-label">Gültigkeitsdauer des Links:</label>
|
||||
<div class="share-expiry-pills">
|
||||
<label class="expiry-pill">
|
||||
<input type="radio" name="share_expiry" value="24h">
|
||||
<span>24 Stunden</span>
|
||||
</label>
|
||||
<label class="expiry-pill">
|
||||
<input type="radio" name="share_expiry" value="3d">
|
||||
<span>3 Tage</span>
|
||||
</label>
|
||||
<label class="expiry-pill">
|
||||
<input type="radio" name="share_expiry" value="7d" checked>
|
||||
<span>7 Tage (Empfohlen)</span>
|
||||
</label>
|
||||
<label class="expiry-pill">
|
||||
<input type="radio" name="share_expiry" value="30d">
|
||||
<span>30 Tage</span>
|
||||
</label>
|
||||
<label class="expiry-pill">
|
||||
<input type="radio" name="share_expiry" value="unlimited">
|
||||
<span>Unbegrenzt</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="share-input-row" style="margin-top: 14px;">
|
||||
<input type="text" id="share-label-input" class="share-input" placeholder="Optionale Notiz (z. B. WhatsApp, Familie...)" maxlength="50">
|
||||
<button type="button" id="btn-create-share" class="cb-btn cb-btn-green" onclick="submitCreateShare()">
|
||||
<span>✨ Link erstellen</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Neuer erstellter Link (Highlight Box) -->
|
||||
<div id="share-result-box" class="share-result-box" style="display: none;">
|
||||
<div class="share-result-header">
|
||||
<span>✓ Neuer Share-Link bereit:</span>
|
||||
<span id="share-result-expiry-badge" class="share-status-pill share-badge-active"></span>
|
||||
</div>
|
||||
<div class="share-result-input-group">
|
||||
<input type="text" id="share-result-url" class="share-url-field" readonly onclick="this.select()">
|
||||
<button type="button" id="btn-copy-share" class="cb-btn cb-btn-green" onclick="copyCurrentShareUrl()">
|
||||
<span id="copy-btn-icon">📋</span> <span id="copy-btn-text">Kopieren</span>
|
||||
</button>
|
||||
<button type="button" id="btn-native-share" class="cb-btn cb-btn-outline" style="display: none;" onclick="triggerNativeShare()">
|
||||
<span>📤 Teilen</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="share-result-hint">Jeder mit diesem Link hat für den gewählten Zeitraum vollen Lese- und Kochzugriff.</div>
|
||||
</div>
|
||||
|
||||
<!-- Liste existierender Freigaben -->
|
||||
<div class="share-existing-section">
|
||||
<h4 class="share-existing-title">Aktive Freigaben für dieses Rezept</h4>
|
||||
<div id="share-links-list" class="share-links-list">
|
||||
<div class="share-links-loading">Lade aktive Links...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cb-modal-footer" style="padding: 12px 18px; border-top: 1.5px solid #e0ded9; display: flex; justify-content: flex-end; background: #faf6f0;">
|
||||
<button type="button" class="cb-btn cb-btn-outline" onclick="closeShareModal()">Schließen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</main>
|
||||
|
||||
<script>
|
||||
@@ -394,6 +600,392 @@
|
||||
toast.style.display = 'none';
|
||||
}, 2500);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// SHARE MODAL & FREIGABELINKS
|
||||
// ==========================================
|
||||
let currentShareUrl = '';
|
||||
|
||||
function openShareModal() {
|
||||
const modal = document.getElementById('share-modal');
|
||||
if (modal) {
|
||||
modal.classList.add('active');
|
||||
document.body.classList.add('modal-open');
|
||||
loadActiveShareLinks();
|
||||
}
|
||||
const menu = document.getElementById('cb-mobile-menu');
|
||||
if (menu) menu.classList.remove('is-open');
|
||||
}
|
||||
|
||||
function closeShareModal(e) {
|
||||
if (e && e.target && e.target.classList && !e.target.classList.contains('cb-modal-backdrop') && !e.target.classList.contains('cb-modal-close') && e.target.tagName !== 'BUTTON') {
|
||||
return;
|
||||
}
|
||||
const modal = document.getElementById('share-modal');
|
||||
if (modal) {
|
||||
modal.classList.remove('active');
|
||||
document.body.classList.remove('modal-open');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadActiveShareLinks() {
|
||||
const listEl = document.getElementById('share-links-list');
|
||||
if (!listEl) return;
|
||||
try {
|
||||
const resp = await fetch('/api/recipes/{{ recipe.id }}/shares');
|
||||
if (!resp.ok) throw new Error('Fehler beim Laden');
|
||||
const data = await resp.json();
|
||||
renderShareLinks(data.shares || []);
|
||||
} catch (err) {
|
||||
listEl.innerHTML = '<div class="share-empty-state">Konnte aktive Links nicht laden.</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderShareLinks(shares) {
|
||||
const listEl = document.getElementById('share-links-list');
|
||||
if (!listEl) return;
|
||||
if (!shares || shares.length === 0) {
|
||||
listEl.innerHTML = '<div class="share-empty-state">Noch keine Share-Links für dieses Rezept erstellt.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
listEl.innerHTML = shares.map(s => {
|
||||
const isExp = s.is_expired;
|
||||
const statusClass = isExp ? 'share-badge-expired' : 'share-badge-active';
|
||||
const labelText = s.label ? `<span class="share-link-label">🏷️ ${escapeHtml(s.label)}</span>` : '';
|
||||
const viewsText = `<span class="share-link-views">👁️ ${s.views_count || 0} ${s.views_count === 1 ? 'Aufruf' : 'Aufrufe'}</span>`;
|
||||
const canonicalUrl = window.location.origin + '/shared/recipe/' + s.token;
|
||||
|
||||
return `
|
||||
<div class="share-link-card ${isExp ? 'is-expired' : ''}">
|
||||
<div class="share-card-main">
|
||||
<div class="share-card-header">
|
||||
${labelText}
|
||||
<span class="share-status-pill ${statusClass}">${s.relative_status}</span>
|
||||
</div>
|
||||
<div class="share-card-meta">
|
||||
<span>Erstellt: ${s.created_at || ''}</span>
|
||||
${viewsText}
|
||||
</div>
|
||||
<div class="share-card-url-preview">
|
||||
<input type="text" class="share-mini-url" value="${canonicalUrl}" readonly onclick="this.select()">
|
||||
</div>
|
||||
</div>
|
||||
<div class="share-card-actions">
|
||||
${!isExp ? `<button type="button" class="cb-btn cb-btn-sm cb-btn-outline" onclick="copyTextToClipboard('${canonicalUrl}', this)" title="In Zwischenablage kopieren">📋 Kopieren</button>` : ''}
|
||||
<button type="button" class="cb-btn cb-btn-sm cb-btn-danger" onclick="deleteShareLink(${s.id})" title="Freigabe widerrufen">🗑️ Löschen</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function submitCreateShare() {
|
||||
const btn = document.getElementById('btn-create-share');
|
||||
const expiryRadio = document.querySelector('input[name="share_expiry"]:checked');
|
||||
const labelInput = document.getElementById('share-label-input');
|
||||
const expiryVal = expiryRadio ? expiryRadio.value : '7d';
|
||||
const labelVal = labelInput ? labelInput.value.trim() : '';
|
||||
|
||||
if (btn) btn.disabled = true;
|
||||
try {
|
||||
const resp = await fetch('/api/recipes/{{ recipe.id }}/shares', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ expires_in: expiryVal, label: labelVal })
|
||||
});
|
||||
if (!resp.ok) throw new Error('Erstellen fehlgeschlagen');
|
||||
const data = await resp.json();
|
||||
const share = data.share;
|
||||
|
||||
currentShareUrl = window.location.origin + '/shared/recipe/' + share.token;
|
||||
|
||||
const resBox = document.getElementById('share-result-box');
|
||||
const resUrl = document.getElementById('share-result-url');
|
||||
const badge = document.getElementById('share-result-expiry-badge');
|
||||
if (resBox && resUrl) {
|
||||
resUrl.value = currentShareUrl;
|
||||
if (badge) badge.textContent = share.relative_status;
|
||||
resBox.style.display = 'block';
|
||||
resUrl.select();
|
||||
}
|
||||
|
||||
const nativeBtn = document.getElementById('btn-native-share');
|
||||
if (nativeBtn && navigator.share) {
|
||||
nativeBtn.style.display = 'inline-flex';
|
||||
}
|
||||
|
||||
if (labelInput) labelInput.value = '';
|
||||
showToast('✓ Share-Link erfolgreich erstellt!');
|
||||
loadActiveShareLinks();
|
||||
} catch (err) {
|
||||
showToast('Fehler beim Erstellen des Links');
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyCurrentShareUrl() {
|
||||
const input = document.getElementById('share-result-url');
|
||||
if (input && input.value) {
|
||||
await copyTextToClipboard(input.value);
|
||||
const copyBtnText = document.getElementById('copy-btn-text');
|
||||
if (copyBtnText) {
|
||||
copyBtnText.textContent = 'Kopiert! ✓';
|
||||
setTimeout(() => { copyBtnText.textContent = 'Kopieren'; }, 2000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function copyTextToClipboard(text, btnEl) {
|
||||
try {
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} else {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
showToast('✓ In Zwischenablage kopiert!');
|
||||
if (btnEl) {
|
||||
const orig = btnEl.innerHTML;
|
||||
btnEl.innerHTML = '✓ Kopiert!';
|
||||
setTimeout(() => { btnEl.innerHTML = orig; }, 2000);
|
||||
}
|
||||
} catch (e) {
|
||||
showToast('Konnte Link nicht automatisch kopieren');
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerNativeShare() {
|
||||
if (navigator.share && currentShareUrl) {
|
||||
try {
|
||||
await navigator.share({
|
||||
title: 'Rezept: {{ recipe.title }}',
|
||||
text: 'Hier ist das Rezept für {{ recipe.title }}:',
|
||||
url: currentShareUrl
|
||||
});
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteShareLink(shareId) {
|
||||
if (!confirm('Diesen Share-Link wirklich löschen? Empfänger können das Rezept danach nicht mehr über diesen Link aufrufen.')) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const resp = await fetch(`/api/recipes/{{ recipe.id }}/shares/${shareId}`, { method: 'DELETE' });
|
||||
if (!resp.ok) throw new Error('Fehler beim Löschen');
|
||||
showToast('Share-Link gelöscht');
|
||||
loadActiveShareLinks();
|
||||
const resBox = document.getElementById('share-result-box');
|
||||
if (resBox) resBox.style.display = 'none';
|
||||
} catch (err) {
|
||||
showToast('Konnte Share-Link nicht löschen');
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
|
||||
// ==========================================
|
||||
// SHARE MODAL & FREIGABELINKS
|
||||
// ==========================================
|
||||
let currentShareUrl = '';
|
||||
|
||||
function openShareModal() {
|
||||
const modal = document.getElementById('share-modal');
|
||||
if (modal) {
|
||||
modal.classList.add('active');
|
||||
document.body.classList.add('modal-open');
|
||||
loadActiveShareLinks();
|
||||
}
|
||||
const menu = document.getElementById('cb-mobile-menu');
|
||||
if (menu) menu.classList.remove('is-open');
|
||||
}
|
||||
|
||||
function closeShareModal(e) {
|
||||
if (e && e.target && e.target.classList && !e.target.classList.contains('cb-modal-backdrop') && !e.target.classList.contains('cb-modal-close') && e.target.tagName !== 'BUTTON') {
|
||||
return;
|
||||
}
|
||||
const modal = document.getElementById('share-modal');
|
||||
if (modal) {
|
||||
modal.classList.remove('active');
|
||||
document.body.classList.remove('modal-open');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadActiveShareLinks() {
|
||||
const listEl = document.getElementById('share-links-list');
|
||||
if (!listEl) return;
|
||||
try {
|
||||
const resp = await fetch('/api/recipes/{{ recipe.id }}/shares');
|
||||
if (!resp.ok) throw new Error('Fehler beim Laden');
|
||||
const data = await resp.json();
|
||||
renderShareLinks(data.shares || []);
|
||||
} catch (err) {
|
||||
listEl.innerHTML = '<div class="share-empty-state">Konnte aktive Links nicht laden.</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderShareLinks(shares) {
|
||||
const listEl = document.getElementById('share-links-list');
|
||||
if (!listEl) return;
|
||||
if (!shares || shares.length === 0) {
|
||||
listEl.innerHTML = '<div class="share-empty-state">Noch keine Share-Links für dieses Rezept erstellt.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
listEl.innerHTML = shares.map(s => {
|
||||
const isExp = s.is_expired;
|
||||
const statusClass = isExp ? 'share-badge-expired' : 'share-badge-active';
|
||||
const labelText = s.label ? `<span class="share-link-label">🏷️ ${escapeHtml(s.label)}</span>` : '';
|
||||
const viewsText = `<span class="share-link-views">👁️ ${s.views_count || 0} ${s.views_count === 1 ? 'Aufruf' : 'Aufrufe'}</span>`;
|
||||
const canonicalUrl = window.location.origin + '/shared/recipe/' + s.token;
|
||||
|
||||
return `
|
||||
<div class="share-link-card ${isExp ? 'is-expired' : ''}">
|
||||
<div class="share-card-main">
|
||||
<div class="share-card-header">
|
||||
${labelText}
|
||||
<span class="share-status-pill ${statusClass}">${s.relative_status}</span>
|
||||
</div>
|
||||
<div class="share-card-meta">
|
||||
<span>Erstellt: ${s.created_at || ''}</span>
|
||||
${viewsText}
|
||||
</div>
|
||||
<div class="share-card-url-preview">
|
||||
<input type="text" class="share-mini-url" value="${canonicalUrl}" readonly onclick="this.select()">
|
||||
</div>
|
||||
</div>
|
||||
<div class="share-card-actions">
|
||||
${!isExp ? `<button type="button" class="cb-btn cb-btn-sm cb-btn-outline" onclick="copyTextToClipboard('${canonicalUrl}', this)" title="In Zwischenablage kopieren">📋 Kopieren</button>` : ''}
|
||||
<button type="button" class="cb-btn cb-btn-sm cb-btn-danger" onclick="deleteShareLink(${s.id})" title="Freigabe widerrufen">🗑️ Löschen</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function submitCreateShare() {
|
||||
const btn = document.getElementById('btn-create-share');
|
||||
const expiryRadio = document.querySelector('input[name="share_expiry"]:checked');
|
||||
const labelInput = document.getElementById('share-label-input');
|
||||
const expiryVal = expiryRadio ? expiryRadio.value : '7d';
|
||||
const labelVal = labelInput ? labelInput.value.trim() : '';
|
||||
|
||||
if (btn) btn.disabled = true;
|
||||
try {
|
||||
const resp = await fetch('/api/recipes/{{ recipe.id }}/shares', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ expires_in: expiryVal, label: labelVal })
|
||||
});
|
||||
if (!resp.ok) throw new Error('Erstellen fehlgeschlagen');
|
||||
const data = await resp.json();
|
||||
const share = data.share;
|
||||
|
||||
currentShareUrl = window.location.origin + '/shared/recipe/' + share.token;
|
||||
|
||||
const resBox = document.getElementById('share-result-box');
|
||||
const resUrl = document.getElementById('share-result-url');
|
||||
const badge = document.getElementById('share-result-expiry-badge');
|
||||
if (resBox && resUrl) {
|
||||
resUrl.value = currentShareUrl;
|
||||
if (badge) badge.textContent = share.relative_status;
|
||||
resBox.style.display = 'block';
|
||||
resUrl.select();
|
||||
}
|
||||
|
||||
const nativeBtn = document.getElementById('btn-native-share');
|
||||
if (nativeBtn && navigator.share) {
|
||||
nativeBtn.style.display = 'inline-flex';
|
||||
}
|
||||
|
||||
if (labelInput) labelInput.value = '';
|
||||
showToast('✓ Share-Link erfolgreich erstellt!');
|
||||
loadActiveShareLinks();
|
||||
} catch (err) {
|
||||
showToast('Fehler beim Erstellen des Links');
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyCurrentShareUrl() {
|
||||
const input = document.getElementById('share-result-url');
|
||||
if (input && input.value) {
|
||||
await copyTextToClipboard(input.value);
|
||||
const copyBtnText = document.getElementById('copy-btn-text');
|
||||
if (copyBtnText) {
|
||||
copyBtnText.textContent = 'Kopiert! ✓';
|
||||
setTimeout(() => { copyBtnText.textContent = 'Kopieren'; }, 2000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function copyTextToClipboard(text, btnEl) {
|
||||
try {
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} else {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
showToast('✓ In Zwischenablage kopiert!');
|
||||
if (btnEl) {
|
||||
const orig = btnEl.innerHTML;
|
||||
btnEl.innerHTML = '✓ Kopiert!';
|
||||
setTimeout(() => { btnEl.innerHTML = orig; }, 2000);
|
||||
}
|
||||
} catch (e) {
|
||||
showToast('Konnte Link nicht automatisch kopieren');
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerNativeShare() {
|
||||
if (navigator.share && currentShareUrl) {
|
||||
try {
|
||||
await navigator.share({
|
||||
title: 'Rezept: {{ recipe.title }}',
|
||||
text: 'Hier ist das Rezept für {{ recipe.title }}:',
|
||||
url: currentShareUrl
|
||||
});
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteShareLink(shareId) {
|
||||
if (!confirm('Diesen Share-Link wirklich löschen? Empfänger können das Rezept danach nicht mehr über diesen Link aufrufen.')) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const resp = await fetch(`/api/recipes/{{ recipe.id }}/shares/${shareId}`, { method: 'DELETE' });
|
||||
if (!resp.ok) throw new Error('Fehler beim Löschen');
|
||||
showToast('Share-Link gelöscht');
|
||||
loadActiveShareLinks();
|
||||
const resBox = document.getElementById('share-result-box');
|
||||
if (resBox) resBox.style.display = 'none';
|
||||
} catch (err) {
|
||||
showToast('Konnte Share-Link nicht löschen');
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<title>Link nicht mehr gültig – Mein Kochbuch</title>
|
||||
<link rel="stylesheet" href="/static/cookbook.css?v=4.7.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>
|
||||
.expired-page-wrapper {
|
||||
min-height: 80vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px 16px;
|
||||
}
|
||||
.expired-card {
|
||||
background: var(--creme, #faf6f0);
|
||||
border: 2.5px solid var(--schwarz, #2a2a2a);
|
||||
border-radius: 16px;
|
||||
box-shadow: 6px 6px 0px rgba(30, 77, 59, 0.4);
|
||||
max-width: 520px;
|
||||
width: 100%;
|
||||
padding: 36px 28px;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
}
|
||||
.expired-icon {
|
||||
font-size: 3.5rem;
|
||||
margin-bottom: 16px;
|
||||
display: inline-block;
|
||||
animation: float-gentle 3s ease-in-out infinite;
|
||||
}
|
||||
.expired-title {
|
||||
font-family: var(--font-hand, 'Caveat', cursive);
|
||||
font-size: 2.2rem;
|
||||
color: var(--tannengruen, #1e4d3b);
|
||||
margin: 0 0 14px 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.expired-desc {
|
||||
font-size: 1.05rem;
|
||||
color: #4a4a4a;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.expired-meta-box {
|
||||
background: rgba(30, 77, 59, 0.07);
|
||||
border: 1.5px dashed var(--tannengruen, #1e4d3b);
|
||||
border-radius: 10px;
|
||||
padding: 12px 16px;
|
||||
font-size: 0.92rem;
|
||||
color: var(--tannengruen, #1e4d3b);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.expired-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
@keyframes float-gentle {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-6px); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="cookbook-body">
|
||||
|
||||
<header class="cb-nav">
|
||||
<div class="cb-nav-brand">
|
||||
<span class="cb-brand-icon">📖</span>
|
||||
<h1 class="cb-nav-title">Mein Kochbuch</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="expired-page-wrapper">
|
||||
<div class="expired-card">
|
||||
<div class="expired-icon">
|
||||
{% if reason == 'revoked' %}🔒{% else %}⏳{% endif %}
|
||||
</div>
|
||||
|
||||
<h2 class="expired-title">
|
||||
{% if reason == 'revoked' %}
|
||||
Freigabe wurde deaktiviert
|
||||
{% elif reason == 'expired' %}
|
||||
Rezept-Link ist abgelaufen
|
||||
{% else %}
|
||||
Link nicht gefunden
|
||||
{% endif %}
|
||||
</h2>
|
||||
|
||||
<p class="expired-desc">
|
||||
{% 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 %}
|
||||
</p>
|
||||
|
||||
{% if share and share.expires_at_formatted and reason == 'expired' %}
|
||||
<div class="expired-meta-box">
|
||||
Ablaufzeitpunkt: <strong>{{ share.expires_at_formatted }} Uhr</strong>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="expired-actions">
|
||||
<a href="/login" class="cb-btn cb-btn-green">
|
||||
<span>📖</span> <span>Zum Kochbuch / Anmelden</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,120 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Ensure project root is in sys.path
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from app.main import app, create_auth_token, COOKIE_NAME
|
||||
from app.database import (
|
||||
create_recipe_share,
|
||||
get_recipe_share_by_token,
|
||||
get_recipe_shares_for_recipe,
|
||||
delete_recipe_share,
|
||||
get_recipe
|
||||
)
|
||||
|
||||
def run_tests():
|
||||
client = TestClient(app)
|
||||
recipe = get_recipe(1)
|
||||
assert recipe is not None, "Recipe ID 1 must exist for testing"
|
||||
recipe_id = recipe["id"]
|
||||
|
||||
print("--- 1. Testing Database Share Creation & Expiry Logic ---")
|
||||
# Share for 24 hours
|
||||
s24 = create_recipe_share(recipe_id, expires_in_seconds=86400, label="WhatsApp Familie")
|
||||
assert s24["token"], "Must have generated token"
|
||||
assert s24["is_expired"] is False, "Should not be expired"
|
||||
assert "Noch 23" in s24["relative_status"] or "Noch 24" in s24["relative_status"] or "Noch 1 Tag" in s24["relative_status"]
|
||||
print(f"[OK] 24h share created: {s24['token']} -> {s24['relative_status']}")
|
||||
|
||||
# Unlimited share
|
||||
sunlim = create_recipe_share(recipe_id, expires_in_seconds=None, label="Dauerhaft")
|
||||
assert sunlim["expires_at"] is None, "Unlimited share should have None expires_at"
|
||||
assert sunlim["relative_status"] == "Unbegrenzt gültig"
|
||||
print(f"[OK] Unlimited share created: {sunlim['token']} -> {sunlim['relative_status']}")
|
||||
|
||||
print("\n--- 2. Testing Public Access to Shared Recipe (No Auth Cookie) ---")
|
||||
resp = client.get(f"/shared/recipe/{s24['token']}")
|
||||
assert resp.status_code == 200, f"Expected 200 OK for public share link, got {resp.status_code}"
|
||||
assert recipe["title"] in resp.text, "Shared page must contain recipe title"
|
||||
assert "recipe-shared-banner" in resp.text, "Must render shared guest banner"
|
||||
assert "Gastansicht" in resp.text, "Must have guest badge"
|
||||
assert "Kochmodus" in resp.text, "Guest must have access to Kochmodus"
|
||||
# Ensure private admin buttons are hidden in guest view
|
||||
assert "Alle Rezepte" not in resp.text or "cb-nav-actions" not in resp.text or "/logout" not in resp.text
|
||||
print("[OK] Public access without auth verified: Recipe renders in guest view with Kochmodus!")
|
||||
|
||||
# Check view count incremented
|
||||
fetched = get_recipe_share_by_token(s24["token"])
|
||||
assert fetched["views_count"] >= 1, f"Views count should be >= 1, got {fetched['views_count']}"
|
||||
print(f"[OK] View count incremented: {fetched['views_count']} views recorded")
|
||||
|
||||
print("\n--- 3. Testing Non-Existent & Expired Links ---")
|
||||
resp_invalid = client.get("/shared/recipe/does_not_exist_xyz123")
|
||||
assert resp_invalid.status_code == 404, f"Expected 404, got {resp_invalid.status_code}"
|
||||
assert "Link nicht mehr" in resp_invalid.text or "ungültig" in resp_invalid.text
|
||||
print("[OK] Invalid token handled with friendly 404 page")
|
||||
|
||||
# Expired token test (past expiry)
|
||||
from datetime import datetime, timedelta
|
||||
from app.database import execute_query
|
||||
expired_token = "test_expired_token_123"
|
||||
past_date = (datetime.now() - timedelta(days=2)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
execute_query("DELETE FROM recipe_shares WHERE token = ?", (expired_token,), fetch="none", commit=True)
|
||||
execute_query("INSERT INTO recipe_shares (recipe_id, token, expires_at, label) VALUES (?, ?, ?, ?)", (recipe_id, expired_token, past_date, "Old"), fetch="none", commit=True)
|
||||
|
||||
resp_exp = client.get(f"/shared/recipe/{expired_token}")
|
||||
assert resp_exp.status_code == 410, f"Expected 410 Gone for expired link, got {resp_exp.status_code}"
|
||||
assert "abgelaufen" in resp_exp.text.lower()
|
||||
print("[OK] Expired link correctly returns 410 Gone with expiration notice")
|
||||
|
||||
print("\n--- 4. Testing Authenticated Share Management APIs ---")
|
||||
# Anonymous request to API must fail
|
||||
resp_unauth = client.get(f"/api/recipes/{recipe_id}/shares")
|
||||
assert resp_unauth.status_code == 401, f"Expected 401 Unauthorized for API without cookie, got {resp_unauth.status_code}"
|
||||
print("[OK] Unauthenticated API request correctly rejected with 401")
|
||||
|
||||
# Authenticated request
|
||||
auth_token = create_auth_token("admin", days=1)
|
||||
client.cookies.set(COOKIE_NAME, auth_token)
|
||||
|
||||
# List shares
|
||||
resp_list = client.get(f"/api/recipes/{recipe_id}/shares")
|
||||
assert resp_list.status_code == 200, f"Expected 200, got {resp_list.status_code}"
|
||||
shares = resp_list.json()["shares"]
|
||||
assert any(s["token"] == s24["token"] for s in shares)
|
||||
print(f"[OK] Authenticated GET /api/recipes/{recipe_id}/shares returned {len(shares)} shares")
|
||||
|
||||
# Create new share via API
|
||||
resp_create = client.post(
|
||||
f"/api/recipes/{recipe_id}/shares",
|
||||
json={"expires_in": "3d", "label": "Kollege"}
|
||||
)
|
||||
assert resp_create.status_code == 200, f"Expected 200, got {resp_create.status_code}"
|
||||
created_share = resp_create.json()["share"]
|
||||
assert created_share["token"]
|
||||
assert "share_url" in created_share
|
||||
assert "Noch 2" in created_share["relative_status"] or "Noch 3" in created_share["relative_status"]
|
||||
print(f"[OK] POST /api/recipes/{recipe_id}/shares created: {created_share['share_url']}")
|
||||
|
||||
# Delete share via API
|
||||
resp_del = client.delete(f"/api/recipes/{recipe_id}/shares/{created_share['id']}")
|
||||
assert resp_del.status_code == 200, f"Expected 200, got {resp_del.status_code}"
|
||||
# Verify it is no longer accessible
|
||||
resp_after_del = client.get(f"/shared/recipe/{created_share['token']}")
|
||||
assert resp_after_del.status_code in (404, 410)
|
||||
print("[OK] DELETE /api/recipes/{recipe_id}/shares/{share_id} deleted and revoked access")
|
||||
|
||||
# Clean up test shares
|
||||
delete_recipe_share(s24["id"], recipe_id)
|
||||
delete_recipe_share(sunlim["id"], recipe_id)
|
||||
execute_query("DELETE FROM recipe_shares WHERE token = ?", (expired_token,), fetch="none", commit=True)
|
||||
print("[OK] Test data cleanup complete")
|
||||
|
||||
print("\n==========================================")
|
||||
print("ALL RECIPE SHARING TESTS PASSED SUCCESSFULLY!")
|
||||
print("==========================================")
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_tests()
|
||||
Reference in New Issue
Block a user