diff options
| -rw-r--r-- | main.py | 4 | ||||
| -rw-r--r-- | models.py | 1 | ||||
| -rw-r--r-- | requirements.txt | 2 | ||||
| -rw-r--r-- | routers/grocery.py | 22 | ||||
| -rw-r--r-- | routers/menus.py | 68 | ||||
| -rw-r--r-- | routers/recipes.py | 144 | ||||
| -rw-r--r-- | schemas.py | 3 | ||||
| -rw-r--r-- | services/ai_service.py | 71 | ||||
| -rw-r--r-- | static/app.js | 129 | ||||
| -rw-r--r-- | static/index.html | 12 | ||||
| -rw-r--r-- | static/logo.png | bin | 0 -> 1626778 bytes | |||
| -rw-r--r-- | static/recipe.html | 68 | ||||
| -rw-r--r-- | static/style.css | 74 |
13 files changed, 545 insertions, 53 deletions
@@ -17,6 +17,10 @@ async def lifespan(app: FastAPI): if 'notes' not in [row[1] for row in result]: conn.execute(text("ALTER TABLE menu_plans ADD COLUMN notes TEXT")) conn.commit() + result = conn.execute(text("PRAGMA table_info(recipes)")) + if 'description' not in [row[1] for row in result]: + conn.execute(text("ALTER TABLE recipes ADD COLUMN description TEXT")) + conn.commit() yield # Shutdown pass @@ -44,6 +44,7 @@ class Recipe(Base): id = Column(Integer, primary_key=True, autoincrement=True) name = Column(String, nullable=False) + description = Column(Text, nullable=True) meal_type = Column(String, nullable=False) ingredients = Column(Text, nullable=False) instructions = Column(Text, nullable=True) diff --git a/requirements.txt b/requirements.txt index c32f824..2162fcf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,5 @@ sqlalchemy==2.0.36 pydantic-settings==2.5.2 python-dotenv==1.0.1 ollama==0.3.3 +httpx==0.27.2 +beautifulsoup4==4.12.3 diff --git a/routers/grocery.py b/routers/grocery.py index cbc66bb..a74776d 100644 --- a/routers/grocery.py +++ b/routers/grocery.py @@ -211,6 +211,28 @@ async def mark_purchased(id: int, db: Session = Depends(get_db)): return GroceryListRead.from_orm(grocery_list) +@router.delete("/{id}/items") +async def delete_grocery_item(id: int, request: dict = Body(...), db: Session = Depends(get_db)): + """Remove a single item from a grocery list by name.""" + grocery_list = db.query(GroceryList).filter(GroceryList.id == id).first() + if not grocery_list: + raise HTTPException(status_code=404, detail="Grocery list not found") + + item_name = request.get("name") + if not item_name: + raise HTTPException(status_code=400, detail="Item name is required") + + try: + items = json.loads(grocery_list.items) + except json.JSONDecodeError: + items = [] + + items = [i for i in items if not (isinstance(i, dict) and i.get("name") == item_name)] + grocery_list.items = json.dumps(items) + db.commit() + return {"status": "ok"} + + @ai_router.post("/suggest-recipe") async def suggest_recipe_endpoint(db: Session = Depends(get_db)): """Suggest a recipe based on current pantry.""" diff --git a/routers/menus.py b/routers/menus.py index 9ec1d1d..db56d73 100644 --- a/routers/menus.py +++ b/routers/menus.py @@ -4,7 +4,7 @@ from datetime import datetime, timedelta, date import json from database import get_db -from schemas import MenuPlanRead +from schemas import MenuPlanRead, RecipeRead from models import MenuPlan, Recipe from services import pantry_service, ai_service from config import settings @@ -78,6 +78,7 @@ async def generate_menu(request: dict = None, db: Session = Depends(get_db)): estimated_time_minutes=recipe_data.get("time_minutes", 30), servings=recipe_data.get("serves", 2), source="ai", + description=recipe_data.get("description", ""), ) db.add(new_recipe) db.flush() @@ -234,6 +235,71 @@ async def swap_recipe_in_menu(recipe_id: int, request: dict = Body(default={}), } +@router.post("/current/recipes") +async def add_recipe_to_menu(request: dict = Body(default={}), db: Session = Depends(get_db)): + """Generate and add a single recipe by description.""" + description = (request.get("description") or "").strip() + if not description: + raise HTTPException(status_code=400, detail="Description is required") + + monday = _current_monday() + menu_plan = db.query(MenuPlan).filter(MenuPlan.week_start == monday).first() + + # Get existing recipe names to avoid duplicates + existing_names = [] + if menu_plan: + try: + plan_ids = json.loads(menu_plan.plan) + existing = db.query(Recipe).filter(Recipe.id.in_(plan_ids)).all() if plan_ids else [] + existing_names = [r.name for r in existing] + except Exception: + pass + + pantry_context = pantry_service.build_pantry_context(db) + + try: + result = await ai_service.generate_single_recipe(description, existing_names, pantry_context) + except ValueError as e: + raise HTTPException(status_code=503, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=503, detail=f"Ollama error: {str(e)}") + + recipe_data = result.get("recipe", {}) + if not recipe_data.get("name"): + raise HTTPException(status_code=503, detail="AI returned invalid recipe") + + new_recipe = Recipe( + name=recipe_data["name"], + description=recipe_data.get("description", ""), + meal_type=recipe_data.get("meal_type", "dinner"), + ingredients=json.dumps(recipe_data.get("ingredients", [])), + instructions=recipe_data.get("instructions", ""), + estimated_time_minutes=recipe_data.get("time_minutes"), + servings=recipe_data.get("serves", 2), + source="ai", + ) + db.add(new_recipe) + db.flush() + + if menu_plan: + try: + plan_ids = json.loads(menu_plan.plan) + except Exception: + plan_ids = [] + plan_ids.append(new_recipe.id) + menu_plan.plan = json.dumps(plan_ids) + else: + menu_plan = MenuPlan( + week_start=monday, + plan=json.dumps([new_recipe.id]), + ) + db.add(menu_plan) + + db.commit() + db.refresh(new_recipe) + return {"recipe": RecipeRead.from_orm(new_recipe)} + + @router.delete("/{id}") async def delete_menu(id: int, db: Session = Depends(get_db)): """Delete a menu plan.""" diff --git a/routers/recipes.py b/routers/recipes.py index f215062..000f945 100644 --- a/routers/recipes.py +++ b/routers/recipes.py @@ -1,14 +1,24 @@ -from fastapi import APIRouter, Depends, HTTPException, Query, status +from fastapi import APIRouter, Depends, HTTPException, Query, status, Body from sqlalchemy.orm import Session from typing import List +import httpx +import json +from datetime import datetime, timedelta +from bs4 import BeautifulSoup from database import get_db -from models import Recipe +from models import Recipe, MenuPlan from schemas import RecipeCreate, RecipeRead, RecipeUpdate +from services import ai_service router = APIRouter(prefix="/api/recipes", tags=["recipes"]) +def _current_monday(): + today = datetime.utcnow().date() + return today - timedelta(days=today.weekday()) + + @router.get("", response_model=List[RecipeRead]) def list_recipes(meal_type: str = Query(None), db: Session = Depends(get_db)): """List all recipes. Optional ?meal_type=dinner.""" @@ -48,3 +58,133 @@ def delete_recipe(id: int, db: Session = Depends(get_db)): db.delete(recipe) db.commit() + + +@router.post("/import") +async def import_recipe(request: dict = Body(default={}), db: Session = Depends(get_db)): + """Import a recipe from a URL.""" + url = (request.get("url") or "").strip() + if not url: + raise HTTPException(status_code=400, detail="URL is required") + + # Fetch the page + try: + async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client: + resp = await client.get(url, headers={"User-Agent": "Mozilla/5.0"}) + resp.raise_for_status() + except Exception as e: + raise HTTPException(status_code=400, detail=f"Could not fetch URL: {e}") + + soup = BeautifulSoup(resp.text, "html.parser") + recipe_data = None + + # Try JSON-LD structured data first + for script in soup.find_all("script", type="application/ld+json"): + try: + ld = json.loads(script.string or "") + # Handle @graph wrapper + if isinstance(ld, dict) and ld.get("@graph"): + ld = next((x for x in ld["@graph"] if x.get("@type") == "Recipe"), None) + if isinstance(ld, list): + ld = next((x for x in ld if x.get("@type") == "Recipe"), None) + if isinstance(ld, dict) and ld.get("@type") == "Recipe": + recipe_data = ld + break + except Exception: + continue + + if recipe_data: + # Map JSON-LD Recipe schema to our format + def parse_duration(iso): + if not iso: + return None + import re + m = re.search(r'(\d+)M', iso) + h = re.search(r'(\d+)H', iso) + return (int(h.group(1)) * 60 if h else 0) + (int(m.group(1)) if m else 0) + + raw_ingredients = recipe_data.get("recipeIngredient") or [] + ingredients = [{"name": ing, "quantity": 1.0, "unit": ""} for ing in raw_ingredients] + + raw_instructions = recipe_data.get("recipeInstructions") or [] + if isinstance(raw_instructions, list): + steps = [] + for step in raw_instructions: + if isinstance(step, dict): + steps.append(step.get("text", "")) + else: + steps.append(str(step)) + instructions = " ".join(steps) + else: + instructions = str(raw_instructions) + + name = recipe_data.get("name") or "Imported Recipe" + time_minutes = parse_duration(recipe_data.get("totalTime") or recipe_data.get("cookTime")) + serves_raw = recipe_data.get("recipeYield") + if isinstance(serves_raw, list): + serves_raw = serves_raw[0] + try: + serves = int(str(serves_raw).split()[0]) if serves_raw else 2 + except Exception: + serves = 2 + + recipe_category = str(recipe_data.get("recipeCategory") or "").lower() + if "breakfast" in recipe_category or "brunch" in recipe_category: + meal_type = "breakfast" + elif "lunch" in recipe_category or "salad" in recipe_category or "sandwich" in recipe_category: + meal_type = "lunch" + else: + meal_type = "dinner" + + parsed = { + "name": name, + "meal_type": meal_type, + "ingredients": ingredients, + "instructions": instructions, + "time_minutes": time_minutes, + "serves": serves, + } + else: + # Fall back to AI parsing of visible text + text = soup.get_text(separator=" ", strip=True) + try: + result = await ai_service.parse_recipe_from_text(text) + except Exception as e: + raise HTTPException(status_code=503, detail=f"AI parsing failed: {e}") + parsed = result.get("recipe", {}) + + # Save recipe to DB + db_recipe = Recipe( + name=parsed.get("name", "Imported Recipe"), + meal_type=parsed.get("meal_type", "dinner"), + ingredients=json.dumps(parsed.get("ingredients", [])), + instructions=parsed.get("instructions", ""), + estimated_time_minutes=parsed.get("time_minutes"), + servings=parsed.get("serves", 2), + source="import", + description=parsed.get("description", ""), + ) + db.add(db_recipe) + db.commit() + db.refresh(db_recipe) + + # Add to current week's menu plan + monday = _current_monday() + menu_plan = db.query(MenuPlan).filter(MenuPlan.week_start == monday).first() + if menu_plan: + try: + plan_ids = json.loads(menu_plan.plan) + except Exception: + plan_ids = [] + plan_ids.append(db_recipe.id) + menu_plan.plan = json.dumps(plan_ids) + db.commit() + else: + menu_plan = MenuPlan( + week_start=monday, + plan=json.dumps([db_recipe.id]), + ) + db.add(menu_plan) + db.commit() + + return {"recipe": RecipeRead.from_orm(db_recipe)} @@ -95,6 +95,7 @@ class RecipeCreate(BaseModel): estimated_time_minutes: Optional[int] = None servings: int = 2 source: str = "ai" + description: Optional[str] = None class RecipeRead(BaseModel): @@ -107,6 +108,7 @@ class RecipeRead(BaseModel): servings: int source: str created_at: datetime + description: Optional[str] = None model_config = ConfigDict(from_attributes=True) @@ -119,6 +121,7 @@ class RecipeUpdate(BaseModel): estimated_time_minutes: Optional[int] = None servings: Optional[int] = None source: Optional[str] = None + description: Optional[str] = None # MenuPlan Schemas diff --git a/services/ai_service.py b/services/ai_service.py index f128b4a..4b54c15 100644 --- a/services/ai_service.py +++ b/services/ai_service.py @@ -50,7 +50,8 @@ Respond ONLY with this JSON structure: "name": "...", "meal_type": "breakfast", "ingredients": [{{"name": "...", "quantity": 1.0, "unit": "cup"}}], - "instructions": "Step 1... Step 2...", + "description": "1-2 sentence description of the dish's flavor and appeal", + "instructions": "1. ... 2. ... (4-8 numbered steps, 1-2 sentences each)", "time_minutes": 20, "serves": 2 }} @@ -204,7 +205,8 @@ Respond ONLY with this JSON: "name": "...", "meal_type": "{meal_type}", "ingredients": [{{"name": "...", "quantity": 1.0, "unit": "cup"}}], - "instructions": "Step 1... Step 2...", + "description": "1-2 sentence description of the dish's flavor and appeal", + "instructions": "1. ... 2. ... (4-8 numbered steps, 1-2 sentences each)", "time_minutes": 30, "serves": 2 }} @@ -262,3 +264,68 @@ Be conversational, helpful, and concise. Reference specific ingredients and reci loop = asyncio.get_running_loop() return await loop.run_in_executor(None, lambda: _chat_sync(messages, json_format=False)) + + +async def parse_recipe_from_text(text: str) -> dict: + """Ask Ollama to parse unstructured recipe text into our standard recipe schema.""" + user_message = f"""Parse this recipe text and extract structured data. + +RECIPE TEXT: +{text[:4000]} + +Respond ONLY with this JSON: +{{ + "recipe": {{ + "name": "...", + "meal_type": "dinner", + "ingredients": [{{"name": "...", "quantity": 1.0, "unit": "cup"}}], + "description": "1-2 sentence description of the dish's flavor and appeal", + "instructions": "1. ... 2. ... (4-8 numbered steps, 1-2 sentences each)", + "time_minutes": 30, + "serves": 2 + }} +}} +meal_type must be one of: breakfast, lunch, dinner, snack""" + + messages = [ + {"role": "system", "content": settings.system_prompt}, + {"role": "user", "content": user_message}, + ] + return await _chat(messages) + + +async def generate_single_recipe(description: str, existing_names: list, pantry_context: dict) -> dict: + """Generate one recipe matching a user description.""" + user_message = f"""Suggest ONE recipe based on this description: "{description}" + +CURRENT PANTRY: +{json.dumps(pantry_context["available_ingredients"], indent=2)} + +ALREADY IN PLAN (do not suggest these): +{json.dumps(existing_names, indent=2)} + +GUIDELINES: +- Match the description as closely as possible +- Do NOT limit yourself to only pantry ingredients +- Under 60 minutes +- Must not be any dish already in the plan + +Respond ONLY with this JSON: +{{ + "recipe": {{ + "name": "...", + "description": "1-2 sentence description of the dish's flavor and appeal", + "meal_type": "dinner", + "ingredients": [{{"name": "...", "quantity": 1.0, "unit": "cup"}}], + "instructions": "1. ... 2. ... (4-8 numbered steps, 1-2 sentences each)", + "time_minutes": 30, + "serves": 2 + }} +}} +meal_type must be one of: breakfast, lunch, dinner, snack""" + + messages = [ + {"role": "system", "content": settings.system_prompt}, + {"role": "user", "content": user_message}, + ] + return await _chat(messages) diff --git a/static/app.js b/static/app.js index 724e565..3135aa9 100644 --- a/static/app.js +++ b/static/app.js @@ -100,19 +100,17 @@ function autoCategory(name) { // ── Tab Switching ────────────────────────────────────────────────────────── function switchTab(tabName) { state.activeTab = tabName; + location.hash = tabName; - // Update button states document.querySelectorAll('.tab-btn').forEach(btn => { btn.classList.toggle('active', btn.dataset.tab === tabName); }); - // Update content visibility document.querySelectorAll('.tab-content').forEach(content => { content.classList.toggle('hidden', content.id !== `tab-${tabName}`); content.classList.toggle('active', content.id === `tab-${tabName}`); }); - // Load tab content if (tabName === 'pantry') loadPantry(); else if (tabName === 'meals') loadMeals(); else if (tabName === 'menu') loadMenu(); @@ -394,33 +392,21 @@ function renderRecipeList(recipes) { } list.innerHTML = recipes.map(recipe => { - let ingredients = recipe.ingredients || []; - if (typeof ingredients === 'string') { - try { ingredients = JSON.parse(ingredients); } catch { ingredients = []; } - } - - const ingredientText = Array.isArray(ingredients) - ? ingredients.map(i => { - if (typeof i === 'string') return i; - return [i.quantity, i.unit, i.name].filter(Boolean).join(' '); - }).join(', ') - : ''; - const mealType = recipe.meal_type || 'dinner'; const typeDisplay = mealType.charAt(0).toUpperCase() + mealType.slice(1); const time = recipe.time_minutes || recipe.estimated_time_minutes; const serves = recipe.serves || recipe.servings; const meta = [time ? `${time} min` : '', serves ? `serves ${serves}` : ''].filter(Boolean).join(' · '); + const description = recipe.description || ''; return ` <div class="recipe-card"> <div class="recipe-card-header"> - <div class="recipe-card-title">${recipe.name}</div> + <a class="recipe-card-title" href="/recipe.html?id=${recipe.id}">${recipe.name}</a> <span class="recipe-type-badge ${mealType}">${typeDisplay}</span> </div> ${meta ? `<div class="recipe-card-meta">${meta}</div>` : ''} - ${ingredientText ? `<div class="recipe-card-ingredients"><strong>Ingredients:</strong> ${ingredientText}</div>` : ''} - ${recipe.instructions ? `<div class="recipe-card-instructions">${recipe.instructions}</div>` : ''} + ${description ? `<div class="recipe-description">${description}</div>` : ''} <div class="recipe-card-actions"> <button class="btn btn-sm btn-primary" onclick="makeThisMeal('${recipe.name.replace(/'/g, "\\'")}', '${mealType}')">Make This</button> <button class="btn btn-sm btn-secondary" onclick="swapRecipe(${recipe.id}, '${mealType}', this)">Swap</button> @@ -550,6 +536,48 @@ async function makeThisMeal(mealName, mealType) { } } +async function importRecipe() { + const input = document.getElementById('import-url-input'); + const btn = document.getElementById('btn-import-recipe'); + const url = input.value.trim(); + if (!url) { showToast('Paste a recipe URL first', 'error'); return; } + + btn.disabled = true; + btn.textContent = 'Importing…'; + try { + const res = await api('POST', '/recipes/import', { url }); + input.value = ''; + showToast(`Imported: ${res.recipe.name}`); + await loadMenu(); + } catch (err) { + showToast('Import failed: ' + err.message, 'error'); + } finally { + btn.disabled = false; + btn.textContent = 'Import'; + } +} + +async function addSingleDish() { + const input = document.getElementById('add-dish-input'); + const btn = document.getElementById('btn-add-dish'); + const description = input.value.trim(); + if (!description) { showToast('Describe a dish first', 'error'); return; } + + btn.disabled = true; + btn.textContent = 'Adding…'; + try { + const res = await api('POST', '/menus/current/recipes', { description }); + input.value = ''; + showToast(`Added: ${res.recipe.name}`); + await loadMenu(); + } catch (err) { + showToast('Failed: ' + err.message, 'error'); + } finally { + btn.disabled = false; + btn.textContent = 'Add Dish'; + } +} + // ── Grocery Tab ──────────────────────────────────────────────────────────── async function loadGrocery() { try { @@ -643,6 +671,7 @@ function renderGroceryList() { ${usedIn ? ` - Used in: ${usedIn}` : ''} </div> </div> + <button class="btn btn-sm btn-danger" style="padding:0.1rem 0.4rem;font-size:0.75rem;" onclick="deleteGroceryItem('${item.name.replace(/'/g, "\\'")}')">×</button> </div> `; }).join('')} @@ -759,11 +788,67 @@ async function markPurchased() { } } +async function deleteGroceryItem(name) { + if (!state.currentGroceryId) return; + try { + await api('DELETE', `/grocery/${state.currentGroceryId}/items`, { name }); + // Remove from local state and re-render + const parsed = JSON.parse(state.currentGrocery.grocery_list?.items || state.currentGrocery.items || '[]'); + const updated = parsed.filter(i => i.name !== name); + if (state.currentGrocery.grocery_list) { + state.currentGrocery.grocery_list.items = JSON.stringify(updated); + } else { + state.currentGrocery.items = JSON.stringify(updated); + } + renderGroceryList(); + } catch (err) { + showToast('Failed to remove item: ' + err.message, 'error'); + } +} + // ── Chat Tab ─────────────────────────────────────────────────────────────── function loadChat() { renderChat(); } +function renderMarkdown(text) { + // Escape HTML first + let html = text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); + + // Fenced code blocks + html = html.replace(/```(\w*)\n?([\s\S]*?)```/g, (_, lang, code) => + `<pre><code>${code.trim()}</code></pre>` + ); + + // Inline code + html = html.replace(/`([^`]+)`/g, '<code>$1</code>'); + + // Bold + italic + html = html.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>'); + // Bold + html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>'); + // Italic + html = html.replace(/\*(.+?)\*/g, '<em>$1</em>'); + + // Headers (## and ###) + html = html.replace(/^### (.+)$/gm, '<h4>$1</h4>'); + html = html.replace(/^## (.+)$/gm, '<h3>$1</h3>'); + + // Unordered lists + html = html.replace(/^[-*] (.+)$/gm, '<li>$1</li>'); + html = html.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>'); + + // Numbered lists + html = html.replace(/^\d+\. (.+)$/gm, '<li>$1</li>'); + + // Paragraphs — double newlines + html = html.replace(/\n\n+/g, '</p><p>'); + // Single newlines outside block elements + html = html.replace(/\n/g, '<br>'); + + return `<p>${html}</p>`; +} + function renderChat() { const messagesDiv = document.getElementById('chat-messages'); if (state.chatHistory.length === 0) { @@ -773,7 +858,7 @@ function renderChat() { messagesDiv.innerHTML = state.chatHistory.map(msg => ` <div class="chat-message chat-message-${msg.role}"> - <div class="chat-bubble">${msg.content.replace(/\n/g, '<br>')}</div> + <div class="chat-bubble">${msg.role === 'assistant' ? renderMarkdown(msg.content) : msg.content.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')}</div> </div> `).join(''); @@ -925,8 +1010,10 @@ async function init() { } }); - // Load initial tab - await loadPantry(); + // Load initial tab from URL hash, defaulting to pantry + const validTabs = ['pantry', 'meals', 'menu', 'grocery', 'chat']; + const hashTab = location.hash.slice(1); + switchTab(validTabs.includes(hashTab) ? hashTab : 'pantry'); } document.addEventListener('DOMContentLoaded', init); diff --git a/static/index.html b/static/index.html index ae36a4f..7db0a86 100644 --- a/static/index.html +++ b/static/index.html @@ -5,11 +5,11 @@ <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Commis</title> <link rel="stylesheet" href="style.css"> - <link rel="icon" href="favicon.svg" type="image/svg+xml"> + <link rel="icon" href="logo.png" type="image/png"> </head> <body> <header> - <div class="header-title">🔪 Commis</div> + <div class="header-title"><img src="logo.png" alt="Commis" style="height:32px;width:32px;object-fit:contain;vertical-align:middle;margin-right:0.4rem;border-radius:6px;">Commis</div> <div id="ollama-status" class="status-badge">Checking...</div> </header> @@ -90,6 +90,14 @@ </div> </div> <div id="menu-notes" class="info-banner hidden"></div> + <div class="import-url-form" id="import-url-form" style="display:flex;gap:0.5rem;margin-top:0.75rem;"> + <input type="url" id="import-url-input" placeholder="Paste recipe URL…" style="flex:1;" /> + <button class="btn btn-secondary" id="btn-import-recipe" onclick="importRecipe()">Import</button> + </div> + <div style="display:flex;gap:0.5rem;margin-top:0.5rem;"> + <input type="text" id="add-dish-input" placeholder="Describe a dish to add… e.g. a quick pasta for tonight" style="flex:1;" /> + <button class="btn btn-secondary" id="btn-add-dish" onclick="addSingleDish()">Add Dish</button> + </div> <div id="recipe-list" class="recipe-list"></div> <div id="menu-empty" class="empty-state hidden">No menu generated yet. Click "Generate Menu" to create one.</div> </div> diff --git a/static/logo.png b/static/logo.png Binary files differnew file mode 100644 index 0000000..e20d8e1 --- /dev/null +++ b/static/logo.png diff --git a/static/recipe.html b/static/recipe.html new file mode 100644 index 0000000..2a5614f --- /dev/null +++ b/static/recipe.html @@ -0,0 +1,68 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Recipe — Commis</title> + <link rel="stylesheet" href="style.css"> + <link rel="icon" href="logo.png" type="image/png"> +</head> +<body> + <header> + <div class="header-title"><img src="logo.png" alt="Commis" style="height:32px;width:32px;object-fit:contain;vertical-align:middle;margin-right:0.4rem;border-radius:6px;">Commis</div> + </header> + <main id="recipe-detail" style="max-width:720px;margin:2rem auto;padding:0 1rem;"> + <a href="/#menu" class="btn btn-secondary" style="margin-bottom:1.5rem;display:inline-block;">← Back to Menu</a> + <div id="recipe-content"><p>Loading…</p></div> + </main> + <script> + const params = new URLSearchParams(location.search); + const id = params.get('id'); + if (!id) { + document.getElementById('recipe-content').innerHTML = '<p>No recipe ID provided.</p>'; + } else { + fetch(`/api/recipes/${id}`) + .then(r => r.ok ? r.json() : Promise.reject(r.statusText)) + .then(recipe => { + let ingredients = []; + try { ingredients = JSON.parse(recipe.ingredients); } catch {} + + const mealType = recipe.meal_type || 'dinner'; + const typeDisplay = mealType.charAt(0).toUpperCase() + mealType.slice(1); + const time = recipe.estimated_time_minutes; + const serves = recipe.servings; + const meta = [time ? `${time} min` : '', serves ? `serves ${serves}` : ''].filter(Boolean).join(' · '); + + const ingredientList = Array.isArray(ingredients) + ? ingredients.map(i => { + if (typeof i === 'string') return `<li>${i}</li>`; + return `<li>${[i.quantity, i.unit, i.name].filter(Boolean).join(' ')}</li>`; + }).join('') + : ''; + + const instructionSteps = (recipe.instructions || '') + .split(/\n+| (?=\d+\. )/) + .map(s => s.trim()) + .filter(Boolean) + .map(s => `<p>${s}</p>`) + .join(''); + + document.title = `${recipe.name} — Commis`; + document.getElementById('recipe-content').innerHTML = ` + <div class="recipe-card-header" style="margin-bottom:0.5rem;"> + <h1 style="font-size:1.6rem;margin:0;">${recipe.name}</h1> + <span class="recipe-type-badge ${mealType}">${typeDisplay}</span> + </div> + ${recipe.description ? `<p style="color:var(--text-muted);margin:0.5rem 0 1rem;">${recipe.description}</p>` : ''} + ${meta ? `<div class="recipe-card-meta" style="margin-bottom:1.5rem;">${meta}</div>` : ''} + ${ingredientList ? `<h2 style="font-size:1.1rem;margin-bottom:0.5rem;">Ingredients</h2><ul style="padding-left:1.25rem;line-height:1.8;">${ingredientList}</ul>` : ''} + ${instructionSteps ? `<h2 style="font-size:1.1rem;margin:1.5rem 0 0.5rem;">Instructions</h2><div style="line-height:1.7;">${instructionSteps}</div>` : ''} + `; + }) + .catch(err => { + document.getElementById('recipe-content').innerHTML = `<p>Failed to load recipe: ${err}</p>`; + }); + } + </script> +</body> +</html> diff --git a/static/style.css b/static/style.css index c530839..4395e88 100644 --- a/static/style.css +++ b/static/style.css @@ -1,15 +1,15 @@ :root { - --bg: #0f1117; - --surface: #1a1d27; - --surface2: #252836; - --border: #2e3148; - --accent: #6c63ff; - --accent-hover: #8b84ff; + --bg: #0b0b0a; + --surface: #141412; + --surface2: #1e1e1b; + --border: #2a2a26; + --accent: #c07d0a; + --accent-hover: #a56a08; --text: #e2e8f0; --text-muted: #94a3b8; - --success: #22c55e; - --warning: #f59e0b; - --danger: #ef4444; + --success: #1ea34e; + --warning: #d4783a; + --danger: #b03030; --info: #3b82f6; } @@ -190,6 +190,24 @@ main { transition: border-color 0.2s ease; } +input[type="url"], +input[type="text"] { + padding: 0.75rem; + background-color: var(--surface2); + border: 1px solid var(--border); + border-radius: 0.375rem; + color: var(--text); + font-size: 0.9rem; + transition: border-color 0.2s ease; +} + +input[type="url"]:focus, +input[type="text"]:focus { + outline: none; + border-color: var(--accent); + background-color: rgba(108, 99, 255, 0.05); +} + .form-group input:focus, .form-group select:focus { outline: none; @@ -385,12 +403,6 @@ tbody tr:hover { gap: 0.5rem; } -.recipe-card-title { - font-weight: 600; - font-size: 1rem; - color: var(--text); -} - .recipe-type-badge { font-size: 0.7rem; padding: 0.2rem 0.6rem; @@ -410,20 +422,23 @@ tbody tr:hover { color: var(--text-muted); } -.recipe-card-ingredients { - font-size: 0.85rem; +.recipe-description { color: var(--text-muted); + font-size: 0.9rem; + margin: 0.25rem 0 0.5rem; line-height: 1.5; } -.recipe-card-instructions { - font-size: 0.85rem; +.recipe-card-title { color: var(--text); - line-height: 1.6; - border-top: 1px solid var(--border); - padding-top: 0.5rem; - margin-top: 0.25rem; - white-space: pre-line; + text-decoration: none; + font-weight: 600; + font-size: 1.05rem; +} + +.recipe-card-title:hover { + color: var(--accent); + text-decoration: underline; } .recipe-card .btn { @@ -454,7 +469,7 @@ tbody tr:hover { .grocery-item { display: flex; - align-items: flex-start; + align-items: center; gap: 1rem; padding: 0.75rem; background-color: rgba(255, 255, 255, 0.01); @@ -943,6 +958,15 @@ tbody tr:hover { border-bottom-left-radius: 0.25rem; } +.chat-bubble p { margin: 0 0 0.5rem; } +.chat-bubble p:last-child { margin-bottom: 0; } +.chat-bubble h3, .chat-bubble h4 { margin: 0.75rem 0 0.25rem; font-size: 0.95rem; } +.chat-bubble ul { padding-left: 1.25rem; margin: 0.25rem 0; } +.chat-bubble li { margin-bottom: 0.15rem; } +.chat-bubble code { background: rgba(0,0,0,0.3); padding: 0.1rem 0.35rem; border-radius: 0.25rem; font-size: 0.85rem; font-family: monospace; } +.chat-bubble pre { background: rgba(0,0,0,0.4); padding: 0.75rem; border-radius: 0.375rem; overflow-x: auto; margin: 0.5rem 0; } +.chat-bubble pre code { background: none; padding: 0; font-size: 0.82rem; } + .chat-typing-indicator { color: var(--text-muted); font-style: italic; |
