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 `
${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, '');
+
+ // 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
-
+
@@ -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
+
+
+
+
+
+
+ ← Back to Menu
+
+
+
+
+
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