commit e832c4e7ce592a5f264f3e8627cc4b85f2ae32dc Author: Andre <200930841+Andre-Bzk@users.noreply.github.com> Date: Sun Mar 29 20:52:20 2026 +0200 Prototyp diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..93ef6c8 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..966c24c --- /dev/null +++ b/.gitignore @@ -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/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fa300f5 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/extract.py b/app/api/extract.py new file mode 100644 index 0000000..7878e3c --- /dev/null +++ b/app/api/extract.py @@ -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", + }, + ) diff --git a/app/api/history.py b/app/api/history.py new file mode 100644 index 0000000..bfeb4d0 --- /dev/null +++ b/app/api/history.py @@ -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)) diff --git a/app/api/tandoor.py b/app/api/tandoor.py new file mode 100644 index 0000000..6816743 --- /dev/null +++ b/app/api/tandoor.py @@ -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}") diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..64b2069 --- /dev/null +++ b/app/config.py @@ -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() diff --git a/app/extractors/__init__.py b/app/extractors/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/extractors/base.py b/app/extractors/base.py new file mode 100644 index 0000000..13d4992 --- /dev/null +++ b/app/extractors/base.py @@ -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)) diff --git a/app/extractors/video.py b/app/extractors/video.py new file mode 100644 index 0000000..3f05f57 --- /dev/null +++ b/app/extractors/video.py @@ -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." + ) diff --git a/app/extractors/website.py b/app/extractors/website.py new file mode 100644 index 0000000..9c541a8 --- /dev/null +++ b/app/extractors/website.py @@ -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 + + diff --git a/app/static/picnic-logo.svg b/app/static/picnic-logo.svg new file mode 100644 index 0000000..5b93b56 --- /dev/null +++ b/app/static/picnic-logo.svg @@ -0,0 +1,13 @@ + + + + img-logo@2x + Created with Sketch. + + + + + + diff --git a/app/static/style.css b/app/static/style.css new file mode 100644 index 0000000..dffc0f8 --- /dev/null +++ b/app/static/style.css @@ -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; + } +} diff --git a/app/static/v1.css b/app/static/v1.css new file mode 100644 index 0000000..b745be5 --- /dev/null +++ b/app/static/v1.css @@ -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; + } +} diff --git a/app/static/v1.html b/app/static/v1.html new file mode 100644 index 0000000..6ed86db --- /dev/null +++ b/app/static/v1.html @@ -0,0 +1,119 @@ + + + + + + Rezept0r + + + + + + +
+
+ +
+
+

Rezept0r v1

+

Rezepte aus Social, Video
und Web extrahieren

+

+ URL einfügen, analysieren lassen und direkt nach Tandoor importieren. +

+ +
+
+ + +
+ +
+
▶YouTube
+
♪TikTok
+
◎Instagram
+
PPinterest
+
⛶Webseiten
+
+
+
+ + + + + + + + +
+ + + + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9278481 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/history.db b/history.db new file mode 100644 index 0000000..11b9df3 Binary files /dev/null and b/history.db differ diff --git a/main.py b/main.py new file mode 100644 index 0000000..6495ddc --- /dev/null +++ b/main.py @@ -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) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..809e62b --- /dev/null +++ b/requirements.txt @@ -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