summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--config.py2
-rw-r--r--routers/recipes.py13
-rw-r--r--services/ai_service.py73
-rw-r--r--static/app.js47
-rw-r--r--static/index.html1
-rw-r--r--static/recipe.html217
6 files changed, 287 insertions, 66 deletions
diff --git a/config.py b/config.py
index f21666c..d0f4616 100644
--- a/config.py
+++ b/config.py
@@ -6,7 +6,7 @@ class Settings(BaseSettings):
model_name: str
database_url: str
ollama_timeout: int = 120
- system_prompt: str = "You are a professional chef assistant. You MUST respond with valid JSON only — no markdown, no explanation outside the JSON. You prioritize:\n1. Using ingredients that expire soonest\n2. Nutritional variety across the week\n3. Avoiding meals eaten in the past 7 days\n4. Practical home recipes under 60 minutes"
+ system_prompt: str = "You are Commis, a professional home-chef assistant. You MUST respond with valid JSON only — no markdown, no explanation outside the JSON. Core priorities:\n1. Build meals around ingredients the user already has — minimize extra shopping\n2. Nutritional variety and balance across the week\n3. Never repeat a meal eaten in the past 7 days\n4. Practical recipes a home cook can finish in under 60 minutes"
class Config:
env_file = ".env"
diff --git a/routers/recipes.py b/routers/recipes.py
index 000f945..d06c335 100644
--- a/routers/recipes.py
+++ b/routers/recipes.py
@@ -49,6 +49,19 @@ def get_recipe(id: int, db: Session = Depends(get_db)):
return recipe
+@router.put("/{id}", response_model=RecipeRead)
+def update_recipe(id: int, update: RecipeUpdate, db: Session = Depends(get_db)):
+ """Update a recipe."""
+ recipe = db.query(Recipe).filter(Recipe.id == id).first()
+ if not recipe:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Recipe not found")
+ for field, value in update.model_dump(exclude_unset=True).items():
+ setattr(recipe, field, value)
+ db.commit()
+ db.refresh(recipe)
+ return recipe
+
+
@router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_recipe(id: int, db: Session = Depends(get_db)):
"""Delete a recipe."""
diff --git a/services/ai_service.py b/services/ai_service.py
index 4b54c15..643a9a2 100644
--- a/services/ai_service.py
+++ b/services/ai_service.py
@@ -30,17 +30,17 @@ async def _chat(messages: list) -> dict:
async def generate_weekly_menu(pantry_context: dict, user_notes: str | None = None) -> dict:
"""Suggest 8-12 diverse recipes for the week, prioritizing expiring pantry items."""
- user_message = f"""Given my current pantry and recent meal history, suggest 8-12 diverse recipes I could make this week.
+ user_message = f"""Plan 8-12 diverse recipes for this week that make the most of what I already have.
PANTRY STATE:
{json.dumps(pantry_context, indent=2)}
-GUIDELINES:
-- Suggest genuinely good, practical dishes — do NOT limit yourself to only pantry ingredients
-- Use pantry ingredients where it makes sense, but freely include recipes that require buying additional items
-- Avoid meals eaten in the last 7 days (see recent_meals)
-- Include a mix of breakfast, lunch, and dinner options
-- Keep each recipe under 60 minutes
+REQUIREMENTS:
+- BUILD recipes around pantry ingredients — most of each recipe should use what I already have
+- Each recipe may call for AT MOST 4-5 ingredients not in my pantry (items I'd pick up at the store)
+- Do not repeat any meal eaten in the last 7 days (see recent_meals)
+- Cover a mix of breakfast, lunch, and dinner
+- Every recipe must be completable in under 60 minutes
- Include full ingredient lists with quantities and units
Respond ONLY with this JSON structure:
@@ -49,15 +49,17 @@ Respond ONLY with this JSON structure:
{{
"name": "...",
"meal_type": "breakfast",
- "ingredients": [{{"name": "...", "quantity": 1.0, "unit": "cup"}}],
+ "ingredients": [{{"name": "...", "quantity": 1.0, "unit": "cup", "in_pantry": true}}],
"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
}}
],
- "notes": "brief explanation of choices"
-}}"""
+ "notes": "brief explanation of choices and what extra ingredients are needed"
+}}
+
+Set `in_pantry: true` for each ingredient that matches something in the pantry, `false` for items that need to be purchased."""
if user_notes:
user_message += f"\n\nUSER PREFERENCES:\n{user_notes}"
@@ -72,20 +74,21 @@ Respond ONLY with this JSON structure:
async def generate_grocery_list(recipes: list, pantry_context: dict, user_notes: str | None = None) -> dict:
"""Generate a minimal grocery list based on a list of recipes and current pantry."""
- user_message = f"""Given these recipes and current pantry, generate a minimal grocery list.
+ user_message = f"""Generate the grocery list of ONLY the items I need to buy to cook all these recipes.
RECIPES:
{json.dumps(recipes, indent=2)}
-CURRENT PANTRY:
+PANTRY (I already have these — DO NOT include them in the grocery list):
{json.dumps(pantry_context["available_ingredients"], indent=2)}
RULES:
-- Only list ingredients NOT already sufficiently in the pantry
-- Consolidate duplicate ingredients across recipes (buy once)
-- Group items by store section (produce, dairy, protein, pantry, frozen, bakery)
-- Estimate realistic retail costs in USD
-- Prefer bulk when ingredient appears in 3+ recipes
+- For every ingredient in every recipe, cross-check against the pantry. If the pantry has it → SKIP IT. If it's not in the pantry → include it.
+- The goal is a minimal, accurate list of ONLY what is missing — do not list anything already stocked.
+- Consolidate the same ingredient appearing across multiple recipes into one entry (sum the quantities).
+- Group items by store section: produce, dairy, protein, pantry, frozen, bakery.
+- Estimate realistic retail costs in USD.
+- If an ingredient appears in 3+ recipes, note that bulk purchase may save money.
Respond ONLY with this JSON:
{{
@@ -117,16 +120,18 @@ Respond ONLY with this JSON:
async def generate_grocery_list_from_pantry(pantry_context: dict, user_notes: str | None = None) -> dict:
"""Generate a grocery list based on pantry state alone, without a menu plan."""
- user_message = f"""Given my current pantry, generate a practical grocery list to stock up for the week.
+ user_message = f"""Generate a solid weekly grocery list to set me up for a full week of cooking.
CURRENT PANTRY:
{json.dumps(pantry_context["available_ingredients"], indent=2)}
RULES:
-- Suggest ingredients that would complement what's already in the pantry
-- Group items by store section (produce, dairy, protein, pantry, frozen, bakery)
-- Estimate realistic retail costs in USD
-- Focus on versatile staples that enable multiple meals
+- If the pantry is empty or nearly empty, suggest a complete foundational shopping list: proteins, produce, dairy, grains, and pantry staples for a week of balanced meals.
+- If the pantry has some items, build around what's there — prioritize ingredients that complement existing stock and fill obvious gaps.
+- Do NOT list items already in the pantry.
+- Aim for variety: at least 2 proteins, a range of vegetables and fruits, a grain, and pantry staples.
+- Group items by store section: produce, dairy, protein, pantry, frozen, bakery.
+- Estimate realistic retail costs in USD.
Respond ONLY with this JSON:
{{
@@ -138,7 +143,7 @@ Respond ONLY with this JSON:
"store_section": "produce",
"estimated_cost": 3.50,
"used_in_meals": [],
- "reason": "versatile staple"
+ "reason": "weekly staple / fills pantry gap"
}}
],
"total_estimate": 45.00,
@@ -194,8 +199,8 @@ ALREADY IN PLAN (do not suggest these):
{json.dumps(existing_names, indent=2)}
GUIDELINES:
-- Suggest a genuinely good, practical {meal_type} dish
-- Do NOT limit yourself to only pantry ingredients
+- Prefer a recipe that uses pantry ingredients as much as possible — only a few new items to buy
+- Make it a genuinely good, practical {meal_type} dish
- Under 60 minutes
- Must not be any of the dishes already in the plan
@@ -239,9 +244,9 @@ async def get_available_models() -> list:
async def chat_with_commis(message: str, history: list, pantry_context: dict, menu_context: list, grocery_context: list) -> str:
"""Have a conversation with Commis using full kitchen context."""
- system_prompt = f"""You are Commis, a friendly and knowledgeable personal chef assistant. You have full visibility into the user's kitchen — their pantry, this week's recipe suggestions, and their grocery list. Use this context to give personalized, practical cooking advice.
+ system_prompt = f"""You are Commis, the user's personal chef assistant. You have real-time visibility into their kitchen — pantry, this week's planned recipes, grocery list, and recent meal history. THIS context is your primary source of truth when answering cooking questions.
-PANTRY (ingredients on hand):
+PANTRY (ingredients currently on hand):
{json.dumps(pantry_context.get("available_ingredients", []), indent=2)}
EXPIRING SOON:
@@ -250,13 +255,19 @@ EXPIRING SOON:
RECENT MEALS (last 14 days):
{json.dumps(pantry_context.get("recent_meals", []), indent=2)}
-THIS WEEK'S RECIPE SUGGESTIONS:
+THIS WEEK'S PLANNED RECIPES:
{json.dumps(menu_context, indent=2)}
-GROCERY LIST:
+GROCERY LIST (items to buy):
{json.dumps(grocery_context, indent=2)}
-Be conversational, helpful, and concise. Reference specific ingredients and recipes by name when relevant. You do not need to respond with JSON."""
+HOW TO RESPOND:
+- When the user asks what to cook, suggest from THIS WEEK'S PLANNED RECIPES first — they were chosen for the pantry.
+- When the user asks about an ingredient, reference whether it's actually in their pantry.
+- When the user asks about substitutions, check if a pantry item can stand in before suggesting something to buy.
+- When the user asks about shopping, reference the grocery list.
+- Be concise, specific, and practical. Reference actual recipe names and ingredient names from the data above.
+- You do not need to respond with JSON."""
messages = [{"role": "system", "content": system_prompt}]
messages.extend(history)
@@ -306,7 +317,7 @@ ALREADY IN PLAN (do not suggest these):
GUIDELINES:
- Match the description as closely as possible
-- Do NOT limit yourself to only pantry ingredients
+- Prefer ingredients already in the pantry — only a few new items to buy if needed
- Under 60 minutes
- Must not be any dish already in the plan
diff --git a/static/app.js b/static/app.js
index 3135aa9..796b9bf 100644
--- a/static/app.js
+++ b/static/app.js
@@ -592,6 +592,7 @@ async function loadGrocery() {
document.getElementById('grocery-empty').classList.remove('hidden');
document.getElementById('btn-mark-purchased').classList.add('hidden');
document.getElementById('btn-clear-grocery').classList.add('hidden');
+ document.getElementById('btn-copy-grocery').classList.add('hidden');
return;
}
@@ -599,6 +600,7 @@ async function loadGrocery() {
state.currentGroceryId = res.id;
document.getElementById('grocery-empty').classList.add('hidden');
document.getElementById('btn-clear-grocery').classList.remove('hidden');
+ document.getElementById('btn-copy-grocery').classList.remove('hidden');
if (res.notes) {
document.getElementById('grocery-notes').textContent = res.notes;
@@ -691,6 +693,48 @@ function renderGroceryList() {
}
}
+function copyGroceryList() {
+ if (!state.currentGrocery) return;
+
+ let items = state.currentGrocery.items;
+ if (typeof items === 'string') {
+ try { items = JSON.parse(items); } catch { items = []; }
+ }
+ if (!Array.isArray(items)) items = [];
+
+ const total = items.filter(i => !i.checked).reduce((sum, item) => sum + (item.estimated_cost || 0), 0);
+ const grouped = {};
+ items.forEach(item => {
+ const section = item.store_section || 'other';
+ if (!grouped[section]) grouped[section] = [];
+ grouped[section].push(item);
+ });
+
+ let md = '## Grocery List\n';
+ if (total > 0) md += `**Estimated total: $${total.toFixed(2)}**\n`;
+
+ Object.keys(grouped).sort().forEach(section => {
+ const unchecked = grouped[section].filter(item => !item.checked);
+ if (!unchecked.length) return;
+ const title = section.charAt(0).toUpperCase() + section.slice(1);
+ md += `\n### ${title}\n`;
+ unchecked.forEach(item => {
+ const qty = [item.quantity, item.unit].filter(Boolean).join(' ');
+ md += `- [ ] ${qty ? qty + ' ' : ''}${item.name}\n`;
+ });
+ });
+
+ const notes = state.currentGrocery.notes;
+ if (notes) md += `\n> ${notes}\n`;
+
+ navigator.clipboard.writeText(md).then(() => {
+ const btn = document.getElementById('btn-copy-grocery');
+ const orig = btn.textContent;
+ btn.textContent = 'Copied!';
+ setTimeout(() => { btn.textContent = orig; }, 2000);
+ }).catch(() => showToast('Could not copy to clipboard', 'error'));
+}
+
async function checkGroceryItem(checkbox) {
const checked = checkbox.checked;
const row = checkbox.closest('.grocery-item');
@@ -739,6 +783,7 @@ async function generateGrocery() {
document.getElementById('grocery-empty').classList.add('hidden');
document.getElementById('btn-mark-purchased').classList.remove('hidden');
document.getElementById('btn-clear-grocery').classList.remove('hidden');
+ document.getElementById('btn-copy-grocery').classList.remove('hidden');
if (res.shopping_notes) {
document.getElementById('grocery-notes').textContent = res.shopping_notes;
@@ -771,6 +816,7 @@ async function clearGrocery() {
document.getElementById('grocery-notes').classList.add('hidden');
document.getElementById('btn-mark-purchased').classList.add('hidden');
document.getElementById('btn-clear-grocery').classList.add('hidden');
+ document.getElementById('btn-copy-grocery').classList.add('hidden');
showToast('Grocery list cleared');
} catch (err) {
showToast(err.message, 'error');
@@ -999,6 +1045,7 @@ async function init() {
// Set up grocery form
document.getElementById('btn-generate-grocery').addEventListener('click', generateGrocery);
document.getElementById('btn-clear-grocery').addEventListener('click', clearGrocery);
+ document.getElementById('btn-copy-grocery').addEventListener('click', copyGroceryList);
document.getElementById('btn-mark-purchased').addEventListener('click', markPurchased);
// Set up chat
diff --git a/static/index.html b/static/index.html
index 7db0a86..f009e00 100644
--- a/static/index.html
+++ b/static/index.html
@@ -110,6 +110,7 @@
<textarea id="grocery-user-notes" class="ai-notes-input" placeholder="Any preferences or instructions for the AI (e.g. budget $50, no red meat, stock up on breakfast items)..." rows="2"></textarea>
<button class="btn btn-primary" id="btn-generate-grocery">✨ Generate List</button>
<button class="btn btn-danger hidden" id="btn-clear-grocery">Clear</button>
+ <button class="btn btn-secondary hidden" id="btn-copy-grocery">Copy</button>
<button class="btn btn-success hidden" id="btn-mark-purchased">✓ Mark All Purchased</button>
<div id="grocery-spinner" class="spinner hidden"></div>
</div>
diff --git a/static/recipe.html b/static/recipe.html
index 2a5614f..34b0669 100644
--- a/static/recipe.html
+++ b/static/recipe.html
@@ -18,46 +18,195 @@
<script>
const params = new URLSearchParams(location.search);
const id = params.get('id');
+ let currentRecipe = null;
+ let isEditMode = false;
+
+ function parseIngredientsFromText(text) {
+ return text.split('\n')
+ .map(line => line.trim())
+ .filter(line => line.length > 0)
+ .map(line => {
+ const parts = line.split(/\s+/);
+ if (parts.length >= 3) {
+ const quantity = parseFloat(parts[0]);
+ if (!isNaN(quantity)) {
+ const unit = parts[1];
+ const name = parts.slice(2).join(' ');
+ return { quantity, unit, name };
+ }
+ }
+ return { name: line, quantity: 1, unit: '' };
+ });
+ }
+
+ function ingredientsToText(ingredients) {
+ if (!Array.isArray(ingredients)) return '';
+ return ingredients.map(i => {
+ if (typeof i === 'string') return i;
+ return [i.quantity, i.unit, i.name].filter(Boolean).join(' ');
+ }).join('\n');
+ }
+
+ function renderViewMode(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;display:flex;justify-content:space-between;align-items:start;gap:1rem;">
+ <div>
+ <h1 style="font-size:1.6rem;margin:0;">${recipe.name}</h1>
+ <span class="recipe-type-badge ${mealType}">${typeDisplay}</span>
+ </div>
+ <button class="btn btn-secondary" style="white-space:nowrap;margin-top:0.25rem;" onclick="enterEditMode()">Edit</button>
+ </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>` : ''}
+ `;
+ isEditMode = false;
+ }
+
+ function renderEditMode(recipe) {
+ let ingredients = [];
+ try { ingredients = JSON.parse(recipe.ingredients); } catch {}
+ const ingredientText = ingredientsToText(ingredients);
+
+ document.getElementById('recipe-content').innerHTML = `
+ <div style="margin-bottom:1.5rem;">
+ <button class="btn btn-secondary" style="margin-bottom:1rem;" onclick="exitEditMode()">← Back</button>
+ <h2 style="font-size:1.4rem;margin:0 0 1.5rem;">Edit Recipe</h2>
+
+ <form id="edit-form" style="display:flex;flex-direction:column;gap:1.5rem;">
+ <div>
+ <label style="display:block;margin-bottom:0.5rem;font-weight:500;">Name</label>
+ <input type="text" id="edit-name" value="${recipe.name}" style="width:100%;padding:0.5rem;border:1px solid var(--border-color);border-radius:6px;font-size:1rem;box-sizing:border-box;">
+ </div>
+
+ <div>
+ <label style="display:block;margin-bottom:0.5rem;font-weight:500;">Meal Type</label>
+ <select id="edit-meal-type" style="width:100%;padding:0.5rem;border:1px solid var(--border-color);border-radius:6px;font-size:1rem;box-sizing:border-box;">
+ <option value="breakfast" ${recipe.meal_type === 'breakfast' ? 'selected' : ''}>Breakfast</option>
+ <option value="lunch" ${recipe.meal_type === 'lunch' ? 'selected' : ''}>Lunch</option>
+ <option value="dinner" ${recipe.meal_type === 'dinner' ? 'selected' : ''}>Dinner</option>
+ </select>
+ </div>
+
+ <div>
+ <label style="display:block;margin-bottom:0.5rem;font-weight:500;">Description (optional)</label>
+ <textarea id="edit-description" style="width:100%;padding:0.5rem;border:1px solid var(--border-color);border-radius:6px;font-size:1rem;box-sizing:border-box;min-height:80px;font-family:inherit;" placeholder="Brief description of the recipe">${recipe.description || ''}</textarea>
+ </div>
+
+ <div>
+ <label style="display:block;margin-bottom:0.5rem;font-weight:500;">Time (minutes, optional)</label>
+ <input type="number" id="edit-time" value="${recipe.estimated_time_minutes || ''}" style="width:100%;padding:0.5rem;border:1px solid var(--border-color);border-radius:6px;font-size:1rem;box-sizing:border-box;">
+ </div>
+
+ <div>
+ <label style="display:block;margin-bottom:0.5rem;font-weight:500;">Servings</label>
+ <input type="number" id="edit-servings" value="${recipe.servings}" min="1" style="width:100%;padding:0.5rem;border:1px solid var(--border-color);border-radius:6px;font-size:1rem;box-sizing:border-box;">
+ </div>
+
+ <div>
+ <label style="display:block;margin-bottom:0.5rem;font-weight:500;">Ingredients</label>
+ <textarea id="edit-ingredients" style="width:100%;padding:0.5rem;border:1px solid var(--border-color);border-radius:6px;font-size:1rem;box-sizing:border-box;min-height:150px;font-family:monospace;" placeholder="One ingredient per line (e.g., 1 cup flour)">${ingredientText}</textarea>
+ <p style="color:var(--text-muted);font-size:0.9rem;margin-top:0.25rem;">Format: quantity unit name (e.g., 1 cup flour)</p>
+ </div>
+
+ <div>
+ <label style="display:block;margin-bottom:0.5rem;font-weight:500;">Instructions</label>
+ <textarea id="edit-instructions" style="width:100%;padding:0.5rem;border:1px solid var(--border-color);border-radius:6px;font-size:1rem;box-sizing:border-box;min-height:150px;font-family:inherit;" placeholder="Step-by-step instructions">${recipe.instructions || ''}</textarea>
+ </div>
+
+ <div style="display:flex;gap:1rem;">
+ <button type="button" class="btn btn-primary" onclick="saveRecipe()" style="flex:1;">Save</button>
+ <button type="button" class="btn btn-secondary" onclick="exitEditMode()" style="flex:1;">Cancel</button>
+ </div>
+ </form>
+ </div>
+ `;
+ isEditMode = true;
+ }
+
+ function enterEditMode() {
+ if (currentRecipe) renderEditMode(currentRecipe);
+ }
+
+ function exitEditMode() {
+ if (currentRecipe) renderViewMode(currentRecipe);
+ }
+
+ async function saveRecipe() {
+ const name = document.getElementById('edit-name').value.trim();
+ const meal_type = document.getElementById('edit-meal-type').value;
+ const description = document.getElementById('edit-description').value.trim();
+ const time_minutes = document.getElementById('edit-time').value;
+ const servings = parseInt(document.getElementById('edit-servings').value, 10) || 2;
+ const ingredientText = document.getElementById('edit-ingredients').value.trim();
+ const instructions = document.getElementById('edit-instructions').value.trim();
+
+ if (!name) {
+ alert('Recipe name is required.');
+ return;
+ }
+
+ const ingredients = parseIngredientsFromText(ingredientText);
+ const payload = {
+ name,
+ meal_type,
+ description: description || null,
+ estimated_time_minutes: time_minutes ? parseInt(time_minutes, 10) : null,
+ servings,
+ ingredients: JSON.stringify(ingredients),
+ instructions: instructions || null,
+ };
+
+ try {
+ const response = await fetch(`/api/recipes/${id}`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ });
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
+ }
+ const updated = await response.json();
+ currentRecipe = updated;
+ renderViewMode(currentRecipe);
+ } catch (err) {
+ alert(`Failed to save recipe: ${err.message}`);
+ }
+ }
+
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>` : ''}
- `;
+ currentRecipe = recipe;
+ renderViewMode(recipe);
})
.catch(err => {
document.getElementById('recipe-content').innerHTML = `<p>Failed to load recipe: ${err}</p>`;