Просмотр исходного кода

TG-46: Implement dynamic collapsible meal builder UI with recipe prompt generator

FerRo988 2 недель назад
Родитель
Сommit
ac63c273ce
3 измененных файлов с 419 добавлено и 0 удалено
  1. 23 0
      static/index.html
  2. 163 0
      static/script.js
  3. 233 0
      static/style.css

+ 23 - 0
static/index.html

@@ -41,6 +41,29 @@
             </div>
         </div>
         
+        <!-- Meal Builder Component (US-10 Task #46) -->
+        <div class="meal-builder-tray" id="meal-builder">
+            <div class="meal-builder-header">
+                <div class="tray-title">
+                    <span class="icon">🍽️</span>
+                    <h3>Meal Builder</h3>
+                    <span class="item-count" id="meal-item-count">0 items</span>
+                </div>
+                <button id="toggle-meal-btn" class="icon-btn" title="Toggle Meal Builder">
+                    <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 9l-7 7-7-7"></path></svg>
+                </button>
+            </div>
+            <div class="meal-content collapsed" id="meal-content">
+                <div class="meal-items-list" id="meal-items-list">
+                    <!-- Dynamic food rows added here -->
+                    <div class="empty-meal-msg" id="empty-meal-msg">No foods added yet. Use the search + button to build a meal.</div>
+                </div>
+                <div class="meal-builder-footer" id="meal-builder-footer" style="display: none;">
+                    <button id="generate-recipe-btn" class="primary-btn-sm">🍲 Generate Recipe Prompt</button>
+                </div>
+            </div>
+        </div>
+        
         <!-- Macro Dashboard UI (US-07 Task #38) -->
         <div class="macro-dashboard" id="macro-dashboard" style="display: none;">
             <div class="macro-card">

+ 163 - 0
static/script.js

@@ -423,6 +423,153 @@ document.addEventListener('DOMContentLoaded', () => {
     const searchInput = document.getElementById('food-search-input');
     const searchDropdown = document.getElementById('search-results-dropdown');
     const clearSearchBtn = document.getElementById('clear-search-btn');
+    
+    // --- Meal Builder State & UI (US-10 Task #46) ---
+    let currentMealItems = [];
+    const mealBuilder = document.getElementById('meal-builder');
+    const mealContent = document.getElementById('meal-content');
+    const mealItemsList = document.getElementById('meal-items-list');
+    const mealItemCount = document.getElementById('meal-item-count');
+    const toggleMealBtn = document.getElementById('toggle-meal-btn');
+    const emptyMealMsg = document.getElementById('empty-meal-msg');
+    const mealBuilderFooter = document.getElementById('meal-builder-footer');
+    const generateRecipeBtn = document.getElementById('generate-recipe-btn');
+
+    const toggleMealBuilder = () => {
+        const isCollapsed = mealContent.classList.contains('collapsed');
+        if (isCollapsed) {
+            mealContent.classList.remove('collapsed');
+            toggleMealBtn.style.transform = 'rotate(180deg)';
+        } else {
+            mealContent.classList.add('collapsed');
+            toggleMealBtn.style.transform = 'rotate(0deg)';
+        }
+    };
+
+    const addItemToMeal = (food) => {
+        // Duplicate Handling: Merge by updating grams
+        const existingItem = currentMealItems.find(item => item.id === food.id);
+        if (existingItem) {
+            existingItem.amount += 100;
+            // Directly update the DOM input for this item to avoid re-rendering the whole list
+            const inputEl = document.querySelector(`.meal-weight-input[data-id="${food.id}"]`);
+            if (inputEl) {
+                inputEl.value = existingItem.amount;
+                // Animate the row slightly to show it was updated
+                const rowEl = inputEl.closest('.meal-item-row');
+                if (rowEl) {
+                    rowEl.style.transform = 'scale(1.02)';
+                    rowEl.style.background = 'rgba(255, 255, 255, 0.1)';
+                    setTimeout(() => {
+                        rowEl.style.transform = '';
+                        rowEl.style.background = 'rgba(255, 255, 255, 0.05)';
+                    }, 200);
+                }
+            }
+        } else {
+            currentMealItems.push({
+                id: food.id,
+                name: food.name,
+                amount: 100,
+                base_macros: {
+                    calories: food.calories,
+                    protein_g: food.protein_g,
+                    fat_g: food.fat_g,
+                    carbs_g: food.carbs_g
+                }
+            });
+            renderMealItems(); // Only re-render if it's a new item
+        }
+        
+        // Ensure builder is expanded when adding an item
+        if (mealContent.classList.contains('collapsed')) {
+            toggleMealBuilder();
+        }
+    };
+
+    const removeItemFromMeal = (id) => {
+        currentMealItems = currentMealItems.filter(item => item.id !== id);
+        renderMealItems();
+    };
+
+    const updateItemAmount = (id, newAmount) => {
+        const item = currentMealItems.find(item => item.id === id);
+        if (item) {
+            item.amount = parseInt(newAmount) || 0;
+            // No need to re-render everything, just keep state in sync
+            // Task #50 will handle dynamic totals later
+        }
+    };
+
+    const renderMealItems = () => {
+        mealItemsList.innerHTML = '';
+        mealItemCount.textContent = `${currentMealItems.length} item${currentMealItems.length !== 1 ? 's' : ''}`;
+        
+        if (currentMealItems.length === 0) {
+            mealItemsList.appendChild(emptyMealMsg);
+            mealBuilderFooter.style.display = 'none';
+            return;
+        }
+
+        mealBuilderFooter.style.display = 'flex';
+
+        currentMealItems.forEach(item => {
+            const row = document.createElement('div');
+            row.className = 'meal-item-row';
+            row.innerHTML = `
+                <div class="meal-item-name" title="${item.name}">${item.name}</div>
+                <div class="meal-item-controls">
+                    <div class="weight-input-wrapper">
+                        <input type="number" value="${item.amount}" min="0" max="5000" data-id="${item.id}" class="meal-weight-input">
+                        <span class="weight-unit">g</span>
+                    </div>
+                    <button class="remove-item-btn" data-id="${item.id}" title="Remove item">
+                        <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"></path></svg>
+                    </button>
+                </div>
+            `;
+
+            // Event Listeners for rows
+            row.querySelector('.meal-weight-input').addEventListener('input', (e) => {
+                updateItemAmount(item.id, e.target.value);
+            });
+
+            row.querySelector('.remove-item-btn').addEventListener('click', () => {
+                removeItemFromMeal(item.id);
+            });
+
+            mealItemsList.appendChild(row);
+        });
+    };
+
+    if (toggleMealBtn) {
+        toggleMealBtn.addEventListener('click', toggleMealBuilder);
+    }
+    
+    if (generateRecipeBtn) {
+        generateRecipeBtn.addEventListener('click', () => {
+            if (currentMealItems.length === 0) return;
+            
+            const itemNames = currentMealItems.map(item => item.name).join(', ');
+            userInput.value = `Give me a recipe that contains: ${itemNames}`;
+            
+            // UI Feedback
+            userInput.focus();
+            userInput.style.height = 'auto';
+            userInput.style.height = userInput.scrollHeight + 'px';
+            sendBtn.disabled = false;
+            
+            // Smoothly scroll to the bottom to see the chat input
+            window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
+        });
+    }
+
+    // Also toggle when clicking the header
+    document.querySelector('.meal-builder-header').addEventListener('click', (e) => {
+        if (!e.target.closest('#toggle-meal-btn')) {
+            toggleMealBuilder();
+        }
+    });
 
     function debounce(func, delay) {
         let timeout;
@@ -480,6 +627,7 @@ document.addEventListener('DOMContentLoaded', () => {
                             <div class="macro-tag">Carb: <span>${item.carbs_g}g</span></div>
                         </div>
                         <div class="food-item-footer">
+                            <button class="add-meal-btn" data-id="${item.id}">➕ Add to Meal</button>
                             <button class="details-btn" data-id="${item.id}">📊 Details</button>
                         </div>
                         <div class="details-panel" id="details-${item.id}"></div>
@@ -497,6 +645,21 @@ document.addEventListener('DOMContentLoaded', () => {
                         clearSearchBtn.style.display = 'none';
                     });
 
+                    // Click on the add to meal button
+                    foodEl.querySelector('.add-meal-btn').addEventListener('click', (e) => {
+                        e.stopPropagation();
+                        addItemToMeal(item);
+                        // Optional: provide visual feedback
+                        const btn = e.target;
+                        const originalText = btn.textContent;
+                        btn.textContent = '✅ Added';
+                        btn.style.background = '#22c55e';
+                        setTimeout(() => {
+                            btn.textContent = originalText;
+                            btn.style.background = '';
+                        }, 1000);
+                    });
+
                     // Click on the details button toggles the panel
                     foodEl.querySelector('.details-btn').addEventListener('click', (e) => {
                         e.stopPropagation();

+ 233 - 0
static/style.css

@@ -814,3 +814,236 @@ textarea::placeholder {
         font-size: 1.1rem;
     }
 }
+
+/* =============================================
+   Meal Builder (US-10 Task #46)
+   ============================================= */
+.meal-builder-tray {
+    margin: 0 20px;
+    background: rgba(255, 255, 255, 0.03);
+    border: 1px solid var(--border);
+    border-radius: 12px;
+    overflow: hidden;
+    backdrop-filter: blur(10px);
+    -webkit-backdrop-filter: blur(10px);
+    transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+    margin-bottom: 12px;
+}
+
+.meal-builder-header {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    padding: 10px 16px;
+    background: rgba(255, 255, 255, 0.05);
+    cursor: pointer;
+}
+
+.tray-title {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+}
+
+.tray-title h3 {
+    font-size: 0.95rem;
+    font-weight: 600;
+    margin: 0;
+}
+
+.tray-title .item-count {
+    font-size: 0.75rem;
+    color: var(--text-muted);
+    background: rgba(255, 255, 255, 0.1);
+    padding: 2px 8px;
+    border-radius: 10px;
+}
+
+.meal-content {
+    max-height: 500px;
+    overflow: hidden;
+    transition: max-height 0.3s ease-out, opacity 0.3s ease;
+    opacity: 1;
+}
+
+.meal-content.collapsed {
+    max-height: 0;
+    opacity: 0;
+}
+
+.meal-items-list {
+    padding: 12px;
+    max-height: 250px;
+    overflow-y: auto;
+    display: flex;
+    flex-direction: column;
+    gap: 8px;
+}
+
+/* Custom Scrollbar for Meal List */
+.meal-items-list::-webkit-scrollbar {
+    width: 4px;
+}
+.meal-items-list::-webkit-scrollbar-thumb {
+    background: rgba(255, 255, 255, 0.1);
+    border-radius: 4px;
+}
+
+.meal-item-row {
+    display: flex;
+    align-items: center;
+    gap: 12px;
+    padding: 8px 12px;
+    background: rgba(255, 255, 255, 0.05);
+    border: 1px solid rgba(255, 255, 255, 0.05);
+    border-radius: 8px;
+    animation: slideInLeft 0.3s ease-out;
+}
+
+.meal-item-name {
+    flex: 1;
+    font-size: 0.9rem;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+}
+
+.meal-item-controls {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+}
+
+.weight-input-wrapper {
+    display: flex;
+    align-items: center;
+    background: rgba(0, 0, 0, 0.2);
+    border-radius: 6px;
+    padding: 2px 6px;
+    border: 1px solid var(--border);
+}
+
+.weight-input-wrapper input {
+    background: transparent;
+    border: none;
+    color: var(--text);
+    width: 45px;
+    font-size: 0.85rem;
+    text-align: right;
+    padding: 2px;
+    outline: none;
+}
+
+.weight-unit {
+    font-size: 0.75rem;
+    color: var(--text-muted);
+    margin-left: 2px;
+}
+
+.remove-item-btn {
+    background: transparent;
+    border: none;
+    color: #ef4444;
+    cursor: pointer;
+    padding: 4px;
+    border-radius: 4px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    opacity: 0.7;
+    transition: opacity 0.2s, background 0.2s;
+}
+
+.remove-item-btn:hover {
+    opacity: 1;
+    background: rgba(239, 68, 68, 0.1);
+}
+
+.empty-meal-msg {
+    text-align: center;
+    padding: 20px;
+    font-size: 0.85rem;
+    color: var(--text-muted);
+    font-style: italic;
+}
+
+#toggle-meal-btn {
+    transition: transform 0.3s ease;
+}
+
+.meal-content:not(.collapsed) + .meal-builder-header #toggle-meal-btn {
+    transform: rotate(180deg);
+}
+
+/* Add-to-meal button in search results */
+.add-meal-btn {
+    background: var(--primary);
+    color: white;
+    border: none;
+    border-radius: 6px;
+    padding: 4px 10px;
+    font-size: 0.8rem;
+    cursor: pointer;
+    transition: background 0.2s, transform 0.1s;
+    font-weight: 600;
+}
+
+.add-meal-btn:hover {
+    background: var(--primary-hover);
+    transform: scale(1.05);
+}
+
+.add-meal-btn:active {
+    transform: scale(0.95);
+}
+
+.food-item-footer {
+    display: flex;
+    gap: 8px;
+    margin-top: 10px;
+}
+
+@keyframes slideInLeft {
+    from { opacity: 0; transform: translateX(-10px); }
+    to { opacity: 1; transform: translateX(0); }
+}
+
+@media (max-width: 600px) {
+    .meal-builder-tray {
+        margin: 0 12px 10px 12px;
+    }
+    .meal-item-name {
+        font-size: 0.8rem;
+    }
+}
+
+.meal-builder-footer {
+    padding: 12px;
+    border-top: 1px solid rgba(255, 255, 255, 0.05);
+    display: flex;
+    justify-content: center;
+    background: rgba(255, 255, 255, 0.02);
+}
+
+.primary-btn-sm {
+    background: var(--primary);
+    color: white;
+    border: none;
+    border-radius: 8px;
+    padding: 8px 16px;
+    font-size: 0.85rem;
+    font-weight: 600;
+    cursor: pointer;
+    transition: all 0.2s ease;
+    width: 100%;
+}
+
+.primary-btn-sm:hover {
+    background: var(--primary-hover);
+    transform: translateY(-1px);
+    box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
+}
+
+.primary-btn-sm:active {
+    transform: translateY(0);
+}