From e832c4e7ce592a5f264f3e8627cc4b85f2ae32dc Mon Sep 17 00:00:00 2001 From: Andre <200930841+Andre-Bzk@users.noreply.github.com> Date: Sun, 29 Mar 2026 20:52:20 +0200 Subject: [PATCH] Prototyp --- .env.example | 7 + .gitignore | 20 + Dockerfile | 30 ++ app/__init__.py | 0 app/api/__init__.py | 0 app/api/extract.py | 85 ++++ app/api/history.py | 22 + app/api/tandoor.py | 21 + app/config.py | 20 + app/extractors/__init__.py | 0 app/extractors/base.py | 22 + app/extractors/video.py | 206 +++++++++ app/extractors/website.py | 301 +++++++++++++ app/models.py | 74 ++++ app/services/__init__.py | 0 app/services/history_service.py | 83 ++++ app/services/nutrition.py | 186 ++++++++ app/services/openai_service.py | 183 ++++++++ app/services/tandoor_service.py | 146 +++++++ app/static/app.js | 523 +++++++++++++++++++++++ app/static/index.html | 141 ++++++ app/static/picnic-logo.svg | 13 + app/static/style.css | 730 ++++++++++++++++++++++++++++++++ app/static/v1.css | 698 ++++++++++++++++++++++++++++++ app/static/v1.html | 119 ++++++ docker-compose.yml | 14 + history.db | Bin 0 -> 53248 bytes main.py | 74 ++++ requirements.txt | 12 + 29 files changed, 3730 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 app/__init__.py create mode 100644 app/api/__init__.py create mode 100644 app/api/extract.py create mode 100644 app/api/history.py create mode 100644 app/api/tandoor.py create mode 100644 app/config.py create mode 100644 app/extractors/__init__.py create mode 100644 app/extractors/base.py create mode 100644 app/extractors/video.py create mode 100644 app/extractors/website.py create mode 100644 app/models.py create mode 100644 app/services/__init__.py create mode 100644 app/services/history_service.py create mode 100644 app/services/nutrition.py create mode 100644 app/services/openai_service.py create mode 100644 app/services/tandoor_service.py create mode 100644 app/static/app.js create mode 100644 app/static/index.html create mode 100644 app/static/picnic-logo.svg create mode 100644 app/static/style.css create mode 100644 app/static/v1.css create mode 100644 app/static/v1.html create mode 100644 docker-compose.yml create mode 100644 history.db create mode 100644 main.py create mode 100644 requirements.txt 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 0000000000000000000000000000000000000000..11b9df3f2e07fb4142fec7ec7c0ea373a1fc3e4d GIT binary patch literal 53248 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.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