mirror of
https://github.com/Andre-Bzk/Rezept0r.git
synced 2026-11-15 15:43:04 +01:00
Prototyp
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
OPENAI_API_KEY=sk-...
|
||||
USDA_API_KEY=DEMO_KEY
|
||||
TANDOOR_BASE_URL=http://localhost:8080
|
||||
TANDOOR_API_TOKEN=
|
||||
APP_HOST=0.0.0.0
|
||||
APP_PORT=8000
|
||||
TMP_DIR=/app/tmp
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
.env
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
.eggs/
|
||||
tmp/
|
||||
*.mp3
|
||||
*.wav
|
||||
*.m4a
|
||||
*.webm
|
||||
.DS_Store
|
||||
*.log
|
||||
CLAUDE.md
|
||||
.vscode/
|
||||
.claude/
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
FROM python:3.12-slim-bookworm
|
||||
|
||||
# Install system dependencies (ffmpeg required by yt-dlp for audio extraction)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ffmpeg \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install yt-dlp (standalone binary, always up-to-date)
|
||||
RUN curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp \
|
||||
-o /usr/local/bin/yt-dlp && \
|
||||
chmod +x /usr/local/bin/yt-dlp
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN mkdir -p /app/tmp
|
||||
|
||||
ENV APP_HOST=0.0.0.0
|
||||
ENV APP_PORT=8000
|
||||
ENV TMP_DIR=/app/tmp
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
# Single worker to stay within Pi 3's memory budget
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
|
||||
@@ -0,0 +1,85 @@
|
||||
"""GET /api/extract/stream – SSE endpoint."""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from app.extractors.base import is_video_url
|
||||
from app.extractors.website import extract_website
|
||||
from app.extractors.video import extract_video
|
||||
from app.models import Recipe, StatusEvent, ErrorEvent, ExtractResponse
|
||||
from app.services.nutrition import enrich_nutrition
|
||||
from app.services.tandoor_service import recipe_to_tandoor
|
||||
from app.services.history_service import save_recipe as save_to_history
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _sse(event: str, data: str) -> str:
|
||||
return f"event: {event}\ndata: {data}\n\n"
|
||||
|
||||
|
||||
async def _stream(url: str) -> AsyncGenerator[str, None]:
|
||||
try:
|
||||
recipe: Recipe | None = None
|
||||
pipeline = extract_video(url) if is_video_url(url) else extract_website(url)
|
||||
|
||||
async for item in pipeline:
|
||||
if isinstance(item, StatusEvent):
|
||||
yield _sse("status", item.model_dump_json())
|
||||
elif isinstance(item, Recipe):
|
||||
recipe = item
|
||||
|
||||
if recipe is None:
|
||||
raise RuntimeError("Kein Rezept extrahiert.")
|
||||
|
||||
# Nutrition enrichment (may yield status events in future; here inline)
|
||||
yield _sse("status", StatusEvent(
|
||||
stage="nutrition", message="Berechne Nährwerte...", progress=90
|
||||
).model_dump_json())
|
||||
|
||||
recipe = await enrich_nutrition(recipe)
|
||||
|
||||
tandoor_json = recipe_to_tandoor(recipe)
|
||||
base = ExtractResponse(recipe=recipe, tandoor_json=tandoor_json)
|
||||
history_id = None
|
||||
try:
|
||||
history_id = save_to_history(base.model_dump())
|
||||
except Exception as db_err:
|
||||
log.warning("History save failed: %s", db_err)
|
||||
response = ExtractResponse(recipe=recipe, tandoor_json=tandoor_json, history_id=history_id)
|
||||
yield _sse("result", response.model_dump_json())
|
||||
|
||||
except Exception as e:
|
||||
log.exception("Extraction error for %s", url)
|
||||
yield _sse("error", ErrorEvent(message=str(e)).model_dump_json())
|
||||
|
||||
|
||||
@router.get("/extract/stream")
|
||||
async def extract_stream(url: str):
|
||||
if not url or not url.startswith(("http://", "https://")):
|
||||
async def bad():
|
||||
yield _sse("error", ErrorEvent(message="Ungültige URL.").model_dump_json())
|
||||
return StreamingResponse(
|
||||
bad(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Accel-Buffering": "no",
|
||||
"Connection": "keep-alive",
|
||||
},
|
||||
)
|
||||
|
||||
return StreamingResponse(
|
||||
_stream(url),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Accel-Buffering": "no",
|
||||
"Connection": "keep-alive",
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,22 @@
|
||||
"""GET /api/history, DELETE /api/history/{id}"""
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from app.services.history_service import get_all, delete_recipe
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/history")
|
||||
async def get_history():
|
||||
try:
|
||||
return get_all()
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/history/{history_id}")
|
||||
async def delete_history_entry(history_id: int):
|
||||
try:
|
||||
delete_recipe(history_id)
|
||||
return {"status": "ok"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -0,0 +1,21 @@
|
||||
"""POST /api/import – import recipe to Tandoor."""
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from app.models import ExtractResponse
|
||||
from app.services.tandoor_service import import_to_tandoor
|
||||
from app.services.history_service import mark_imported
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/import")
|
||||
async def import_recipe(data: ExtractResponse):
|
||||
try:
|
||||
result = await import_to_tandoor(data.recipe)
|
||||
tandoor_recipe_id = result.get("id")
|
||||
if data.history_id:
|
||||
mark_imported(data.history_id, tandoor_recipe_id)
|
||||
return {"status": "ok", "recipe_id": tandoor_recipe_id}
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Import fehlgeschlagen: {e}")
|
||||
@@ -0,0 +1,20 @@
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
from functools import lru_cache
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
|
||||
|
||||
openai_api_key: str = ""
|
||||
usda_api_key: str = "DEMO_KEY"
|
||||
tandoor_base_url: str = "http://localhost:8080"
|
||||
tandoor_api_token: str = ""
|
||||
app_host: str = "0.0.0.0"
|
||||
app_port: int = 8000
|
||||
tmp_dir: str = "/app/tmp"
|
||||
ffmpeg_location: str = "" # Optional: full path to ffmpeg binary dir (for Windows local dev)
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Platform detection: decides whether a URL is a video or a website."""
|
||||
import re
|
||||
|
||||
VIDEO_PATTERNS = [
|
||||
r"youtube\.com/watch",
|
||||
r"youtu\.be/",
|
||||
r"tiktok\.com/",
|
||||
r"instagram\.com/reel",
|
||||
r"instagram\.com/p/",
|
||||
r"vimeo\.com/",
|
||||
r"twitch\.tv/",
|
||||
r"twitter\.com/.+/status",
|
||||
r"x\.com/.+/status",
|
||||
r"facebook\.com/.*video",
|
||||
r"reddit\.com/r/.*/comments", # reddit video posts
|
||||
]
|
||||
|
||||
_VIDEO_RE = re.compile("|".join(VIDEO_PATTERNS), re.IGNORECASE)
|
||||
|
||||
|
||||
def is_video_url(url: str) -> bool:
|
||||
return bool(_VIDEO_RE.search(url))
|
||||
@@ -0,0 +1,206 @@
|
||||
"""Video recipe extraction: yt-dlp → caption check → Whisper → GPT-4."""
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models import Recipe, StatusEvent
|
||||
from app.services.openai_service import (
|
||||
check_caption_has_recipe,
|
||||
extract_recipe_from_text,
|
||||
transcribe_audio,
|
||||
)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
MAX_DURATION_WARN = 20 * 60 # 20 minutes in seconds
|
||||
MAX_FILESIZE = "50m"
|
||||
WHISPER_MAX_BYTES = 24 * 1024 * 1024 # 25MB limit, buffer to 24MB
|
||||
|
||||
|
||||
async def extract_video(url: str) -> AsyncGenerator[StatusEvent | Recipe, None]:
|
||||
yield StatusEvent(stage="detecting", message="Erkenne Plattform...", progress=5)
|
||||
|
||||
# Step 1: Get metadata without downloading
|
||||
yield StatusEvent(stage="metadata", message="Lade Video-Metadaten...", progress=10)
|
||||
metadata = await _get_metadata(url)
|
||||
|
||||
title = metadata.get("title", "")
|
||||
description = metadata.get("description", "") or ""
|
||||
tags = metadata.get("tags") or []
|
||||
duration = metadata.get("duration") or 0
|
||||
|
||||
# Step 2: Check if caption/description contains a complete recipe
|
||||
yield StatusEvent(stage="caption", message="Prüfe Video-Beschreibung...", progress=20)
|
||||
|
||||
caption_text = f"{title}\n\n{description}"
|
||||
has_recipe_in_caption = await check_caption_has_recipe(caption_text)
|
||||
|
||||
if has_recipe_in_caption:
|
||||
yield StatusEvent(stage="extracting", message="Extrahiere Rezept aus Beschreibung...", progress=50)
|
||||
enriched_text = f"Titel: {title}\n\nBeschreibung:\n{description}"
|
||||
recipe = await extract_recipe_from_text(enriched_text, url)
|
||||
if recipe.image_url is None:
|
||||
recipe.image_url = _get_thumbnail(metadata)
|
||||
if not recipe.tags and tags:
|
||||
recipe.tags = [str(t) for t in tags[:5]]
|
||||
yield StatusEvent(stage="done", message="Rezept aus Beschreibung extrahiert.", progress=95)
|
||||
yield recipe
|
||||
return
|
||||
|
||||
# Step 3: Audio download + transcription fallback
|
||||
if duration > MAX_DURATION_WARN:
|
||||
yield StatusEvent(
|
||||
stage="warning",
|
||||
message=f"Video ist {duration // 60} Min. lang – Transkription kann dauern.",
|
||||
progress=25,
|
||||
)
|
||||
|
||||
yield StatusEvent(stage="downloading", message="Lade Audio herunter...", progress=30)
|
||||
|
||||
settings = get_settings()
|
||||
audio_path = Path(settings.tmp_dir) / f"{uuid.uuid4()}.mp3"
|
||||
|
||||
try:
|
||||
await _download_audio(url, audio_path)
|
||||
|
||||
# Check file size for Whisper limit
|
||||
if audio_path.exists():
|
||||
size = audio_path.stat().st_size
|
||||
if size > WHISPER_MAX_BYTES:
|
||||
raise RuntimeError(
|
||||
f"Audio-Datei zu groß ({size // 1024 // 1024} MB). "
|
||||
"Whisper unterstützt max. 25MB. Bitte kürzeres Video verwenden."
|
||||
)
|
||||
else:
|
||||
raise RuntimeError("Audio-Download fehlgeschlagen.")
|
||||
|
||||
yield StatusEvent(stage="transcribing", message="Transkribiere Audio (Whisper)...", progress=55)
|
||||
transcript = await transcribe_audio(audio_path)
|
||||
|
||||
if not transcript.strip():
|
||||
raise RuntimeError("Transkription leer. Kein gesprochener Text erkannt.")
|
||||
|
||||
yield StatusEvent(stage="extracting", message="Extrahiere Rezept aus Transkription...", progress=75)
|
||||
|
||||
enriched_text = (
|
||||
f"Video-Titel: {title}\n"
|
||||
f"Beschreibung: {description[:500]}\n\n"
|
||||
f"Transkription:\n{transcript}"
|
||||
)
|
||||
recipe = await extract_recipe_from_text(enriched_text, url)
|
||||
if recipe.image_url is None:
|
||||
recipe.image_url = _get_thumbnail(metadata)
|
||||
if not recipe.tags and tags:
|
||||
recipe.tags = [str(t) for t in tags[:5]]
|
||||
|
||||
yield StatusEvent(stage="done", message="Rezept extrahiert.", progress=95)
|
||||
yield recipe
|
||||
|
||||
except FileNotFoundError:
|
||||
raise RuntimeError(
|
||||
"yt-dlp nicht gefunden. Bitte sicherstellen, dass yt-dlp installiert ist."
|
||||
)
|
||||
except Exception:
|
||||
raise
|
||||
finally:
|
||||
if audio_path.exists():
|
||||
try:
|
||||
audio_path.unlink()
|
||||
except Exception as e:
|
||||
log.warning("Could not delete tmp file %s: %s", audio_path, e)
|
||||
|
||||
|
||||
def _ffmpeg_args() -> list[str]:
|
||||
"""Return --ffmpeg-location args if configured, else empty list."""
|
||||
loc = get_settings().ffmpeg_location
|
||||
return ["--ffmpeg-location", loc] if loc else []
|
||||
|
||||
|
||||
async def _get_metadata(url: str) -> dict:
|
||||
"""Run yt-dlp --dump-json to get metadata without downloading."""
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"yt-dlp",
|
||||
"--dump-json",
|
||||
"--no-playlist",
|
||||
*_ffmpeg_args(),
|
||||
url,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=60)
|
||||
if proc.returncode != 0:
|
||||
err = stderr.decode(errors="replace")
|
||||
_check_tiktok_error(err, url)
|
||||
raise RuntimeError(f"yt-dlp Metadaten-Fehler: {err[:500]}")
|
||||
try:
|
||||
return json.loads(stdout.decode())
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
|
||||
|
||||
async def _download_audio(url: str, output_path: Path) -> None:
|
||||
"""Download audio only via yt-dlp subprocess (non-blocking)."""
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"yt-dlp",
|
||||
"--no-playlist",
|
||||
"--extract-audio",
|
||||
"--audio-format", "mp3",
|
||||
"--audio-quality", "5",
|
||||
"--max-filesize", MAX_FILESIZE,
|
||||
*_ffmpeg_args(),
|
||||
"--output", str(output_path).replace(".mp3", ".%(ext)s"),
|
||||
url,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=300)
|
||||
except asyncio.TimeoutError:
|
||||
proc.kill()
|
||||
raise RuntimeError("Audio-Download Timeout (5 Min.) überschritten.")
|
||||
|
||||
if proc.returncode != 0:
|
||||
err = stderr.decode(errors="replace")
|
||||
_check_tiktok_error(err, url)
|
||||
raise RuntimeError(f"Audio-Download fehlgeschlagen: {err[:500]}")
|
||||
|
||||
# yt-dlp may output with different extension before conversion
|
||||
# Try to find the actual file
|
||||
if not output_path.exists():
|
||||
parent = output_path.parent
|
||||
stem = output_path.stem
|
||||
for f in parent.glob(f"{stem}*"):
|
||||
if f.suffix in (".mp3", ".m4a", ".webm", ".opus"):
|
||||
f.rename(output_path)
|
||||
break
|
||||
|
||||
|
||||
def _get_thumbnail(metadata: dict) -> str | None:
|
||||
thumbnail = metadata.get("thumbnail")
|
||||
if thumbnail:
|
||||
return thumbnail
|
||||
thumbnails = metadata.get("thumbnails") or []
|
||||
if thumbnails:
|
||||
# prefer largest
|
||||
best = max(thumbnails, key=lambda t: (t.get("width", 0) or 0), default=None)
|
||||
if best:
|
||||
return best.get("url")
|
||||
return None
|
||||
|
||||
|
||||
def _check_tiktok_error(stderr: str, url: str) -> None:
|
||||
"""Raise a clear error for TikTok login/cookie issues."""
|
||||
if "tiktok" in url.lower():
|
||||
if any(phrase in stderr.lower() for phrase in ["login", "cookie", "captcha", "403"]):
|
||||
raise RuntimeError(
|
||||
"TikTok erfordert Cookies für dieses Video. "
|
||||
"Bitte exportiere deine Browser-Cookies und konfiguriere yt-dlp entsprechend."
|
||||
)
|
||||
@@ -0,0 +1,301 @@
|
||||
"""Website recipe extraction: recipe-scrapers → LD+JSON → GPT-4 fallback."""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import AsyncGenerator
|
||||
|
||||
import httpx
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from app.models import Ingredient, Recipe, StatusEvent
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
MAX_GPT_CHARS = 12_000
|
||||
|
||||
|
||||
async def extract_website(
|
||||
url: str,
|
||||
) -> AsyncGenerator[StatusEvent | Recipe, None]:
|
||||
"""Yields StatusEvents then a final Recipe."""
|
||||
|
||||
yield StatusEvent(stage="detecting", message="Erkenne Rezeptseite...", progress=10)
|
||||
yield StatusEvent(stage="fetching", message="Lade Seite...", progress=20)
|
||||
|
||||
# Fetch HTML once — reused by all tiers
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client:
|
||||
resp = await client.get(
|
||||
url,
|
||||
headers={"User-Agent": "Mozilla/5.0 (compatible; Rezept0r/1.0)"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
html = resp.text
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Seite konnte nicht geladen werden: {e}") from e
|
||||
|
||||
soup = BeautifulSoup(html, "lxml")
|
||||
|
||||
# Tier 1: recipe-scrapers (uses already-fetched HTML)
|
||||
try:
|
||||
from recipe_scrapers import scrape_html, NoSchemaFoundInWildMode
|
||||
scraper = scrape_html(html, org_url=url, wild_mode=True)
|
||||
recipe = _scraper_to_recipe(scraper, url)
|
||||
if recipe.ingredients and recipe.steps:
|
||||
yield StatusEvent(stage="parsing", message="Rezept geparst.", progress=70)
|
||||
yield recipe
|
||||
return
|
||||
except Exception as e:
|
||||
log.info("recipe-scrapers failed (%s), trying LD+JSON fallback", e)
|
||||
|
||||
yield StatusEvent(stage="parsing", message="Suche nach Schema.org-Daten...", progress=40)
|
||||
|
||||
# Tier 2: LD+JSON in page (including Next.js RSC payloads)
|
||||
recipe = _try_ldjson(soup, url)
|
||||
if recipe and (recipe.ingredients or recipe.steps):
|
||||
yield StatusEvent(stage="parsing", message="Schema.org-Daten gefunden.", progress=70)
|
||||
yield recipe
|
||||
return
|
||||
|
||||
yield StatusEvent(stage="gpt", message="Extrahiere mit KI...", progress=55)
|
||||
|
||||
# Tier 3: GPT-4 fallback
|
||||
from app.services.openai_service import extract_recipe_from_text
|
||||
article_text = _extract_article_text(soup)
|
||||
if not article_text.strip():
|
||||
raise RuntimeError("Kein verwertbarer Text auf der Seite gefunden.")
|
||||
|
||||
truncated = article_text[:MAX_GPT_CHARS]
|
||||
recipe = await extract_recipe_from_text(truncated, url)
|
||||
yield StatusEvent(stage="done", message="Rezept extrahiert.", progress=100)
|
||||
yield recipe
|
||||
|
||||
|
||||
def _scraper_to_recipe(scraper, url: str) -> Recipe:
|
||||
def safe(fn):
|
||||
try:
|
||||
return fn()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
raw_ingredients = safe(scraper.ingredients) or []
|
||||
ingredients = [Ingredient(name=i) for i in raw_ingredients if i]
|
||||
|
||||
raw_instructions = safe(scraper.instructions_list) or []
|
||||
if not raw_instructions:
|
||||
instr = safe(scraper.instructions) or ""
|
||||
raw_instructions = [s.strip() for s in instr.split("\n") if s.strip()] if instr else []
|
||||
|
||||
nutrition_data = safe(scraper.nutrients) or {}
|
||||
nutrition = None
|
||||
if nutrition_data:
|
||||
from app.models import NutritionInfo
|
||||
kcal_str = nutrition_data.get("calories", "")
|
||||
kcal = _parse_float(kcal_str)
|
||||
if kcal:
|
||||
nutrition = NutritionInfo(kcal_per_serving=kcal, source="extracted")
|
||||
|
||||
servings = None
|
||||
yields_raw = safe(scraper.yields)
|
||||
if yields_raw:
|
||||
servings = _parse_int(yields_raw)
|
||||
|
||||
return Recipe(
|
||||
title=safe(scraper.title) or "Unbekanntes Rezept",
|
||||
description=safe(scraper.description),
|
||||
image_url=safe(scraper.image),
|
||||
source_url=url,
|
||||
servings=servings,
|
||||
prep_time_minutes=safe(scraper.prep_time),
|
||||
cook_time_minutes=safe(scraper.cook_time),
|
||||
ingredients=ingredients,
|
||||
steps=raw_instructions,
|
||||
nutrition=nutrition,
|
||||
tags=list(safe(scraper.category) or []) if isinstance(safe(scraper.category), list)
|
||||
else ([safe(scraper.category)] if safe(scraper.category) else []),
|
||||
)
|
||||
|
||||
|
||||
def _is_recipe_type(data: dict) -> bool:
|
||||
t = data.get("@type")
|
||||
if isinstance(t, list):
|
||||
return "Recipe" in t
|
||||
return t == "Recipe"
|
||||
|
||||
|
||||
def _parse_ldjson_candidates(data) -> list[dict]:
|
||||
"""Return all Recipe-type dicts from a parsed JSON-LD structure."""
|
||||
candidates: list[dict] = []
|
||||
if isinstance(data, dict):
|
||||
if "@graph" in data:
|
||||
for item in data["@graph"]:
|
||||
if isinstance(item, dict) and _is_recipe_type(item):
|
||||
candidates.append(item)
|
||||
elif _is_recipe_type(data):
|
||||
candidates.append(data)
|
||||
elif isinstance(data, list):
|
||||
for item in data:
|
||||
if isinstance(item, dict) and _is_recipe_type(item):
|
||||
candidates.append(item)
|
||||
return candidates
|
||||
|
||||
|
||||
def _try_ldjson(soup: BeautifulSoup, url: str) -> Recipe | None:
|
||||
# Standard <script type="application/ld+json"> tags
|
||||
for script in soup.find_all("script", type="application/ld+json"):
|
||||
try:
|
||||
data = json.loads(script.string or "")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
for item in _parse_ldjson_candidates(data):
|
||||
return _ldjson_to_recipe(item, url)
|
||||
|
||||
# Next.js RSC streaming payloads: self.__next_f.push([1,"<json-encoded-string>"])
|
||||
for script in soup.find_all("script"):
|
||||
text = script.string or ""
|
||||
if "recipeInstructions" not in text and "@type" not in text:
|
||||
continue
|
||||
m = re.search(r'self\.__next_f\.push\(\[1,"(.+)"\]\)\s*$', text, re.DOTALL)
|
||||
if not m:
|
||||
continue
|
||||
try:
|
||||
decoded = json.loads('"' + m.group(1) + '"')
|
||||
data = json.loads(decoded)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
continue
|
||||
for item in _parse_ldjson_candidates(data):
|
||||
return _ldjson_to_recipe(item, url)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _ldjson_to_recipe(data: dict, url: str) -> Recipe:
|
||||
from app.models import NutritionInfo
|
||||
|
||||
ingredients_raw = data.get("recipeIngredient", [])
|
||||
ingredients = [Ingredient(name=i) for i in ingredients_raw if isinstance(i, str)]
|
||||
|
||||
instructions_raw = data.get("recipeInstructions", [])
|
||||
steps: list[str] = []
|
||||
if isinstance(instructions_raw, str):
|
||||
steps = [s.strip() for s in instructions_raw.split("\n") if s.strip()]
|
||||
elif isinstance(instructions_raw, list):
|
||||
for item in instructions_raw:
|
||||
if isinstance(item, str):
|
||||
steps.append(item)
|
||||
elif isinstance(item, dict):
|
||||
item_type = item.get("@type", "")
|
||||
# HowToSection: recurse into itemListElement
|
||||
if item_type == "HowToSection":
|
||||
for sub in item.get("itemListElement", []):
|
||||
if isinstance(sub, dict):
|
||||
text = sub.get("text", "") or sub.get("name", "")
|
||||
if text:
|
||||
steps.append(text)
|
||||
else:
|
||||
text = item.get("text", "") or item.get("name", "")
|
||||
if text:
|
||||
steps.append(text)
|
||||
|
||||
nutrition = None
|
||||
nutr_data = data.get("nutrition", {})
|
||||
if isinstance(nutr_data, dict):
|
||||
kcal = _parse_float(nutr_data.get("calories", ""))
|
||||
if kcal:
|
||||
nutrition = NutritionInfo(kcal_per_serving=kcal, source="extracted")
|
||||
|
||||
yields_raw = data.get("recipeYield", data.get("yield", ""))
|
||||
servings = None
|
||||
if isinstance(yields_raw, list) and yields_raw:
|
||||
servings = _parse_int(str(yields_raw[0]))
|
||||
elif isinstance(yields_raw, (str, int)):
|
||||
servings = _parse_int(str(yields_raw))
|
||||
|
||||
keywords = data.get("keywords", "")
|
||||
if isinstance(keywords, str):
|
||||
tags = [k.strip() for k in keywords.split(",") if k.strip()]
|
||||
elif isinstance(keywords, list):
|
||||
tags = [str(k).strip() for k in keywords if k]
|
||||
else:
|
||||
tags = []
|
||||
|
||||
categories = data.get("recipeCategory", [])
|
||||
if isinstance(categories, str):
|
||||
tags += [categories]
|
||||
elif isinstance(categories, list):
|
||||
tags += categories
|
||||
|
||||
return Recipe(
|
||||
title=data.get("name", "Unbekanntes Rezept"),
|
||||
description=data.get("description"),
|
||||
image_url=_extract_image_url(data.get("image")),
|
||||
source_url=url,
|
||||
servings=servings,
|
||||
prep_time_minutes=_parse_duration(data.get("prepTime")),
|
||||
cook_time_minutes=_parse_duration(data.get("cookTime")),
|
||||
ingredients=ingredients,
|
||||
steps=steps,
|
||||
nutrition=nutrition,
|
||||
tags=list(set(tags))[:10],
|
||||
)
|
||||
|
||||
|
||||
def _extract_article_text(soup: BeautifulSoup) -> str:
|
||||
for tag in soup.find_all(["script", "style", "nav", "footer", "header", "aside"]):
|
||||
tag.decompose()
|
||||
for selector in ["article", "main", '[class*="recipe"]', '[id*="recipe"]']:
|
||||
el = soup.select_one(selector)
|
||||
if el:
|
||||
return el.get_text(separator="\n", strip=True)
|
||||
return soup.get_text(separator="\n", strip=True)
|
||||
|
||||
|
||||
def _extract_image_url(image) -> str | None:
|
||||
if not image:
|
||||
return None
|
||||
if isinstance(image, str):
|
||||
return image
|
||||
if isinstance(image, list) and image:
|
||||
first = image[0]
|
||||
return first.get("url") if isinstance(first, dict) else str(first)
|
||||
if isinstance(image, dict):
|
||||
return image.get("url") or image.get("contentUrl")
|
||||
return None
|
||||
|
||||
|
||||
def _parse_float(s: str | None) -> float | None:
|
||||
if not s:
|
||||
return None
|
||||
import re
|
||||
m = re.search(r"[\d.]+", str(s))
|
||||
try:
|
||||
return float(m.group()) if m else None
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _parse_int(s: str | None) -> int | None:
|
||||
if not s:
|
||||
return None
|
||||
import re
|
||||
m = re.search(r"\d+", str(s))
|
||||
try:
|
||||
return int(m.group()) if m else None
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _parse_duration(iso: str | None) -> int | None:
|
||||
"""Parse ISO 8601 duration (PT1H30M) → minutes."""
|
||||
if not iso:
|
||||
return None
|
||||
import re
|
||||
hours = re.search(r"(\d+)H", iso)
|
||||
minutes = re.search(r"(\d+)M", iso)
|
||||
total = 0
|
||||
if hours:
|
||||
total += int(hours.group(1)) * 60
|
||||
if minutes:
|
||||
total += int(minutes.group(1))
|
||||
return total or None
|
||||
@@ -0,0 +1,74 @@
|
||||
from __future__ import annotations
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class Ingredient(BaseModel):
|
||||
name: str
|
||||
amount: Optional[str] = None
|
||||
unit: Optional[str] = None
|
||||
note: Optional[str] = None
|
||||
|
||||
|
||||
class NutritionInfo(BaseModel):
|
||||
kcal_per_serving: Optional[float] = None
|
||||
kcal_total: Optional[float] = None
|
||||
servings: Optional[int] = None
|
||||
source: str = "extracted" # "extracted" | "usda" | "gpt4_estimate"
|
||||
|
||||
|
||||
class Recipe(BaseModel):
|
||||
title: str
|
||||
description: Optional[str] = None
|
||||
image_url: Optional[str] = None
|
||||
source_url: str
|
||||
servings: Optional[int] = None
|
||||
prep_time_minutes: Optional[int] = None
|
||||
cook_time_minutes: Optional[int] = None
|
||||
ingredients: list[Ingredient] = []
|
||||
steps: list[str] = []
|
||||
nutrition: Optional[NutritionInfo] = None
|
||||
tags: list[str] = []
|
||||
|
||||
|
||||
class TandoorStep(BaseModel):
|
||||
instruction: str
|
||||
ingredients: list[TandoorIngredient] = []
|
||||
time: int = 0
|
||||
|
||||
|
||||
class TandoorIngredient(BaseModel):
|
||||
food: dict # {"name": str}
|
||||
amount: float = 0
|
||||
unit: Optional[dict] = None # {"name": str} or None
|
||||
note: str = ""
|
||||
|
||||
|
||||
class TandoorRecipe(BaseModel):
|
||||
name: str
|
||||
description: str = ""
|
||||
servings: int = 1
|
||||
working_time: int = 0
|
||||
source_url: str = ""
|
||||
image: Optional[str] = None
|
||||
steps: list[TandoorStep] = []
|
||||
keywords: list[dict] = []
|
||||
internal: bool = True
|
||||
show_ingredient_overview: bool = False
|
||||
servings_text: str = "Portionen"
|
||||
|
||||
|
||||
class ExtractResponse(BaseModel):
|
||||
recipe: Recipe
|
||||
tandoor_json: TandoorRecipe
|
||||
history_id: Optional[int] = None
|
||||
|
||||
|
||||
class StatusEvent(BaseModel):
|
||||
stage: str
|
||||
message: str
|
||||
progress: int
|
||||
|
||||
|
||||
class ErrorEvent(BaseModel):
|
||||
message: str
|
||||
@@ -0,0 +1,83 @@
|
||||
"""SQLite-based recipe history."""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
|
||||
def _db_path() -> Path:
|
||||
settings = get_settings()
|
||||
return Path(settings.tmp_dir).parent / "history.db"
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
db = _db_path()
|
||||
db.parent.mkdir(parents=True, exist_ok=True)
|
||||
with sqlite3.connect(db) as conn:
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS recipe_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
extracted_at TEXT NOT NULL,
|
||||
title TEXT,
|
||||
source_url TEXT,
|
||||
image_url TEXT,
|
||||
recipe_json TEXT NOT NULL,
|
||||
imported_at TEXT,
|
||||
tandoor_recipe_id INTEGER
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
|
||||
|
||||
def save_recipe(extract_data: dict) -> int:
|
||||
recipe = extract_data.get("recipe", {})
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
with sqlite3.connect(_db_path()) as conn:
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO recipe_history
|
||||
(extracted_at, title, source_url, image_url, recipe_json)
|
||||
VALUES (?, ?, ?, ?, ?)""",
|
||||
(
|
||||
now,
|
||||
recipe.get("title"),
|
||||
recipe.get("source_url"),
|
||||
recipe.get("image_url"),
|
||||
json.dumps(extract_data),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
def get_all() -> list[dict]:
|
||||
with sqlite3.connect(_db_path()) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM recipe_history ORDER BY extracted_at DESC"
|
||||
).fetchall()
|
||||
result = []
|
||||
for r in rows:
|
||||
entry = dict(r)
|
||||
entry["recipe_data"] = json.loads(entry.pop("recipe_json"))
|
||||
result.append(entry)
|
||||
return result
|
||||
|
||||
|
||||
def delete_recipe(history_id: int) -> None:
|
||||
with sqlite3.connect(_db_path()) as conn:
|
||||
conn.execute("DELETE FROM recipe_history WHERE id = ?", (history_id,))
|
||||
conn.commit()
|
||||
|
||||
|
||||
def mark_imported(history_id: int, tandoor_recipe_id: Optional[int]) -> None:
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
with sqlite3.connect(_db_path()) as conn:
|
||||
conn.execute(
|
||||
"UPDATE recipe_history SET imported_at = ?, tandoor_recipe_id = ? WHERE id = ?",
|
||||
(now, tandoor_recipe_id, history_id),
|
||||
)
|
||||
conn.commit()
|
||||
@@ -0,0 +1,186 @@
|
||||
"""3-Tier nutrition strategy: extracted → USDA FoodData → GPT-4 estimate."""
|
||||
from __future__ import annotations
|
||||
import logging
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models import Ingredient, NutritionInfo, Recipe
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
USDA_API_URL = "https://api.nal.usda.gov/fdc/v1"
|
||||
MIN_USDA_COVERAGE = 0.5 # require ≥50% ingredients resolved
|
||||
|
||||
|
||||
async def enrich_nutrition(recipe: Recipe) -> Recipe:
|
||||
"""Enrich recipe with nutrition info using 3-tier fallback."""
|
||||
# Tier 1: Already extracted from source
|
||||
if recipe.nutrition and recipe.nutrition.kcal_per_serving:
|
||||
return recipe
|
||||
|
||||
if not recipe.ingredients:
|
||||
return recipe
|
||||
|
||||
servings = recipe.servings or 1
|
||||
|
||||
# Tier 2: USDA FoodData
|
||||
try:
|
||||
nutrition = await _usda_lookup(recipe.ingredients, servings)
|
||||
if nutrition and nutrition.kcal_per_serving:
|
||||
recipe = recipe.model_copy(update={"nutrition": nutrition})
|
||||
return recipe
|
||||
except Exception as e:
|
||||
log.warning("USDA lookup failed: %s", e)
|
||||
|
||||
# Tier 3: GPT-4 estimate (only if OpenAI key configured)
|
||||
settings = get_settings()
|
||||
if settings.openai_api_key:
|
||||
try:
|
||||
from app.services.openai_service import estimate_nutrition_gpt
|
||||
nutrition = await estimate_nutrition_gpt(recipe.ingredients, servings)
|
||||
recipe = recipe.model_copy(update={"nutrition": nutrition})
|
||||
except Exception as e:
|
||||
log.warning("GPT-4 nutrition estimate failed: %s", e)
|
||||
|
||||
return recipe
|
||||
|
||||
|
||||
async def _usda_lookup(ingredients: list[Ingredient], servings: int) -> Optional[NutritionInfo]:
|
||||
"""Look up each ingredient in USDA FoodData API and sum kcal."""
|
||||
settings = get_settings()
|
||||
api_key = settings.usda_api_key or "DEMO_KEY"
|
||||
|
||||
total_kcal = 0.0
|
||||
resolved = 0
|
||||
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
for ing in ingredients:
|
||||
try:
|
||||
kcal = await _usda_ingredient_kcal(client, ing, api_key)
|
||||
if kcal is not None:
|
||||
total_kcal += kcal
|
||||
resolved += 1
|
||||
except Exception as e:
|
||||
log.debug("USDA lookup for '%s' failed: %s", ing.name, e)
|
||||
|
||||
coverage = resolved / len(ingredients) if ingredients else 0
|
||||
if coverage < MIN_USDA_COVERAGE:
|
||||
log.info("USDA coverage %.0f%% < 50%%, skipping", coverage * 100)
|
||||
return None
|
||||
|
||||
kcal_per_serving = total_kcal / servings if servings else total_kcal
|
||||
return NutritionInfo(
|
||||
kcal_per_serving=round(kcal_per_serving, 1),
|
||||
kcal_total=round(total_kcal, 1),
|
||||
servings=servings,
|
||||
source="usda",
|
||||
)
|
||||
|
||||
|
||||
async def _usda_ingredient_kcal(
|
||||
client: httpx.AsyncClient,
|
||||
ing: Ingredient,
|
||||
api_key: str,
|
||||
) -> Optional[float]:
|
||||
"""Search USDA for one ingredient and return estimated kcal."""
|
||||
resp = await client.get(
|
||||
f"{USDA_API_URL}/foods/search",
|
||||
params={
|
||||
"query": ing.name,
|
||||
"dataType": "Foundation,SR Legacy",
|
||||
"pageSize": 1,
|
||||
"api_key": api_key,
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
foods = data.get("foods", [])
|
||||
if not foods:
|
||||
return None
|
||||
|
||||
food = foods[0]
|
||||
# Find Energy (kcal) nutrient
|
||||
nutrients = food.get("foodNutrients", [])
|
||||
kcal_per_100g = None
|
||||
for n in nutrients:
|
||||
name = (n.get("nutrientName") or "").lower()
|
||||
unit = (n.get("unitName") or "").upper()
|
||||
if "energy" in name and unit == "KCAL":
|
||||
kcal_per_100g = n.get("value")
|
||||
break
|
||||
|
||||
if kcal_per_100g is None:
|
||||
return None
|
||||
|
||||
grams = _estimate_grams(ing)
|
||||
if grams is None:
|
||||
return None
|
||||
|
||||
return kcal_per_100g * grams / 100
|
||||
|
||||
|
||||
def _estimate_grams(ing: Ingredient) -> Optional[float]:
|
||||
"""Convert ingredient amount + unit to grams (approximate)."""
|
||||
if not ing.amount:
|
||||
return None
|
||||
|
||||
amount_val = _parse_float(ing.amount)
|
||||
if amount_val is None:
|
||||
return None
|
||||
|
||||
unit = (ing.unit or "").lower().strip()
|
||||
|
||||
# Direct weight
|
||||
if unit in ("g", "gr", "gramm", "gram"):
|
||||
return amount_val
|
||||
if unit in ("kg", "kilogramm"):
|
||||
return amount_val * 1000
|
||||
if unit in ("mg", "milligramm"):
|
||||
return amount_val / 1000
|
||||
|
||||
# Volume → grams (approximate, assuming water density)
|
||||
if unit in ("ml", "milliliter", "millilitre"):
|
||||
return amount_val
|
||||
if unit in ("l", "liter", "litre"):
|
||||
return amount_val * 1000
|
||||
if unit in ("el", "esslöffel", "tbsp", "tablespoon"):
|
||||
return amount_val * 15
|
||||
if unit in ("tl", "teelöffel", "tsp", "teaspoon"):
|
||||
return amount_val * 5
|
||||
if unit in ("cup", "cups", "tasse"):
|
||||
return amount_val * 240
|
||||
|
||||
# Count → fallback average
|
||||
if unit in ("stück", "stk", "piece", "pieces", "", "pcs"):
|
||||
return amount_val * 100 # rough average
|
||||
|
||||
# Unknown unit → assume grams
|
||||
return amount_val
|
||||
|
||||
|
||||
def _parse_float(s: str) -> Optional[float]:
|
||||
if not s:
|
||||
return None
|
||||
# Handle fractions like "1/2"
|
||||
frac = re.match(r"^\s*(\d+)\s*/\s*(\d+)\s*$", s)
|
||||
if frac:
|
||||
return int(frac.group(1)) / int(frac.group(2))
|
||||
# Handle mixed like "1 1/2"
|
||||
mixed = re.match(r"^\s*(\d+)\s+(\d+)\s*/\s*(\d+)\s*$", s)
|
||||
if mixed:
|
||||
return int(mixed.group(1)) + int(mixed.group(2)) / int(mixed.group(3))
|
||||
# Range like "2-3" → take first
|
||||
range_m = re.match(r"^\s*([\d.]+)\s*[-–]\s*([\d.]+)\s*$", s)
|
||||
if range_m:
|
||||
return float(range_m.group(1))
|
||||
# Plain float
|
||||
plain = re.search(r"[\d.]+", s)
|
||||
if plain:
|
||||
try:
|
||||
return float(plain.group())
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
@@ -0,0 +1,183 @@
|
||||
"""OpenAI integration: Whisper transcription + GPT-4o-mini recipe extraction."""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from openai import AsyncOpenAI, AuthenticationError, RateLimitError, APIConnectionError
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models import Ingredient, NutritionInfo, Recipe
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_RECIPE_SYSTEM_PROMPT = """Du bist ein Rezept-Extraktor. Extrahiere das Rezept aus dem Text und antworte NUR mit einem JSON-Objekt (kein Markdown).
|
||||
|
||||
JSON-Schema:
|
||||
{
|
||||
"title": "string",
|
||||
"description": "string oder null",
|
||||
"servings": integer oder null,
|
||||
"prep_time_minutes": integer oder null,
|
||||
"cook_time_minutes": integer oder null,
|
||||
"ingredients": [
|
||||
{"name": "string", "amount": "string oder null", "unit": "string oder null", "note": "string oder null"}
|
||||
],
|
||||
"steps": ["string"],
|
||||
"kcal_per_serving": number oder null,
|
||||
"tags": ["string"]
|
||||
}
|
||||
|
||||
Regeln:
|
||||
- amount ist immer ein String (z.B. "200", "1/2", "2-3")
|
||||
- unit ist die Maßeinheit (g, ml, EL, TL, Stück, etc.)
|
||||
- Wenn keine Kalorien erkennbar sind, setze kcal_per_serving auf null
|
||||
- steps sind vollständige Zubereitungsschritte
|
||||
- tags: maximal 5 relevante Schlagwörter
|
||||
"""
|
||||
|
||||
_CAPTION_CHECK_PROMPT = """Enthält der folgende Text ein vollständiges Rezept mit Zutaten UND Zubereitungsschritten?
|
||||
Antworte NUR mit einem JSON-Objekt: {"has_recipe": true} oder {"has_recipe": false}
|
||||
|
||||
Text:
|
||||
"""
|
||||
|
||||
|
||||
def _get_client() -> AsyncOpenAI:
|
||||
return AsyncOpenAI(api_key=get_settings().openai_api_key)
|
||||
|
||||
|
||||
def _openai_error(e: Exception) -> RuntimeError:
|
||||
if isinstance(e, AuthenticationError):
|
||||
return RuntimeError("OpenAI API-Schlüssel ungültig oder abgelaufen.")
|
||||
if isinstance(e, RateLimitError):
|
||||
return RuntimeError("OpenAI Rate-Limit erreicht. Bitte kurz warten und erneut versuchen.")
|
||||
if isinstance(e, APIConnectionError):
|
||||
return RuntimeError("OpenAI nicht erreichbar. Internetverbindung prüfen.")
|
||||
return RuntimeError(f"OpenAI-Fehler: {e}")
|
||||
|
||||
|
||||
async def check_caption_has_recipe(text: str) -> bool:
|
||||
"""Check if a video description/caption contains a complete recipe."""
|
||||
client = _get_client()
|
||||
try:
|
||||
resp = await client.chat.completions.create(
|
||||
model="gpt-4o-mini",
|
||||
messages=[
|
||||
{"role": "user", "content": _CAPTION_CHECK_PROMPT + text[:3000]}
|
||||
],
|
||||
max_tokens=20,
|
||||
response_format={"type": "json_object"},
|
||||
)
|
||||
data = json.loads(resp.choices[0].message.content or "{}")
|
||||
return bool(data.get("has_recipe", False))
|
||||
except (AuthenticationError, RateLimitError, APIConnectionError) as e:
|
||||
raise _openai_error(e) from e
|
||||
except Exception as e:
|
||||
log.warning("Caption check failed: %s", e)
|
||||
return False
|
||||
|
||||
|
||||
async def transcribe_audio(audio_path: Path) -> str:
|
||||
"""Transcribe audio file with Whisper API."""
|
||||
client = _get_client()
|
||||
try:
|
||||
with open(audio_path, "rb") as f:
|
||||
transcript = await client.audio.transcriptions.create(
|
||||
model="whisper-1",
|
||||
file=f,
|
||||
language="de",
|
||||
response_format="text",
|
||||
)
|
||||
return str(transcript)
|
||||
except (AuthenticationError, RateLimitError, APIConnectionError) as e:
|
||||
raise _openai_error(e) from e
|
||||
|
||||
|
||||
async def extract_recipe_from_text(text: str, source_url: str) -> Recipe:
|
||||
"""Use GPT-4o-mini to extract a structured recipe from text."""
|
||||
client = _get_client()
|
||||
try:
|
||||
resp = await client.chat.completions.create(
|
||||
model="gpt-4o-mini",
|
||||
messages=[
|
||||
{"role": "system", "content": _RECIPE_SYSTEM_PROMPT},
|
||||
{"role": "user", "content": text[:12000]},
|
||||
],
|
||||
response_format={"type": "json_object"},
|
||||
temperature=0.1,
|
||||
)
|
||||
except (AuthenticationError, RateLimitError, APIConnectionError) as e:
|
||||
raise _openai_error(e) from e
|
||||
raw = resp.choices[0].message.content or "{}"
|
||||
data = json.loads(raw)
|
||||
return _gpt_json_to_recipe(data, source_url)
|
||||
|
||||
|
||||
async def estimate_nutrition_gpt(ingredients: list[Ingredient], servings: int) -> NutritionInfo:
|
||||
"""Estimate kcal using GPT-4o-mini as last resort."""
|
||||
client = _get_client()
|
||||
ing_text = "\n".join(
|
||||
f"- {i.amount or ''} {i.unit or ''} {i.name} {('(' + i.note + ')') if i.note else ''}".strip()
|
||||
for i in ingredients
|
||||
)
|
||||
prompt = (
|
||||
f"Schätze die Gesamtkalorienzahl und Kalorien pro Portion für folgende Zutaten "
|
||||
f"({servings} Portionen):\n{ing_text}\n\n"
|
||||
'Antworte NUR mit JSON: {"kcal_total": number, "kcal_per_serving": number}'
|
||||
)
|
||||
try:
|
||||
resp = await client.chat.completions.create(
|
||||
model="gpt-4o-mini",
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
response_format={"type": "json_object"},
|
||||
temperature=0,
|
||||
)
|
||||
data = json.loads(resp.choices[0].message.content or "{}")
|
||||
return NutritionInfo(
|
||||
kcal_per_serving=data.get("kcal_per_serving"),
|
||||
kcal_total=data.get("kcal_total"),
|
||||
servings=servings,
|
||||
source="gpt4_estimate",
|
||||
)
|
||||
except (AuthenticationError, RateLimitError, APIConnectionError) as e:
|
||||
log.warning("GPT nutrition estimate failed (API): %s", _openai_error(e))
|
||||
return NutritionInfo(source="gpt4_estimate")
|
||||
except Exception as e:
|
||||
log.warning("GPT nutrition estimate failed: %s", e)
|
||||
return NutritionInfo(source="gpt4_estimate")
|
||||
|
||||
|
||||
def _gpt_json_to_recipe(data: dict, source_url: str) -> Recipe:
|
||||
ingredients = [
|
||||
Ingredient(
|
||||
name=i.get("name", ""),
|
||||
amount=i.get("amount"),
|
||||
unit=i.get("unit"),
|
||||
note=i.get("note"),
|
||||
)
|
||||
for i in data.get("ingredients", [])
|
||||
if i.get("name")
|
||||
]
|
||||
|
||||
nutrition = None
|
||||
kcal = data.get("kcal_per_serving")
|
||||
if kcal:
|
||||
try:
|
||||
nutrition = NutritionInfo(kcal_per_serving=float(kcal), source="extracted")
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
return Recipe(
|
||||
title=data.get("title", "Unbekanntes Rezept"),
|
||||
description=data.get("description"),
|
||||
image_url=None,
|
||||
source_url=source_url,
|
||||
servings=data.get("servings"),
|
||||
prep_time_minutes=data.get("prep_time_minutes"),
|
||||
cook_time_minutes=data.get("cook_time_minutes"),
|
||||
ingredients=ingredients,
|
||||
steps=data.get("steps", []),
|
||||
nutrition=nutrition,
|
||||
tags=data.get("tags", [])[:10],
|
||||
)
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Tandoor REST API client."""
|
||||
from __future__ import annotations
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models import (
|
||||
Ingredient,
|
||||
Recipe,
|
||||
TandoorIngredient,
|
||||
TandoorRecipe,
|
||||
TandoorStep,
|
||||
)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def recipe_to_tandoor(recipe: Recipe) -> TandoorRecipe:
|
||||
"""Convert internal Recipe to Tandoor-compatible import format."""
|
||||
working_time = (recipe.prep_time_minutes or 0) + (recipe.cook_time_minutes or 0)
|
||||
|
||||
# All ingredients go into step[0] (Tandoor convention)
|
||||
tandoor_ingredients = [_to_tandoor_ingredient(ing) for ing in recipe.ingredients]
|
||||
|
||||
# Build steps: first step carries all ingredients, subsequent steps are plain text
|
||||
steps: list[TandoorStep] = []
|
||||
for i, step_text in enumerate(recipe.steps):
|
||||
step = TandoorStep(
|
||||
instruction=step_text,
|
||||
ingredients=tandoor_ingredients if i == 0 else [],
|
||||
time=0,
|
||||
)
|
||||
steps.append(step)
|
||||
|
||||
if not steps:
|
||||
# If no steps, create a placeholder with all ingredients
|
||||
steps = [TandoorStep(instruction="Zubereitung", ingredients=tandoor_ingredients)]
|
||||
|
||||
keywords = [{"label": tag, "name": tag} for tag in recipe.tags]
|
||||
|
||||
servings_text = "Portionen"
|
||||
if recipe.nutrition and recipe.nutrition.kcal_per_serving:
|
||||
kcal = round(recipe.nutrition.kcal_per_serving)
|
||||
servings_text = f"Portionen (ca. {kcal} kcal)"
|
||||
|
||||
return TandoorRecipe(
|
||||
name=recipe.title,
|
||||
description=recipe.description or "",
|
||||
servings=recipe.servings or 1,
|
||||
working_time=working_time,
|
||||
source_url=recipe.source_url,
|
||||
image=recipe.image_url,
|
||||
steps=steps,
|
||||
keywords=keywords,
|
||||
servings_text=servings_text,
|
||||
)
|
||||
|
||||
|
||||
def _to_tandoor_ingredient(ing: Ingredient) -> TandoorIngredient:
|
||||
amount = 0.0
|
||||
if ing.amount:
|
||||
try:
|
||||
from app.services.nutrition import _parse_float
|
||||
val = _parse_float(ing.amount)
|
||||
amount = val if val is not None else 0.0
|
||||
except Exception:
|
||||
amount = 0.0
|
||||
|
||||
unit = {"name": ing.unit} if ing.unit else None
|
||||
|
||||
return TandoorIngredient(
|
||||
food={"name": ing.name},
|
||||
amount=amount,
|
||||
unit=unit,
|
||||
note=ing.note or "",
|
||||
)
|
||||
|
||||
|
||||
async def import_to_tandoor(recipe: Recipe) -> dict:
|
||||
"""Import a recipe to Tandoor via REST API. Returns created recipe data."""
|
||||
settings = get_settings()
|
||||
|
||||
if not settings.tandoor_api_token:
|
||||
raise RuntimeError("TANDOOR_API_TOKEN nicht konfiguriert.")
|
||||
if not settings.tandoor_base_url:
|
||||
raise RuntimeError("TANDOOR_BASE_URL nicht konfiguriert.")
|
||||
|
||||
base_url = settings.tandoor_base_url.rstrip("/")
|
||||
headers = {
|
||||
"Authorization": f"Bearer {settings.tandoor_api_token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
tandoor_recipe = recipe_to_tandoor(recipe)
|
||||
payload = tandoor_recipe.model_dump(exclude={"image"})
|
||||
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
# POST recipe
|
||||
resp = await client.post(
|
||||
f"{base_url}/api/recipe/",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
)
|
||||
if resp.status_code not in (200, 201):
|
||||
raise RuntimeError(
|
||||
f"Tandoor API Fehler {resp.status_code}: {resp.text[:300]}"
|
||||
)
|
||||
created = resp.json()
|
||||
recipe_id = created.get("id")
|
||||
|
||||
# Upload image if available
|
||||
if recipe.image_url and recipe_id:
|
||||
await _upload_image(client, base_url, recipe_id, recipe.image_url, settings.tandoor_api_token)
|
||||
|
||||
return created
|
||||
|
||||
|
||||
async def _upload_image(
|
||||
client: httpx.AsyncClient,
|
||||
base_url: str,
|
||||
recipe_id: int,
|
||||
image_url: str,
|
||||
token: str,
|
||||
) -> None:
|
||||
"""Download image from URL and upload to Tandoor."""
|
||||
try:
|
||||
img_resp = await client.get(image_url, follow_redirects=True, timeout=15)
|
||||
img_resp.raise_for_status()
|
||||
image_data = img_resp.content
|
||||
|
||||
# Determine content type
|
||||
content_type = img_resp.headers.get("content-type", "image/jpeg").split(";")[0]
|
||||
ext = {"image/jpeg": "jpg", "image/png": "png", "image/webp": "webp"}.get(content_type, "jpg")
|
||||
|
||||
files = {"image": (f"recipe.{ext}", image_data, content_type)}
|
||||
patch_resp = await client.patch(
|
||||
f"{base_url}/api/recipe/{recipe_id}/",
|
||||
files=files,
|
||||
headers={"Authorization": f"Bearer {token}"}, # NO Content-Type here
|
||||
)
|
||||
if patch_resp.status_code not in (200, 201):
|
||||
log.warning("Image upload failed: %s", patch_resp.text[:200])
|
||||
except Exception as e:
|
||||
log.warning("Could not upload image: %s", e)
|
||||
@@ -0,0 +1,523 @@
|
||||
/* Rezept0r – Frontend */
|
||||
|
||||
let currentRecipeData = null;
|
||||
let tandoorConfigured = false;
|
||||
|
||||
async function checkHealth() {
|
||||
try {
|
||||
const res = await fetch('/api/health');
|
||||
const data = await res.json();
|
||||
tandoorConfigured = data.tandoor_configured;
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function renderMarkdownBold(text) {
|
||||
const escaped = text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
return escaped.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
||||
}
|
||||
|
||||
function showSection(id) {
|
||||
['progress-section', 'result-section', 'error-section'].forEach(s => {
|
||||
document.getElementById(s).classList.add('hidden');
|
||||
});
|
||||
document.getElementById(id).classList.remove('hidden');
|
||||
}
|
||||
|
||||
function setProgress(pct, message) {
|
||||
document.getElementById('progress-bar').style.width = pct + '%';
|
||||
document.getElementById('progress-message').textContent = message;
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
showSection('error-section');
|
||||
document.getElementById('error-message').textContent = message;
|
||||
}
|
||||
|
||||
function renderRecipe(data) {
|
||||
const { recipe, tandoor_json } = data;
|
||||
currentRecipeData = data;
|
||||
|
||||
// Image
|
||||
const img = document.getElementById('recipe-image');
|
||||
if (recipe.image_url) {
|
||||
img.src = recipe.image_url;
|
||||
img.classList.remove('hidden');
|
||||
img.onerror = () => img.classList.add('hidden');
|
||||
} else {
|
||||
img.classList.add('hidden');
|
||||
}
|
||||
|
||||
document.getElementById('recipe-title').textContent = recipe.title || '';
|
||||
|
||||
const desc = document.getElementById('recipe-description');
|
||||
if (recipe.description) {
|
||||
desc.textContent = recipe.description;
|
||||
desc.classList.remove('hidden');
|
||||
} else {
|
||||
desc.classList.add('hidden');
|
||||
}
|
||||
|
||||
// Meta tags
|
||||
if (recipe.servings) {
|
||||
document.getElementById('servings-value').textContent = recipe.servings + ' Portionen';
|
||||
document.getElementById('recipe-servings').classList.remove('hidden');
|
||||
}
|
||||
if (recipe.prep_time_minutes) {
|
||||
document.getElementById('prep-value').textContent = recipe.prep_time_minutes;
|
||||
document.getElementById('recipe-prep').classList.remove('hidden');
|
||||
}
|
||||
if (recipe.cook_time_minutes) {
|
||||
document.getElementById('cook-value').textContent = recipe.cook_time_minutes;
|
||||
document.getElementById('recipe-cook').classList.remove('hidden');
|
||||
}
|
||||
if (recipe.nutrition?.kcal_per_serving) {
|
||||
const sourceLabel = {
|
||||
'extracted': '',
|
||||
'usda': '(USDA)',
|
||||
'gpt4_estimate': '(Schätzung)'
|
||||
}[recipe.nutrition.source] || '';
|
||||
document.getElementById('kcal-value').textContent = Math.round(recipe.nutrition.kcal_per_serving);
|
||||
document.getElementById('kcal-source').textContent = sourceLabel;
|
||||
document.getElementById('recipe-kcal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
// Tags
|
||||
const tagList = document.getElementById('recipe-tags');
|
||||
tagList.innerHTML = '';
|
||||
(recipe.tags || []).forEach(tag => {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'tag';
|
||||
span.textContent = tag;
|
||||
tagList.appendChild(span);
|
||||
});
|
||||
|
||||
// Ingredients
|
||||
const ul = document.getElementById('ingredients-list');
|
||||
ul.innerHTML = '';
|
||||
(recipe.ingredients || []).forEach(ing => {
|
||||
const li = document.createElement('li');
|
||||
if (ing.amount || ing.unit) {
|
||||
const amountSpan = document.createElement('span');
|
||||
amountSpan.className = 'ingredient-amount';
|
||||
amountSpan.textContent = [ing.amount, ing.unit].filter(Boolean).join(' ');
|
||||
li.appendChild(amountSpan);
|
||||
}
|
||||
const nameSpan = document.createElement('span');
|
||||
nameSpan.textContent = ing.name;
|
||||
li.appendChild(nameSpan);
|
||||
if (ing.note) {
|
||||
const noteSpan = document.createElement('span');
|
||||
noteSpan.className = 'ingredient-note';
|
||||
noteSpan.textContent = '(' + ing.note + ')';
|
||||
li.appendChild(noteSpan);
|
||||
}
|
||||
ul.appendChild(li);
|
||||
});
|
||||
|
||||
// Steps
|
||||
const ol = document.getElementById('steps-list');
|
||||
ol.innerHTML = '';
|
||||
(recipe.steps || []).forEach(step => {
|
||||
const li = document.createElement('li');
|
||||
li.innerHTML = renderMarkdownBold(step);
|
||||
ol.appendChild(li);
|
||||
});
|
||||
|
||||
// Source link
|
||||
const sourceLink = document.getElementById('source-link');
|
||||
sourceLink.href = recipe.source_url || '#';
|
||||
|
||||
// Tandoor fallback JSON
|
||||
document.getElementById('tandoor-json').value = JSON.stringify(tandoor_json, null, 2);
|
||||
|
||||
document.getElementById('import-status').textContent = '';
|
||||
document.getElementById('tandoor-fallback').classList.add('hidden');
|
||||
|
||||
showSection('result-section');
|
||||
}
|
||||
|
||||
function startExtraction() {
|
||||
const url = document.getElementById('url-input').value.trim();
|
||||
if (!url) return;
|
||||
|
||||
const btn = document.getElementById('extract-btn');
|
||||
btn.disabled = true;
|
||||
|
||||
showSection('progress-section');
|
||||
setProgress(0, 'Starte Extraktion...');
|
||||
|
||||
const sse = new EventSource('/api/extract/stream?url=' + encodeURIComponent(url));
|
||||
|
||||
sse.addEventListener('status', e => {
|
||||
const data = JSON.parse(e.data);
|
||||
setProgress(data.progress, data.message);
|
||||
});
|
||||
|
||||
sse.addEventListener('result', e => {
|
||||
sse.close();
|
||||
btn.disabled = false;
|
||||
const data = JSON.parse(e.data);
|
||||
renderRecipe(data);
|
||||
loadHistory();
|
||||
});
|
||||
|
||||
sse.addEventListener('error', e => {
|
||||
sse.close();
|
||||
btn.disabled = false;
|
||||
if (e.data) {
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
showError(data.message || 'Unbekannter Fehler');
|
||||
} catch (_) {
|
||||
showError('Verbindungsfehler. Bitte erneut versuchen.');
|
||||
}
|
||||
} else {
|
||||
showError('Verbindung zum Server unterbrochen.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function importToTandoor() {
|
||||
if (!currentRecipeData) return;
|
||||
const btn = document.getElementById('import-btn');
|
||||
const status = document.getElementById('import-status');
|
||||
|
||||
if (!tandoorConfigured) {
|
||||
status.textContent = 'Tandoor nicht konfiguriert – JSON unten kopieren.';
|
||||
status.className = 'import-status error';
|
||||
showTandoorFallback();
|
||||
return;
|
||||
}
|
||||
|
||||
btn.disabled = true;
|
||||
status.textContent = 'Importiere...';
|
||||
status.className = 'import-status';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/import', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(currentRecipeData)
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
status.textContent = 'Erfolgreich importiert!';
|
||||
status.className = 'import-status success';
|
||||
loadHistory();
|
||||
} else {
|
||||
status.textContent = 'Fehler: ' + (data.detail || 'Import fehlgeschlagen');
|
||||
status.className = 'import-status error';
|
||||
showTandoorFallback();
|
||||
}
|
||||
} catch (_) {
|
||||
status.textContent = 'Netzwerkfehler beim Import';
|
||||
status.className = 'import-status error';
|
||||
showTandoorFallback();
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function showTandoorFallback() {
|
||||
document.getElementById('tandoor-fallback').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function copyJson() {
|
||||
const ta = document.getElementById('tandoor-json');
|
||||
ta.select();
|
||||
navigator.clipboard.writeText(ta.value).catch(() => document.execCommand('copy'));
|
||||
const btn = document.getElementById('copy-json-btn');
|
||||
btn.textContent = 'Kopiert!';
|
||||
setTimeout(() => btn.textContent = 'JSON kopieren', 2000);
|
||||
}
|
||||
|
||||
// ── History ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatDate(isoStr) {
|
||||
if (!isoStr) return '';
|
||||
const d = new Date(isoStr);
|
||||
return d.toLocaleDateString('de-DE', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
async function loadHistory() {
|
||||
try {
|
||||
const res = await fetch('/api/history');
|
||||
const entries = await res.json();
|
||||
renderHistory(entries);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function renderHistory(entries) {
|
||||
const section = document.getElementById('history-section');
|
||||
const container = document.getElementById('history-list');
|
||||
if (!section || !container) return;
|
||||
|
||||
if (!Array.isArray(entries) || entries.length === 0) {
|
||||
section.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
section.classList.remove('hidden');
|
||||
container.innerHTML = '';
|
||||
entries.forEach(entry => {
|
||||
try {
|
||||
container.appendChild(buildHistoryCard(entry));
|
||||
} catch (e) {
|
||||
console.error('History card render error:', e, entry);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function buildHistoryCard(entry) {
|
||||
const recipe = entry.recipe_data?.recipe || {};
|
||||
const card = document.createElement('div');
|
||||
card.className = 'history-card';
|
||||
|
||||
// ── Header (always visible) ──
|
||||
const header = document.createElement('div');
|
||||
header.className = 'history-card-header';
|
||||
|
||||
if (recipe.image_url) {
|
||||
const img = document.createElement('img');
|
||||
img.className = 'history-thumb';
|
||||
img.src = recipe.image_url;
|
||||
img.alt = '';
|
||||
img.onerror = () => img.remove();
|
||||
header.appendChild(img);
|
||||
}
|
||||
|
||||
const info = document.createElement('div');
|
||||
info.className = 'history-info';
|
||||
|
||||
const title = document.createElement('h3');
|
||||
title.className = 'history-title';
|
||||
title.textContent = entry.title || 'Unbekanntes Rezept';
|
||||
info.appendChild(title);
|
||||
|
||||
if (recipe.description) {
|
||||
const desc = document.createElement('p');
|
||||
desc.className = 'history-desc';
|
||||
desc.textContent = recipe.description;
|
||||
info.appendChild(desc);
|
||||
}
|
||||
|
||||
// Meta-Tags (Portionen, Zeit, kcal)
|
||||
const metaTags = document.createElement('div');
|
||||
metaTags.className = 'meta-tags';
|
||||
const addMetaTag = (icon, text) => {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'meta-tag';
|
||||
span.innerHTML = `<span class="icon">${icon}</span> ${text}`;
|
||||
metaTags.appendChild(span);
|
||||
};
|
||||
if (recipe.servings) addMetaTag('⏱', recipe.servings + ' Portionen');
|
||||
if (recipe.prep_time_minutes) addMetaTag('⏱', 'Vorbereitung: ' + recipe.prep_time_minutes + ' Min.');
|
||||
if (recipe.cook_time_minutes) addMetaTag('🔥', 'Kochen: ' + recipe.cook_time_minutes + ' Min.');
|
||||
if (recipe.nutrition?.kcal_per_serving) {
|
||||
const sourceLabel = { usda: ' (USDA)', gpt4_estimate: ' (Schätzung)' }[recipe.nutrition.source] || '';
|
||||
addMetaTag('🔥', Math.round(recipe.nutrition.kcal_per_serving) + ' kcal' + sourceLabel);
|
||||
}
|
||||
if (metaTags.children.length) info.appendChild(metaTags);
|
||||
|
||||
if (recipe.tags?.length) {
|
||||
const tagList = document.createElement('div');
|
||||
tagList.className = 'tag-list history-tags';
|
||||
recipe.tags.forEach(tag => {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'tag';
|
||||
span.textContent = tag;
|
||||
tagList.appendChild(span);
|
||||
});
|
||||
info.appendChild(tagList);
|
||||
}
|
||||
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'history-meta';
|
||||
|
||||
const dateSpan = document.createElement('span');
|
||||
dateSpan.className = 'history-date';
|
||||
dateSpan.textContent = formatDate(entry.extracted_at);
|
||||
meta.appendChild(dateSpan);
|
||||
|
||||
if (entry.imported_at) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'history-imported-badge';
|
||||
badge.textContent = 'Importiert ' + formatDate(entry.imported_at);
|
||||
meta.appendChild(badge);
|
||||
}
|
||||
|
||||
info.appendChild(meta);
|
||||
header.appendChild(info);
|
||||
|
||||
// Controls
|
||||
const controls = document.createElement('div');
|
||||
controls.className = 'history-controls';
|
||||
|
||||
const toggleBtn = document.createElement('button');
|
||||
toggleBtn.className = 'history-toggle-btn';
|
||||
toggleBtn.title = 'Auf-/Zuklappen';
|
||||
toggleBtn.innerHTML = '▼';
|
||||
controls.appendChild(toggleBtn);
|
||||
|
||||
const deleteBtn = document.createElement('button');
|
||||
deleteBtn.className = 'history-delete-btn';
|
||||
deleteBtn.title = 'Löschen';
|
||||
deleteBtn.innerHTML = '✕';
|
||||
controls.appendChild(deleteBtn);
|
||||
|
||||
header.appendChild(controls);
|
||||
card.appendChild(header);
|
||||
|
||||
// ── Body (collapsed by default) ──
|
||||
const body = document.createElement('div');
|
||||
body.className = 'history-card-body hidden';
|
||||
|
||||
const bodyInner = document.createElement('div');
|
||||
bodyInner.className = 'recipe-body';
|
||||
|
||||
if (recipe.ingredients?.length) {
|
||||
const ingSection = document.createElement('div');
|
||||
ingSection.className = 'ingredients-section';
|
||||
const ingTitle = document.createElement('h3');
|
||||
ingTitle.textContent = 'Zutaten';
|
||||
ingSection.appendChild(ingTitle);
|
||||
const ul = document.createElement('ul');
|
||||
ul.className = 'ingredients-list';
|
||||
recipe.ingredients.forEach(ing => {
|
||||
const li = document.createElement('li');
|
||||
if (ing.amount || ing.unit) {
|
||||
const amountSpan = document.createElement('span');
|
||||
amountSpan.className = 'ingredient-amount';
|
||||
amountSpan.textContent = [ing.amount, ing.unit].filter(Boolean).join(' ');
|
||||
li.appendChild(amountSpan);
|
||||
}
|
||||
const nameSpan = document.createElement('span');
|
||||
nameSpan.textContent = ing.name;
|
||||
li.appendChild(nameSpan);
|
||||
if (ing.note) {
|
||||
const noteSpan = document.createElement('span');
|
||||
noteSpan.className = 'ingredient-note';
|
||||
noteSpan.textContent = '(' + ing.note + ')';
|
||||
li.appendChild(noteSpan);
|
||||
}
|
||||
ul.appendChild(li);
|
||||
});
|
||||
ingSection.appendChild(ul);
|
||||
bodyInner.appendChild(ingSection);
|
||||
}
|
||||
|
||||
if (recipe.steps?.length) {
|
||||
const stepsSection = document.createElement('div');
|
||||
stepsSection.className = 'steps-section';
|
||||
const stepsTitle = document.createElement('h3');
|
||||
stepsTitle.textContent = 'Zubereitung';
|
||||
stepsSection.appendChild(stepsTitle);
|
||||
const ol = document.createElement('ol');
|
||||
ol.className = 'steps-list';
|
||||
recipe.steps.forEach(step => {
|
||||
const li = document.createElement('li');
|
||||
li.innerHTML = renderMarkdownBold(step);
|
||||
ol.appendChild(li);
|
||||
});
|
||||
stepsSection.appendChild(ol);
|
||||
bodyInner.appendChild(stepsSection);
|
||||
}
|
||||
|
||||
body.appendChild(bodyInner);
|
||||
|
||||
// Actions
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'recipe-actions';
|
||||
|
||||
if (recipe.source_url) {
|
||||
const sourceLink = document.createElement('a');
|
||||
sourceLink.href = recipe.source_url;
|
||||
sourceLink.target = '_blank';
|
||||
sourceLink.className = 'btn btn-secondary';
|
||||
sourceLink.textContent = 'Originalrezept';
|
||||
actions.appendChild(sourceLink);
|
||||
}
|
||||
|
||||
if (!entry.imported_at) {
|
||||
const importBtn = document.createElement('button');
|
||||
importBtn.className = 'btn btn-primary';
|
||||
importBtn.textContent = 'In Tandoor importieren';
|
||||
const importStatus = document.createElement('span');
|
||||
importStatus.className = 'import-status';
|
||||
|
||||
importBtn.addEventListener('click', async () => {
|
||||
if (!tandoorConfigured) {
|
||||
importStatus.textContent = 'Tandoor nicht konfiguriert.';
|
||||
importStatus.className = 'import-status error';
|
||||
return;
|
||||
}
|
||||
importBtn.disabled = true;
|
||||
importStatus.textContent = 'Importiere...';
|
||||
importStatus.className = 'import-status';
|
||||
try {
|
||||
const payload = { ...entry.recipe_data, history_id: entry.id };
|
||||
const res = await fetch('/api/import', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
await loadHistory();
|
||||
} else {
|
||||
importStatus.textContent = 'Fehler: ' + (data.detail || 'Import fehlgeschlagen');
|
||||
importStatus.className = 'import-status error';
|
||||
importBtn.disabled = false;
|
||||
}
|
||||
} catch (_) {
|
||||
importStatus.textContent = 'Netzwerkfehler';
|
||||
importStatus.className = 'import-status error';
|
||||
importBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
actions.appendChild(importBtn);
|
||||
actions.appendChild(importStatus);
|
||||
}
|
||||
|
||||
body.appendChild(actions);
|
||||
card.appendChild(body);
|
||||
|
||||
// ── Toggle ──
|
||||
toggleBtn.addEventListener('click', () => {
|
||||
const expanded = !body.classList.contains('hidden');
|
||||
body.classList.toggle('hidden', expanded);
|
||||
toggleBtn.innerHTML = expanded ? '▼' : '▲';
|
||||
card.classList.toggle('history-card--expanded', !expanded);
|
||||
});
|
||||
|
||||
// ── Delete ──
|
||||
deleteBtn.addEventListener('click', async () => {
|
||||
if (!confirm('Rezept aus dem Verlauf löschen?')) return;
|
||||
try {
|
||||
await fetch('/api/history/' + entry.id, { method: 'DELETE' });
|
||||
card.remove();
|
||||
if (!document.querySelector('#history-list .history-card')) {
|
||||
document.getElementById('history-section').classList.add('hidden');
|
||||
}
|
||||
} catch (_) {}
|
||||
});
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
// ── Event listeners ──────────────────────────────────────────────────────────
|
||||
|
||||
document.getElementById('extract-btn').addEventListener('click', startExtraction);
|
||||
document.getElementById('url-input').addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter') startExtraction();
|
||||
});
|
||||
document.getElementById('import-btn').addEventListener('click', importToTandoor);
|
||||
document.getElementById('copy-json-btn').addEventListener('click', copyJson);
|
||||
|
||||
// Init
|
||||
checkHealth();
|
||||
loadHistory();
|
||||
@@ -0,0 +1,141 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Rezept0r</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Instrument+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/static/style.css?v=1">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<nav class="nav">
|
||||
<div class="nav-inner">
|
||||
<span class="nav-logo">Rezept0r</span>
|
||||
<span class="nav-badge">v2</span>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="page-shell">
|
||||
|
||||
<section class="hero">
|
||||
<h1 class="hero-headline">Rezepte aus Social,<br>Video und Web.</h1>
|
||||
<p class="hero-sub">URL einfügen — analysieren, extrahieren, direkt nach Tandoor importieren.</p>
|
||||
|
||||
<div class="url-form">
|
||||
<input
|
||||
type="url"
|
||||
id="url-input"
|
||||
placeholder="Rezept-URL oder YouTube / TikTok / Instagram…"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<button id="extract-btn" type="button">
|
||||
<span class="btn-label">Extrahieren</span>
|
||||
<span class="btn-arrow" aria-hidden="true">→</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="platform-strip" aria-label="Unterstützte Plattformen">
|
||||
<span class="platform-label">Unterstützt:</span>
|
||||
<div class="platform-pill">
|
||||
<svg class="platform-icon" viewBox="0 0 24 24" aria-hidden="true"><path fill="#FF0000" d="M23.5 6.19a3.02 3.02 0 0 0-2.12-2.14C19.5 3.55 12 3.55 12 3.55s-7.51 0-9.38.5A3.02 3.02 0 0 0 .5 6.19C0 8.07 0 12 0 12s0 3.93.5 5.81a3.02 3.02 0 0 0 2.12 2.14c1.87.5 9.38.5 9.38.5s7.51 0 9.38-.5a3.02 3.02 0 0 0 2.12-2.14C24 15.93 24 12 24 12s0-3.93-.5-5.81zM9.54 15.57V8.43L15.82 12l-6.28 3.57z"/></svg>
|
||||
YouTube
|
||||
</div>
|
||||
<div class="platform-pill">
|
||||
<svg class="platform-icon" viewBox="0 0 24 24" aria-hidden="true"><path fill="#010101" d="M12.53.02C13.84 0 15.14.01 16.44 0c.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z"/></svg>
|
||||
TikTok
|
||||
</div>
|
||||
<div class="platform-pill">
|
||||
<svg class="platform-icon" viewBox="0 0 24 24" aria-hidden="true"><defs><radialGradient id="ig-grad" cx="30%" cy="107%" r="150%"><stop offset="0%" stop-color="#fdf497"/><stop offset="5%" stop-color="#fdf497"/><stop offset="45%" stop-color="#fd5949"/><stop offset="60%" stop-color="#d6249f"/><stop offset="90%" stop-color="#285AEB"/></radialGradient></defs><path fill="url(#ig-grad)" d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 1 0 0 12.324 6.162 6.162 0 0 0 0-12.324zM12 16a4 4 0 1 1 0-8 4 4 0 0 1 0 8zm6.406-11.845a1.44 1.44 0 1 0 0 2.881 1.44 1.44 0 0 0 0-2.881z"/></svg>
|
||||
Instagram
|
||||
</div>
|
||||
<div class="platform-pill">
|
||||
<svg class="platform-icon" viewBox="0 0 24 24" aria-hidden="true"><path fill="#E60023" d="M12 0C5.373 0 0 5.373 0 12c0 5.084 3.163 9.426 7.627 11.174-.105-.949-.2-2.405.042-3.441.218-.937 1.407-5.965 1.407-5.965s-.359-.719-.359-1.782c0-1.668.967-2.914 2.171-2.914 1.023 0 1.518.769 1.518 1.69 0 1.029-.655 2.568-.994 3.995-.283 1.194.599 2.169 1.777 2.169 2.133 0 3.772-2.249 3.772-5.495 0-2.873-2.064-4.882-5.012-4.882-3.414 0-5.418 2.561-5.418 5.207 0 1.031.397 2.138.893 2.738a.36.36 0 0 1 .083.345l-.333 1.36c-.053.22-.174.267-.402.161-1.499-.698-2.436-2.889-2.436-4.649 0-3.785 2.75-7.262 7.929-7.262 4.163 0 7.398 2.967 7.398 6.931 0 4.136-2.607 7.464-6.227 7.464-1.216 0-2.359-.632-2.75-1.378l-.748 2.853c-.271 1.043-1.002 2.35-1.492 3.146C9.57 23.812 10.763 24 12 24c6.627 0 12-5.373 12-12S18.627 0 12 0z"/></svg>
|
||||
Pinterest
|
||||
</div>
|
||||
<div class="platform-pill">
|
||||
<img class="platform-icon" src="/static/picnic-logo.svg" alt="Picnic" aria-hidden="true">
|
||||
Picnic
|
||||
</div>
|
||||
<div class="platform-pill">
|
||||
<svg class="platform-icon" viewBox="0 0 24 24" aria-hidden="true"><path fill="#0071e3" d="M12 2a10 10 0 1 0 0 20A10 10 0 0 0 12 2zm0 1.5c1.07 0 2.1.6 2.97 1.67-.37.13-.75.25-1.14.35A9.7 9.7 0 0 0 12 5.1a9.7 9.7 0 0 0-1.83.42c-.39-.1-.77-.22-1.14-.35C9.9 4.1 10.93 3.5 12 3.5zm-3.7 1.04c.46.2.93.37 1.41.51a9.5 9.5 0 0 0-.52 1.7 12.7 12.7 0 0 1-2.25-.96 8.5 8.5 0 0 1 1.36-1.25zm7.4 0c.5.37.96.79 1.36 1.25a12.7 12.7 0 0 1-2.25.96 9.5 9.5 0 0 0-.52-1.7c.48-.14.95-.31 1.41-.51zM12 6.6c.43 0 .86.03 1.27.09.2.67.32 1.38.35 2.1H10.38c.03-.72.15-1.43.35-2.1.41-.06.84-.09 1.27-.09zm-3.14.27a10.9 10.9 0 0 0-.29 1.92H5.64a8.5 8.5 0 0 1 3.22-1.92zm6.28 0a8.5 8.5 0 0 1 3.22 1.92h-2.93a10.9 10.9 0 0 0-.29-1.92zM5.17 10.3h3.2c-.04.55-.07 1.1-.07 1.65 0 .57.03 1.12.08 1.65H5.17a8.5 8.5 0 0 1 0-3.3zm4.7 0h4.26c.05.54.07 1.09.07 1.65 0 .56-.02 1.11-.07 1.65H9.87a19.3 19.3 0 0 1-.07-1.65c0-.56.02-1.11.07-1.65zm5.76 0h3.2a8.5 8.5 0 0 1 0 3.3h-3.21c.05-.53.08-1.08.08-1.65 0-.55-.03-1.1-.07-1.65zm-9.99 4.8h2.93c.08.68.18 1.33.29 1.93a8.5 8.5 0 0 1-3.22-1.92zm4.38 0h4.04a9.5 9.5 0 0 1-.35 2.1A9.5 9.5 0 0 0 12 17.4a9.5 9.5 0 0 0-1.27-.09 9.5 9.5 0 0 1-.35-2.1h-.36zm5.54 0h2.93a8.5 8.5 0 0 1-3.22 1.92c.11-.6.21-1.25.29-1.93zM8.7 16.37c.37.13.75.25 1.14.35.26.76.54 1.41.83 1.91-.94-.47-1.77-1.17-1.97-2.26zm6.6 0c-.2 1.09-1.03 1.79-1.97 2.26.29-.5.57-1.15.83-1.91.39-.1.77-.22 1.14-.35z"/></svg>
|
||||
Webseiten
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="progress-section" class="hidden">
|
||||
<div class="progress-wrap">
|
||||
<div class="progress-bar-container">
|
||||
<div class="progress-bar" id="progress-bar"></div>
|
||||
</div>
|
||||
<p id="progress-message" class="progress-message"></p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="result-section" class="hidden">
|
||||
<div id="recipe-card" class="recipe-card">
|
||||
|
||||
<img id="recipe-image" class="recipe-image hidden" src="" alt="Rezeptbild" />
|
||||
|
||||
<div class="recipe-content">
|
||||
<div class="recipe-meta-top">
|
||||
<h2 id="recipe-title"></h2>
|
||||
<p id="recipe-description" class="recipe-description"></p>
|
||||
</div>
|
||||
|
||||
<div class="meta-tags">
|
||||
<span id="recipe-servings" class="meta-tag hidden"><span id="servings-value"></span></span>
|
||||
<span id="recipe-prep" class="meta-tag hidden">⏱ <span id="prep-value"></span> Min. Vorbereitung</span>
|
||||
<span id="recipe-cook" class="meta-tag hidden">🔥 <span id="cook-value"></span> Min. Kochen</span>
|
||||
<span id="recipe-kcal" class="meta-tag hidden">⚡ <span id="kcal-value"></span> kcal<span id="kcal-source" class="kcal-source"></span></span>
|
||||
</div>
|
||||
|
||||
<div id="recipe-tags" class="tag-list"></div>
|
||||
|
||||
<div class="recipe-body">
|
||||
<div class="ingredients-section">
|
||||
<h3>Zutaten</h3>
|
||||
<ul id="ingredients-list" class="ingredients-list"></ul>
|
||||
</div>
|
||||
<div class="steps-section">
|
||||
<h3>Zubereitung</h3>
|
||||
<ol id="steps-list" class="steps-list"></ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="recipe-actions">
|
||||
<a id="source-link" href="#" target="_blank" class="btn btn-secondary">Originalrezept</a>
|
||||
<button id="import-btn" class="btn btn-primary">In Tandoor importieren</button>
|
||||
<span id="import-status" class="import-status"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="tandoor-fallback" class="hidden fallback-card">
|
||||
<h3>Tandoor JSON</h3>
|
||||
<p class="fallback-note">Kopiere dieses JSON in Tandoor unter Einstellungen → Import.</p>
|
||||
<textarea id="tandoor-json" class="json-textarea" readonly></textarea>
|
||||
<button id="copy-json-btn" class="btn btn-secondary">JSON kopieren</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="error-section" class="hidden">
|
||||
<div class="error-box">
|
||||
<div class="error-icon" aria-hidden="true">!</div>
|
||||
<p id="error-message"></p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="history-section" class="hidden">
|
||||
<h2 class="section-heading">Verlauf</h2>
|
||||
<div id="history-list"></div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<script src="/static/app.js?v=4"></script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 133 KiB |
@@ -0,0 +1,730 @@
|
||||
:root {
|
||||
--bg: #f5f5f7;
|
||||
--surface: #ffffff;
|
||||
--text: #1d1d1f;
|
||||
--text-secondary: #6e6e73;
|
||||
--text-tertiary: #86868b;
|
||||
--accent: #0071e3;
|
||||
--line: rgba(0, 0, 0, 0.08);
|
||||
--line-strong: rgba(0, 0, 0, 0.12);
|
||||
--ok: #34c759;
|
||||
--ok-text: #1a7a3c;
|
||||
--err: #ff3b30;
|
||||
--shadow-card: 0 2px 16px rgba(0, 0, 0, 0.06), 0 0 0 0.5px rgba(0, 0, 0, 0.04);
|
||||
--shadow-hover: 0 8px 40px rgba(0, 0, 0, 0.12), 0 0 0 0.5px rgba(0, 0, 0, 0.06);
|
||||
--radius-xl: 24px;
|
||||
--radius-lg: 18px;
|
||||
--radius-md: 12px;
|
||||
--radius-sm: 8px;
|
||||
--radius-pill: 999px;
|
||||
}
|
||||
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
font-family: 'Instrument Sans', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
font-size: 15px;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* ── Nav ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
.nav {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
backdrop-filter: saturate(180%) blur(20px);
|
||||
-webkit-backdrop-filter: saturate(180%) blur(20px);
|
||||
background: rgba(245, 245, 247, 0.8);
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.nav-inner {
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: 0 24px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.nav-logo {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
letter-spacing: -0.015em;
|
||||
}
|
||||
|
||||
.nav-badge {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-tertiary);
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-pill);
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
/* ── Page Shell ───────────────────────────────────────────────────────── */
|
||||
|
||||
.page-shell {
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: 0 24px 80px;
|
||||
}
|
||||
|
||||
/* ── Hero ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
.hero {
|
||||
padding: 80px 0 56px;
|
||||
text-align: center;
|
||||
animation: appear 600ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
|
||||
@keyframes appear {
|
||||
from { opacity: 0; transform: translateY(24px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.hero-headline {
|
||||
font-size: clamp(2.8rem, 6vw, 4.6rem);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.03em;
|
||||
line-height: 1.04;
|
||||
margin-bottom: 18px;
|
||||
/* Most of headline is Apple near-black; last word bleeds into warm orange */
|
||||
background: linear-gradient(
|
||||
130deg,
|
||||
#1d1d1f 0%,
|
||||
#1d1d1f 50%,
|
||||
#e8612a 75%,
|
||||
#f5a623 100%
|
||||
);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.hero-sub {
|
||||
font-size: clamp(1rem, 2.2vw, 1.15rem);
|
||||
color: var(--text-secondary);
|
||||
font-weight: 400;
|
||||
margin-bottom: 36px;
|
||||
letter-spacing: -0.005em;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ── URL Form ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.url-form {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
max-width: 700px;
|
||||
margin: 0 auto 24px;
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-card);
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.url-form input[type="url"] {
|
||||
flex: 1;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: 0.95rem;
|
||||
padding: 10px 12px;
|
||||
outline: none;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.url-form input[type="url"]::placeholder {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
#extract-btn {
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--text);
|
||||
color: #fff;
|
||||
font-family: inherit;
|
||||
font-weight: 600;
|
||||
font-size: 0.88rem;
|
||||
padding: 10px 20px;
|
||||
cursor: pointer;
|
||||
transition: background 150ms ease, transform 150ms ease, opacity 150ms ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
#extract-btn:hover {
|
||||
background: #333;
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
#extract-btn:disabled {
|
||||
opacity: 0.38;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.btn-arrow {
|
||||
display: inline-block;
|
||||
transition: transform 200ms ease;
|
||||
}
|
||||
|
||||
#extract-btn:hover .btn-arrow {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
/* ── Platform Strip ───────────────────────────────────────────────────── */
|
||||
|
||||
.platform-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
animation: appear 600ms 80ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
|
||||
.platform-label {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-tertiary);
|
||||
font-weight: 500;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.platform-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius-pill);
|
||||
padding: 5px 13px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.platform-icon {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Progress ─────────────────────────────────────────────────────────── */
|
||||
|
||||
#progress-section,
|
||||
#result-section,
|
||||
#error-section,
|
||||
#history-section {
|
||||
animation: appear 400ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
|
||||
.progress-wrap {
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-card);
|
||||
padding: 24px 28px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.progress-bar-container {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
border-radius: var(--radius-pill);
|
||||
height: 4px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
width: 0;
|
||||
background: var(--text);
|
||||
border-radius: var(--radius-pill);
|
||||
transition: width 350ms ease;
|
||||
}
|
||||
|
||||
.progress-message {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ── Recipe Card ──────────────────────────────────────────────────────── */
|
||||
|
||||
.recipe-card {
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-card);
|
||||
overflow: hidden;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.recipe-image {
|
||||
width: 100%;
|
||||
height: 320px;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.recipe-content {
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.recipe-meta-top {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.recipe-meta-top h2 {
|
||||
font-size: clamp(1.6rem, 3.5vw, 2.2rem);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.025em;
|
||||
line-height: 1.15;
|
||||
margin-bottom: 10px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.recipe-description {
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Meta chips (both .meta-tag from JS-built history cards and static) */
|
||||
|
||||
.meta-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.meta-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
background: var(--bg);
|
||||
border-radius: var(--radius-pill);
|
||||
padding: 6px 14px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.kcal-source {
|
||||
font-size: 0.72rem;
|
||||
opacity: 0.6;
|
||||
margin-left: 2px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* Tags */
|
||||
|
||||
.tag-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 7px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
background: rgba(0, 113, 227, 0.08);
|
||||
color: var(--accent);
|
||||
border-radius: var(--radius-pill);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
padding: 4px 12px;
|
||||
}
|
||||
|
||||
/* ── Recipe Body ──────────────────────────────────────────────────────── */
|
||||
|
||||
.recipe-body {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1.7fr;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.ingredients-section,
|
||||
.steps-section {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.ingredients-section {
|
||||
background: var(--bg);
|
||||
border-right: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.ingredients-section h3,
|
||||
.steps-section h3 {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.ingredients-list {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.ingredients-list li {
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
font-size: 0.9rem;
|
||||
align-items: baseline;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.ingredients-list li:last-child {
|
||||
border-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.ingredient-amount {
|
||||
font-weight: 700;
|
||||
font-size: 0.86rem;
|
||||
color: var(--text);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ingredient-note {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.steps-list {
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.steps-list li {
|
||||
margin-bottom: 14px;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.72;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* ── Actions ──────────────────────────────────────────────────────────── */
|
||||
|
||||
.recipe-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-pill);
|
||||
text-decoration: none;
|
||||
padding: 10px 20px;
|
||||
font-family: inherit;
|
||||
font-weight: 600;
|
||||
font-size: 0.88rem;
|
||||
cursor: pointer;
|
||||
transition: background 150ms ease, transform 150ms ease, border-color 150ms ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--text);
|
||||
color: #fff;
|
||||
border-color: var(--text);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #333;
|
||||
border-color: #333;
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
border-color: var(--line-strong);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #e8e8ed;
|
||||
}
|
||||
|
||||
.import-status {
|
||||
font-size: 0.84rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.import-status.success { color: var(--ok-text); }
|
||||
.import-status.error { color: var(--err); }
|
||||
|
||||
/* ── Fallback Card ────────────────────────────────────────────────────── */
|
||||
|
||||
.fallback-card {
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-card);
|
||||
padding: 24px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.fallback-card h3 {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.fallback-note {
|
||||
font-size: 0.86rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.json-textarea {
|
||||
width: 100%;
|
||||
min-height: 180px;
|
||||
resize: vertical;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-secondary);
|
||||
padding: 14px;
|
||||
font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
|
||||
font-size: 0.8rem;
|
||||
margin-bottom: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ── Error ────────────────────────────────────────────────────────────── */
|
||||
|
||||
.error-box {
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-card);
|
||||
padding: 20px 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
color: var(--err);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 59, 48, 0.1);
|
||||
color: var(--err);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 800;
|
||||
font-size: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── History ──────────────────────────────────────────────────────────── */
|
||||
|
||||
.section-heading {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-tertiary);
|
||||
margin: 36px 0 14px;
|
||||
}
|
||||
|
||||
.history-card {
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-card);
|
||||
overflow: hidden;
|
||||
margin-bottom: 10px;
|
||||
transition: box-shadow 200ms ease, transform 200ms ease;
|
||||
}
|
||||
|
||||
.history-card:hover {
|
||||
box-shadow: var(--shadow-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.history-card-header {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: flex-start;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.history-thumb {
|
||||
width: 68px;
|
||||
height: 60px;
|
||||
border-radius: var(--radius-sm);
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.history-info {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.history-title {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.history-desc {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 0.8rem;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.history-meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.history-date {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.history-imported-badge {
|
||||
background: rgba(52, 199, 89, 0.1);
|
||||
color: var(--ok-text);
|
||||
border-radius: var(--radius-pill);
|
||||
padding: 2px 8px;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.history-tags {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.history-controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.history-toggle-btn,
|
||||
.history-delete-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--line);
|
||||
background: var(--bg);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 120ms ease;
|
||||
font-size: 0.72rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.history-toggle-btn:hover {
|
||||
background: #e8e8ed;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.history-delete-btn:hover {
|
||||
background: rgba(255, 59, 48, 0.07);
|
||||
color: var(--err);
|
||||
border-color: rgba(255, 59, 48, 0.2);
|
||||
}
|
||||
|
||||
.history-card-body {
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
/* ── Utility ──────────────────────────────────────────────────────────── */
|
||||
|
||||
.hidden { display: none !important; }
|
||||
|
||||
/* ── Responsive ───────────────────────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.hero {
|
||||
padding: 52px 0 40px;
|
||||
}
|
||||
|
||||
.url-form {
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
#extract-btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.recipe-content {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.recipe-body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.ingredients-section {
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.recipe-image {
|
||||
height: 220px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,698 @@
|
||||
:root {
|
||||
--bg: #0d0c0b;
|
||||
--bg-card: #131110;
|
||||
--bg-elevated: #1a1714;
|
||||
--surface: #1e1b17;
|
||||
--text: #f0e8db;
|
||||
--muted: #6e6153;
|
||||
--muted-light: #8a7a6b;
|
||||
--accent: #c97a3a;
|
||||
--accent-warm: #e8a065;
|
||||
--line: #252118;
|
||||
--line-soft: #1c1a16;
|
||||
--ok: #4d9468;
|
||||
--err: #b85454;
|
||||
--shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
|
||||
--shadow-lg: 0 20px 60px rgba(0, 0, 0, 0.8);
|
||||
--radius-xl: 20px;
|
||||
--radius-lg: 14px;
|
||||
--radius-md: 8px;
|
||||
--radius-sm: 4px;
|
||||
}
|
||||
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Grain texture overlay */
|
||||
body::after {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
opacity: 0.035;
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 512 512' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.75' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||
background-size: 256px 256px;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
font-family: 'Syne', 'Segoe UI', sans-serif;
|
||||
font-size: 15px;
|
||||
color: var(--text);
|
||||
background-color: var(--bg);
|
||||
padding: 32px 16px 80px;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.bg-orb {
|
||||
position: fixed;
|
||||
z-index: 0;
|
||||
border-radius: 50%;
|
||||
filter: blur(100px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.orb-a {
|
||||
width: 500px;
|
||||
height: 500px;
|
||||
background: radial-gradient(circle, rgba(160, 75, 20, 0.18), transparent 70%);
|
||||
top: -150px;
|
||||
left: -150px;
|
||||
}
|
||||
|
||||
.orb-b {
|
||||
width: 600px;
|
||||
height: 600px;
|
||||
background: radial-gradient(circle, rgba(120, 55, 15, 0.12), transparent 70%);
|
||||
right: -200px;
|
||||
bottom: -100px;
|
||||
}
|
||||
|
||||
.page-shell {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* ── Hero ───────────────────────────────────────────────────────────── */
|
||||
|
||||
.hero {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: clamp(28px, 5vw, 52px);
|
||||
margin-bottom: 20px;
|
||||
animation: fade-up 500ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Amber gradient top edge */
|
||||
.hero::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent 0%, rgba(201, 122, 58, 0.5) 40%, rgba(232, 160, 101, 0.5) 60%, transparent 100%);
|
||||
}
|
||||
|
||||
@keyframes fade-up {
|
||||
from { opacity: 0; transform: translateY(18px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.eyebrow::before {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 22px;
|
||||
height: 1px;
|
||||
background: var(--accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-family: 'Cormorant Garamond', Georgia, serif;
|
||||
font-size: clamp(2.4rem, 5.5vw, 4rem);
|
||||
font-weight: 600;
|
||||
font-style: italic;
|
||||
line-height: 1.05;
|
||||
color: var(--text);
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.hero-copy {
|
||||
color: var(--muted-light);
|
||||
font-size: 0.86rem;
|
||||
letter-spacing: 0.01em;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 28px;
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
/* ── URL Form ───────────────────────────────────────────────────────── */
|
||||
|
||||
.url-form {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.url-form input[type="url"] {
|
||||
width: 100%;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text);
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: 0.88rem;
|
||||
padding: 0.85rem 1.1rem;
|
||||
transition: border-color 150ms ease, box-shadow 150ms ease;
|
||||
}
|
||||
|
||||
.url-form input[type="url"]::placeholder {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.url-form input[type="url"]:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(201, 122, 58, 0.12);
|
||||
}
|
||||
|
||||
#extract-btn {
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--accent);
|
||||
color: #0d0c0b;
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-weight: 700;
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
padding: 0 1.4rem;
|
||||
cursor: pointer;
|
||||
transition: background 120ms ease, transform 120ms ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#extract-btn:hover {
|
||||
background: var(--accent-warm);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
#extract-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* ── Platform Pills ─────────────────────────────────────────────────── */
|
||||
|
||||
.platform-strip {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.platform-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 5px 10px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
transition: border-color 120ms ease, color 120ms ease;
|
||||
}
|
||||
|
||||
.platform-pill:hover {
|
||||
border-color: var(--muted);
|
||||
color: var(--muted-light);
|
||||
}
|
||||
|
||||
.platform-icon {
|
||||
color: var(--accent);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
/* ── Progress ───────────────────────────────────────────────────────── */
|
||||
|
||||
#progress-section,
|
||||
#result-section,
|
||||
#error-section,
|
||||
#history-section {
|
||||
animation: fade-up 380ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
|
||||
.progress-bar-container {
|
||||
background: var(--surface);
|
||||
border-radius: 999px;
|
||||
height: 2px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
width: 0;
|
||||
background: linear-gradient(90deg, var(--accent), var(--accent-warm), var(--accent));
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.8s linear infinite;
|
||||
transition: width 350ms ease;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 0% 0%; }
|
||||
100% { background-position: 200% 0%; }
|
||||
}
|
||||
|
||||
.progress-message {
|
||||
color: var(--muted-light);
|
||||
font-size: 0.8rem;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* ── Cards ──────────────────────────────────────────────────────────── */
|
||||
|
||||
.recipe-card,
|
||||
#tandoor-fallback,
|
||||
.history-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Recipe Header ──────────────────────────────────────────────────── */
|
||||
|
||||
.recipe-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.recipe-image {
|
||||
width: 260px;
|
||||
height: 200px;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.recipe-meta {
|
||||
flex: 1;
|
||||
min-width: 240px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.recipe-meta h2 {
|
||||
font-family: 'Cormorant Garamond', Georgia, serif;
|
||||
font-size: 1.9rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.15;
|
||||
margin-bottom: 8px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.recipe-description {
|
||||
color: var(--muted-light);
|
||||
font-size: 0.86rem;
|
||||
line-height: 1.65;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.meta-tags,
|
||||
.tag-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.meta-tag {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
color: var(--muted-light);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 4px 9px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.kcal-source {
|
||||
font-size: 0.68rem;
|
||||
opacity: 0.65;
|
||||
font-weight: 400;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.tag {
|
||||
background: rgba(201, 122, 58, 0.12);
|
||||
color: var(--accent-warm);
|
||||
border: 1px solid rgba(201, 122, 58, 0.2);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
padding: 3px 8px;
|
||||
}
|
||||
|
||||
/* ── Recipe Body ────────────────────────────────────────────────────── */
|
||||
|
||||
.recipe-body {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1.7fr;
|
||||
}
|
||||
|
||||
.ingredients-section,
|
||||
.steps-section {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.ingredients-section {
|
||||
border-right: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.ingredients-section h3,
|
||||
.steps-section h3,
|
||||
.section-heading {
|
||||
font-size: 0.64rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.ingredients-list {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.ingredients-list li {
|
||||
padding: 7px 0;
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
font-size: 0.89rem;
|
||||
align-items: baseline;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.ingredients-list li:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.ingredient-amount {
|
||||
color: var(--accent-warm);
|
||||
font-weight: 700;
|
||||
font-size: 0.84rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ingredient-note {
|
||||
color: var(--muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.steps-list {
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.steps-list li {
|
||||
margin-bottom: 12px;
|
||||
font-size: 0.89rem;
|
||||
line-height: 1.7;
|
||||
color: #cec4b8;
|
||||
}
|
||||
|
||||
/* ── Actions ────────────────────────────────────────────────────────── */
|
||||
|
||||
.recipe-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border-top: 1px solid var(--line);
|
||||
padding: 14px 24px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-md);
|
||||
text-decoration: none;
|
||||
padding: 8px 14px;
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-weight: 700;
|
||||
font-size: 0.74rem;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
transition: background 120ms ease, border-color 120ms ease, color 120ms ease, transform 120ms ease;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: #0d0c0b;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--accent-warm);
|
||||
border-color: var(--accent-warm);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
color: var(--muted-light);
|
||||
border-color: var(--line);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--muted);
|
||||
}
|
||||
|
||||
.import-status {
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.import-status.success { color: var(--ok); }
|
||||
.import-status.error { color: var(--err); }
|
||||
|
||||
/* ── Tandoor Fallback ───────────────────────────────────────────────── */
|
||||
|
||||
#tandoor-fallback {
|
||||
margin-top: 12px;
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
#tandoor-fallback h3 {
|
||||
font-size: 0.64rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.fallback-note {
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
margin-bottom: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.json-textarea {
|
||||
width: 100%;
|
||||
min-height: 180px;
|
||||
resize: vertical;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--muted-light);
|
||||
padding: 12px;
|
||||
font-family: ui-monospace, 'JetBrains Mono', Menlo, Consolas, monospace;
|
||||
font-size: 0.8rem;
|
||||
margin-bottom: 10px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ── Error ──────────────────────────────────────────────────────────── */
|
||||
|
||||
.error-box {
|
||||
border: 1px solid rgba(184, 84, 84, 0.25);
|
||||
background: rgba(184, 84, 84, 0.07);
|
||||
color: var(--err);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 14px 18px;
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ── History ────────────────────────────────────────────────────────── */
|
||||
|
||||
.section-heading {
|
||||
margin: 28px 0 12px;
|
||||
}
|
||||
|
||||
.history-card {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.history-card-header {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: flex-start;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.history-thumb {
|
||||
width: 72px;
|
||||
height: 64px;
|
||||
border-radius: var(--radius-sm);
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.history-info {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.history-title {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 4px;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.history-desc {
|
||||
color: var(--muted-light);
|
||||
font-size: 0.82rem;
|
||||
margin-bottom: 6px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.history-meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.history-date {
|
||||
color: var(--muted);
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.history-imported-badge {
|
||||
background: rgba(77, 148, 104, 0.12);
|
||||
color: var(--ok);
|
||||
border: 1px solid rgba(77, 148, 104, 0.2);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 2px 7px;
|
||||
font-size: 0.64rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.history-controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.history-toggle-btn,
|
||||
.history-delete-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
transition: all 120ms ease;
|
||||
font-size: 0.76rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.history-toggle-btn:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--muted);
|
||||
}
|
||||
|
||||
.history-delete-btn:hover {
|
||||
color: var(--err);
|
||||
border-color: rgba(184, 84, 84, 0.35);
|
||||
background: rgba(184, 84, 84, 0.07);
|
||||
}
|
||||
|
||||
.history-card-body {
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
/* ── Utility ────────────────────────────────────────────────────────── */
|
||||
|
||||
.hidden { display: none !important; }
|
||||
|
||||
/* ── Responsive ─────────────────────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 780px) {
|
||||
.url-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
#extract-btn {
|
||||
min-height: 46px;
|
||||
}
|
||||
|
||||
.recipe-body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.ingredients-section {
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.recipe-image {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.recipe-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Rezept0r</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,600;1,400;1,600&family=Syne:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/static/v1.css?v=2">
|
||||
</head>
|
||||
<body>
|
||||
<div class="bg-orb orb-a"></div>
|
||||
<div class="bg-orb orb-b"></div>
|
||||
|
||||
<main class="page-shell">
|
||||
<section class="hero">
|
||||
<p class="eyebrow">Rezept0r v1</p>
|
||||
<h1>Rezepte aus Social, Video<br>und Web extrahieren</h1>
|
||||
<p class="hero-copy">
|
||||
URL einfügen, analysieren lassen und direkt nach Tandoor importieren.
|
||||
</p>
|
||||
|
||||
<section class="input-section">
|
||||
<div class="url-form">
|
||||
<input
|
||||
type="url"
|
||||
id="url-input"
|
||||
placeholder="https://example.com/rezept oder YouTube/TikTok URL..."
|
||||
autocomplete="off"
|
||||
/>
|
||||
<button id="extract-btn" type="button">Extrahieren</button>
|
||||
</div>
|
||||
|
||||
<div class="platform-strip" aria-label="Unterstützte Plattformen">
|
||||
<div class="platform-pill"><span class="platform-icon">▶</span><span>YouTube</span></div>
|
||||
<div class="platform-pill"><span class="platform-icon">♪</span><span>TikTok</span></div>
|
||||
<div class="platform-pill"><span class="platform-icon">◎</span><span>Instagram</span></div>
|
||||
<div class="platform-pill"><span class="platform-icon">P</span><span>Pinterest</span></div>
|
||||
<div class="platform-pill"><span class="platform-icon">⛶</span><span>Webseiten</span></div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section id="progress-section" class="hidden">
|
||||
<div class="progress-bar-container">
|
||||
<div class="progress-bar" id="progress-bar"></div>
|
||||
</div>
|
||||
<p id="progress-message" class="progress-message"></p>
|
||||
</section>
|
||||
|
||||
<section id="result-section" class="hidden">
|
||||
<div id="recipe-card" class="recipe-card">
|
||||
<div class="recipe-header">
|
||||
<img id="recipe-image" class="recipe-image hidden" src="" alt="Rezeptbild" />
|
||||
<div class="recipe-meta">
|
||||
<h2 id="recipe-title"></h2>
|
||||
<p id="recipe-description" class="recipe-description"></p>
|
||||
<div class="meta-tags">
|
||||
<span id="recipe-servings" class="meta-tag hidden">
|
||||
<span id="servings-value"></span>
|
||||
</span>
|
||||
<span id="recipe-prep" class="meta-tag hidden">
|
||||
Vorbereitung: <span id="prep-value"></span> Min.
|
||||
</span>
|
||||
<span id="recipe-cook" class="meta-tag hidden">
|
||||
Kochen: <span id="cook-value"></span> Min.
|
||||
</span>
|
||||
<span id="recipe-kcal" class="meta-tag hidden">
|
||||
<span id="kcal-value"></span> kcal
|
||||
<span id="kcal-source" class="kcal-source"></span>
|
||||
</span>
|
||||
</div>
|
||||
<div id="recipe-tags" class="tag-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="recipe-body">
|
||||
<div class="ingredients-section">
|
||||
<h3>Zutaten</h3>
|
||||
<ul id="ingredients-list" class="ingredients-list"></ul>
|
||||
</div>
|
||||
|
||||
<div class="steps-section">
|
||||
<h3>Zubereitung</h3>
|
||||
<ol id="steps-list" class="steps-list"></ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="recipe-actions">
|
||||
<a id="source-link" href="#" target="_blank" class="btn btn-secondary">Originalrezept</a>
|
||||
<button id="import-btn" class="btn btn-primary">In Tandoor importieren</button>
|
||||
<span id="import-status" class="import-status"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="tandoor-fallback" class="hidden">
|
||||
<h3>Tandoor JSON (manueller Import)</h3>
|
||||
<p class="fallback-note">Kopiere dieses JSON in Tandoor unter Einstellungen → Import.</p>
|
||||
<textarea id="tandoor-json" class="json-textarea" readonly></textarea>
|
||||
<button id="copy-json-btn" class="btn btn-secondary">JSON kopieren</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="error-section" class="hidden">
|
||||
<div class="error-box">
|
||||
<p id="error-message"></p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="history-section" class="hidden">
|
||||
<h2 class="section-heading">Verlauf</h2>
|
||||
<div id="history-list"></div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="/static/app.js?v=4"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,14 @@
|
||||
services:
|
||||
rezeptor:
|
||||
build: .
|
||||
image: rezeptor:latest
|
||||
container_name: rezeptor
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- "88:8000"
|
||||
volumes:
|
||||
- ./tmp:/app/tmp
|
||||
mem_limit: 512m
|
||||
memswap_limit: 512m # No swap – protect SD card
|
||||
BIN
Binary file not shown.
@@ -0,0 +1,74 @@
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse
|
||||
from app.config import get_settings
|
||||
from app.api import extract, tandoor, history
|
||||
from app.services.history_service import init_db
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
os.makedirs(settings.tmp_dir, exist_ok=True)
|
||||
for f in Path(settings.tmp_dir).iterdir():
|
||||
if f.is_file():
|
||||
f.unlink(missing_ok=True)
|
||||
init_db()
|
||||
yield
|
||||
|
||||
app = FastAPI(title="Rezept0r", version="1.0.0", lifespan=lifespan)
|
||||
|
||||
app.include_router(extract.router, prefix="/api")
|
||||
app.include_router(tandoor.router, prefix="/api")
|
||||
app.include_router(history.router, prefix="/api")
|
||||
|
||||
static_dir = Path(__file__).parent / "app" / "static"
|
||||
app.mount("/static", StaticFiles(directory=static_dir), name="static")
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return FileResponse(static_dir / "index.html")
|
||||
|
||||
|
||||
@app.get("/v1")
|
||||
async def v1():
|
||||
return FileResponse(static_dir / "v1.html")
|
||||
|
||||
|
||||
@app.get("/v2")
|
||||
async def v2():
|
||||
return FileResponse(static_dir / "v2.html")
|
||||
|
||||
|
||||
@app.get("/v3")
|
||||
async def v3():
|
||||
return FileResponse(static_dir / "v3.html")
|
||||
|
||||
|
||||
@app.get("/v4")
|
||||
async def v4():
|
||||
return FileResponse(static_dir / "v4.html")
|
||||
|
||||
|
||||
@app.get("/v5")
|
||||
async def v5():
|
||||
return FileResponse(static_dir / "v5.html")
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health():
|
||||
s = get_settings()
|
||||
return {
|
||||
"status": "ok",
|
||||
"tandoor_configured": bool(s.tandoor_api_token and s.tandoor_base_url),
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run("main:app", host=settings.app_host, port=settings.app_port, workers=1)
|
||||
@@ -0,0 +1,12 @@
|
||||
fastapi>=0.115
|
||||
uvicorn>=0.30
|
||||
pydantic>=2.9
|
||||
pydantic-settings>=2.5
|
||||
httpx>=0.27
|
||||
python-multipart>=0.0.18
|
||||
recipe-scrapers>=15.0
|
||||
beautifulsoup4>=4.12
|
||||
lxml>=5.2
|
||||
openai>=1.50
|
||||
python-dotenv>=1.0
|
||||
aiofiles>=23.0
|
||||
Reference in New Issue
Block a user