From 360eadf78fb001e947f3850603152adc413bb3a8 Mon Sep 17 00:00:00 2001 From: Tyler Hoang Date: Sat, 9 May 2026 02:31:10 -0700 Subject: Recipe detail page, menu revamp, and UX improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- static/app.js | 129 ++++++++++++++++++++++++++++++++++++++++++++--------- static/index.html | 12 ++++- static/logo.png | Bin 0 -> 1626778 bytes static/recipe.html | 68 ++++++++++++++++++++++++++++ static/style.css | 74 +++++++++++++++++++----------- 5 files changed, 235 insertions(+), 48 deletions(-) create mode 100644 static/logo.png create mode 100644 static/recipe.html (limited to 'static') 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 `
-
${recipe.name}
+ ${recipe.name} ${typeDisplay}
${meta ? `
${meta}
` : ''} - ${ingredientText ? `
Ingredients: ${ingredientText}
` : ''} - ${recipe.instructions ? `
${recipe.instructions}
` : ''} + ${description ? `
${description}
` : ''}
@@ -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}` : ''}
+ `; }).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, '>'); + + // Fenced code blocks + html = html.replace(/```(\w*)\n?([\s\S]*?)```/g, (_, lang, code) => + `
${code.trim()}
` + ); + + // Inline code + html = html.replace(/`([^`]+)`/g, '$1'); + + // Bold + italic + html = html.replace(/\*\*\*(.+?)\*\*\*/g, '$1'); + // Bold + html = html.replace(/\*\*(.+?)\*\*/g, '$1'); + // Italic + html = html.replace(/\*(.+?)\*/g, '$1'); + + // Headers (## and ###) + html = html.replace(/^### (.+)$/gm, '

$1

'); + html = html.replace(/^## (.+)$/gm, '

$1

'); + + // Unordered lists + html = html.replace(/^[-*] (.+)$/gm, '
  • $1
  • '); + html = html.replace(/(
  • .*<\/li>)/s, '
      $1
    '); + + // Numbered lists + html = html.replace(/^\d+\. (.+)$/gm, '
  • $1
  • '); + + // Paragraphs — double newlines + html = html.replace(/\n\n+/g, '

    '); + // Single newlines outside block elements + html = html.replace(/\n/g, '
    '); + + return `

    ${html}

    `; +} + 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 => `
    -
    ${msg.content.replace(/\n/g, '
    ')}
    +
    ${msg.role === 'assistant' ? renderMarkdown(msg.content) : msg.content.replace(/&/g, '&').replace(//g, '>').replace(/\n/g, '
    ')}
    `).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 @@ Commis - +
    -
    🔪 Commis
    +
    CommisCommis
    Checking...
    @@ -90,6 +90,14 @@ +
    + + +
    +
    + + +
    diff --git a/static/logo.png b/static/logo.png new file mode 100644 index 0000000..e20d8e1 Binary files /dev/null and b/static/logo.png 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 @@ + + + + + + Recipe — Commis + + + + +
    +
    CommisCommis
    +
    +
    + ← Back to Menu +

    Loading…

    +
    + + + 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; -- cgit v1.3-2-g0d8e