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