summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorTyler Hoang <tyler@tylerhoang.xyz>2026-05-09 02:31:10 -0700
committerTyler Hoang <tyler@tylerhoang.xyz>2026-05-09 02:31:10 -0700
commit360eadf78fb001e947f3850603152adc413bb3a8 (patch)
tree2d67065890ce195bc2c0f2f6e430e86c241b2424
parentaba03fd72df5729a86d21c6866761b43a8abad68 (diff)
Recipe detail page, menu revamp, and UX improvements
- Add recipe detail page (recipe.html) with full ingredients and instructions - Simplify menu tab: cards show name + description only, click through for full recipe - Add description field to Recipe model with DB migration - Add AI-generated descriptions to menu, swap, and import prompts - Add single dish by description (POST /api/menus/current/recipes) - Add grocery item delete without pantry add (DELETE /api/grocery/{id}/items) - Persist grocery checked state server-side (PATCH /api/grocery/{id}/check-item) - Hash-based tab routing — refresh stays on current tab - Logo branding in header and favicon - Dark theme fixes: URL/text inputs, amber accent, muted danger/warning colors - Markdown rendering in chat (bold, italic, code blocks, lists, headers) - Fix instruction step splitting for inline-numbered steps (1. 2. 3.) - Import recipe from URL with JSON-LD structured data + AI fallback Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
-rw-r--r--main.py4
-rw-r--r--models.py1
-rw-r--r--requirements.txt2
-rw-r--r--routers/grocery.py22
-rw-r--r--routers/menus.py68
-rw-r--r--routers/recipes.py144
-rw-r--r--schemas.py3
-rw-r--r--services/ai_service.py71
-rw-r--r--static/app.js129
-rw-r--r--static/index.html12
-rw-r--r--static/logo.pngbin0 -> 1626778 bytes
-rw-r--r--static/recipe.html68
-rw-r--r--static/style.css74
13 files changed, 545 insertions, 53 deletions
diff --git a/main.py b/main.py
index 65b8271..8bc2d4f 100644
--- a/main.py
+++ b/main.py
@@ -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
diff --git a/models.py b/models.py
index 9e78d65..4059e20 100644
--- a/models.py
+++ b/models.py
@@ -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)}
diff --git a/schemas.py b/schemas.py
index b21c1f0..e6d926f 100644
--- a/schemas.py
+++ b/schemas.py
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
+
+ // 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').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
new file mode 100644
index 0000000..e20d8e1
--- /dev/null
+++ b/static/logo.png
Binary files differ
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;