// GourmetVault client application state let allRecipes = []; let activeRecipe = null; let currentMultiplier = 1; let selectedCoverImage = ''; let stopwatchInterval = null; let stopwatchSeconds = 0; let cookingActiveStep = 0; // Page elements caching const elements = { themeToggle: document.getElementById('theme-toggle'), urlScrapeForm: document.getElementById('url-scrape-form'), scrapeUrlInput: document.getElementById('scrape-url-input'), btnScrapeSubmit: document.getElementById('btn-scrape-submit'), btnManualAdd: document.getElementById('btn-manual-add'), recipesGrid: document.getElementById('recipes-grid'), emptyState: document.getElementById('empty-state'), librarySearch: document.getElementById('library-search'), // Scrape / Edit Modal saveModal: document.getElementById('save-modal'), recipeEditForm: document.getElementById('recipe-edit-form'), editRecipeId: document.getElementById('edit-recipe-id'), editTitle: document.getElementById('edit-title'), editSource: document.getElementById('edit-source'), editDescription: document.getElementById('edit-description'), editServings: document.getElementById('edit-servings'), editPrepTime: document.getElementById('edit-prep-time'), editCookTime: document.getElementById('edit-cook-time'), imageSelectionGrid: document.getElementById('image-selection-grid'), editImageUrl: document.getElementById('edit-image-url'), editIngredients: document.getElementById('edit-ingredients'), editInstructions: document.getElementById('edit-instructions'), modalActionTitle: document.getElementById('modal-action-title'), btnSaveConfirm: document.getElementById('btn-save-confirm'), // Detail Modal detailModal: document.getElementById('detail-modal'), recipeDetailBanner: document.getElementById('recipe-detail-banner'), btnDetailEdit: document.getElementById('btn-detail-edit'), btnDetailDelete: document.getElementById('btn-detail-delete'), recipeDetailTitle: document.getElementById('recipe-detail-title'), tagServings: document.getElementById('tag-servings'), tagPrep: document.getElementById('tag-prep'), tagCook: document.getElementById('tag-cook'), recipeDetailDescription: document.getElementById('recipe-detail-description'), recipeDetailIngredients: document.getElementById('recipe-detail-ingredients'), recipeDetailInstructions: document.getElementById('recipe-detail-instructions'), scalerMinus: document.getElementById('scaler-minus'), scalerPlus: document.getElementById('scaler-plus'), currentServingsVal: document.getElementById('current-servings-val'), btnTriggerCookmode: document.getElementById('btn-trigger-cookmode'), // Cooking Mode Modal cookingModeModal: document.getElementById('cooking-mode-modal'), cookRecipeTitle: document.getElementById('cook-recipe-title'), cookTimerVal: document.getElementById('cook-timer-val'), cookTimerPlay: document.getElementById('cook-timer-play'), cookTimerReset: document.getElementById('cook-timer-reset'), cookProgressBar: document.getElementById('cook-progress-bar'), cookStepIndex: document.getElementById('cook-step-index'), cookStepInstruction: document.getElementById('cook-step-instruction'), cookBtnPrev: document.getElementById('cook-btn-prev'), cookBtnNext: document.getElementById('cook-btn-next'), cookStepDots: document.getElementById('cook-step-dots'), // Loader loadingOverlay: document.getElementById('loading-overlay'), loaderStepFetch: document.getElementById('loader-step-fetch'), loaderStepParse: document.getElementById('loader-step-parse'), loaderStepImages: document.getElementById('loader-step-images') }; // ------------------------------------------------------------- // LIFE CYCLE AND REGISTRATION // ------------------------------------------------------------- window.addEventListener('DOMContentLoaded', () => { initTheme(); fetchRecipes(); registerEvents(); }); function registerEvents() { // Theme Toggle elements.themeToggle.addEventListener('click', toggleTheme); // URL Form Submission elements.urlScrapeForm.addEventListener('submit', handleScrapeSubmit); // Manual Add Form Opening elements.btnManualAdd.addEventListener('click', openManualAdd); // Library Search elements.librarySearch.addEventListener('input', filterRecipes); // Recipe Form Save elements.recipeEditForm.addEventListener('submit', handleRecipeSave); // Action Buttons Detail Modal elements.btnDetailEdit.addEventListener('click', editActiveRecipe); elements.btnDetailDelete.addEventListener('click', deleteActiveRecipe); // Servings Scaler Buttons elements.scalerMinus.addEventListener('click', () => adjustMultiplier(-0.5)); elements.scalerPlus.addEventListener('click', () => adjustMultiplier(0.5)); // Cooking Mode Trigger elements.btnTriggerCookmode.addEventListener('click', enterCookingMode); // Cooking Controls elements.cookBtnPrev.addEventListener('click', prevCookingStep); elements.cookBtnNext.addEventListener('click', nextCookingStep); elements.cookTimerPlay.addEventListener('click', toggleTimer); elements.cookTimerReset.addEventListener('click', resetTimer); // Global Modals click to dismiss on overlay document.querySelectorAll('.modal-overlay').forEach(overlay => { overlay.addEventListener('click', (e) => { if (e.target === overlay && !overlay.classList.contains('loading-overlay')) { closeModal(overlay.id); } }); }); // Global key bindings for cooking mode document.addEventListener('keydown', (e) => { if (elements.cookingModeModal.classList.contains('active')) { if (e.key === 'ArrowRight' || e.key === ' ') { e.preventDefault(); nextCookingStep(); } else if (e.key === 'ArrowLeft') { e.preventDefault(); prevCookingStep(); } else if (e.key === 'Escape') { exitCookingMode(); } } }); } // ------------------------------------------------------------- // UTILITIES: SYSTEM THEMES // ------------------------------------------------------------- function initTheme() { const savedTheme = localStorage.getItem('gourmet-theme') || 'dark'; if (savedTheme === 'dark') { document.body.classList.add('dark-theme'); } else { document.body.classList.remove('dark-theme'); } } function toggleTheme() { document.body.classList.toggle('dark-theme'); const isDark = document.body.classList.contains('dark-theme'); localStorage.setItem('gourmet-theme', isDark ? 'dark' : 'light'); } // ------------------------------------------------------------- // LOADING STATUS MACHINE // ------------------------------------------------------------- function setLoaderStepIcon(stepElement, iconName, spin = false) { const wrapper = stepElement.querySelector('.step-icon-wrapper'); if (wrapper) { wrapper.innerHTML = ``; } } function showScrapeLoading() { elements.loadingOverlay.classList.add('active'); // Reset Steps to default elements.loaderStepFetch.className = 'loader-step active'; elements.loaderStepParse.className = 'loader-step'; elements.loaderStepImages.className = 'loader-step'; setLoaderStepIcon(elements.loaderStepFetch, 'circle-dashed', true); setLoaderStepIcon(elements.loaderStepParse, 'circle', false); setLoaderStepIcon(elements.loaderStepImages, 'circle', false); lucide.createIcons(); // Simulate process transitions setTimeout(() => { if (!elements.loadingOverlay.classList.contains('active')) return; elements.loaderStepFetch.className = 'loader-step completed'; setLoaderStepIcon(elements.loaderStepFetch, 'check-circle-2', false); elements.loaderStepParse.className = 'loader-step active'; setLoaderStepIcon(elements.loaderStepParse, 'circle-dashed', true); lucide.createIcons(); }, 1800); setTimeout(() => { if (!elements.loadingOverlay.classList.contains('active')) return; elements.loaderStepParse.className = 'loader-step completed'; setLoaderStepIcon(elements.loaderStepParse, 'check-circle-2', false); elements.loaderStepImages.className = 'loader-step active'; setLoaderStepIcon(elements.loaderStepImages, 'circle-dashed', true); lucide.createIcons(); }, 3500); } function hideScrapeLoading() { elements.loadingOverlay.classList.remove('active'); } // ------------------------------------------------------------- // CORE MODAL ROUTER // ------------------------------------------------------------- function openModal(id) { document.getElementById(id).classList.add('active'); } function closeModal(id) { document.getElementById(id).classList.remove('active'); if (id === 'cooking-mode-modal') { exitCookingMode(); } } // ------------------------------------------------------------- // CRUD & API HANDLERS // ------------------------------------------------------------- async function fetchRecipes() { try { const response = await fetch('/api/recipes'); allRecipes = await response.json(); renderLibrary(); } catch (error) { console.error('Error fetching recipes library:', error); } } // Scrape link form submission async function handleScrapeSubmit(e) { e.preventDefault(); const url = elements.scrapeUrlInput.value.trim(); if (!url) return; showScrapeLoading(); try { const response = await fetch('/api/scrape', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url }) }); if (!response.ok) { const errorData = await response.json().catch(() => ({})); if (response.status === 403 || errorData.isBlocked) { throw new Error('BLOCKED'); } throw new Error(errorData.error || 'Extraction error.'); } const data = await response.json(); hideScrapeLoading(); openDraftPreview(data); elements.scrapeUrlInput.value = ''; } catch (error) { hideScrapeLoading(); if (error.message === 'BLOCKED') { alert(`Access Blocked by Site Protection\n\nThis website uses Cloudflare or bot-defense screens to prevent automated scraping.\n\nDon't worry! You can still add this recipe by clicking the "Manual Recipe" button at the top to quickly paste your ingredients and directions.`); } else { alert(`Scraping failed: ${error.message}`); } } } // Save or Edit submit async function handleRecipeSave(e) { e.preventDefault(); const id = elements.editRecipeId.value; const recipePayload = { title: elements.editTitle.value.trim(), sourceUrl: elements.editSource.value.trim(), description: elements.editDescription.value.trim(), servings: elements.editServings.value.trim(), prepTime: elements.editPrepTime.value.trim(), cookTime: elements.editCookTime.value.trim(), image: elements.editImageUrl.value.trim(), images: Array.from(elements.imageSelectionGrid.querySelectorAll('.image-option img')).map(img => img.src), ingredients: elements.editIngredients.value.trim().split(/\r?\n/).map(line => line.trim()).filter(Boolean), instructions: elements.editInstructions.value.trim().split(/\r?\n/).map(line => line.trim()).filter(Boolean) }; try { let response; if (id) { // Edit response = await fetch(`/api/recipes/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(recipePayload) }); } else { // Create response = await fetch('/api/recipes', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(recipePayload) }); } if (!response.ok) throw new Error('Saving failed.'); const saved = await response.json(); closeModal('save-modal'); fetchRecipes(); // If active view was open, update it if (activeRecipe && activeRecipe.id === saved.id) { showRecipeDetails(saved); } } catch (error) { console.error('Error saving recipe:', error); alert('Error saving recipe.'); } } // Delete Active Recipe async function deleteActiveRecipe() { if (!activeRecipe) return; if (!confirm(`Are you sure you want to delete "${activeRecipe.title}"?`)) return; try { const response = await fetch(`/api/recipes/${activeRecipe.id}`, { method: 'DELETE' }); if (!response.ok) throw new Error('Deletion failed.'); closeModal('detail-modal'); activeRecipe = null; fetchRecipes(); } catch (error) { console.error('Error deleting recipe:', error); alert('Deletion failed.'); } } // ------------------------------------------------------------- // DRAFT / MANUAL CREATOR MODAL POPULATOR // ------------------------------------------------------------- function openManualAdd() { elements.modalActionTitle.textContent = 'Create New Recipe'; elements.editRecipeId.value = ''; elements.recipeEditForm.reset(); elements.imageSelectionGrid.innerHTML = ''; selectedCoverImage = ''; openModal('save-modal'); } function openDraftPreview(data) { elements.modalActionTitle.textContent = 'Save Extracted Recipe'; elements.editRecipeId.value = ''; elements.editTitle.value = data.title || ''; elements.editSource.value = data.sourceUrl || ''; elements.editDescription.value = data.description || ''; elements.editServings.value = data.servings || ''; elements.editPrepTime.value = data.prepTime || ''; elements.editCookTime.value = data.cookTime || ''; // Ingredients text area elements.editIngredients.value = data.ingredients ? data.ingredients.join('\n') : ''; // Instructions text area elements.editInstructions.value = data.instructions ? data.instructions.join('\n') : ''; // Render Image Selector renderImageSelector(data.images || []); openModal('save-modal'); } function editActiveRecipe() { if (!activeRecipe) return; elements.modalActionTitle.textContent = 'Edit Recipe Details'; elements.editRecipeId.value = activeRecipe.id; elements.editTitle.value = activeRecipe.title || ''; elements.editSource.value = activeRecipe.sourceUrl || ''; elements.editDescription.value = activeRecipe.description || ''; elements.editServings.value = activeRecipe.servings || ''; elements.editPrepTime.value = activeRecipe.prepTime || ''; elements.editCookTime.value = activeRecipe.cookTime || ''; elements.editIngredients.value = activeRecipe.ingredients ? activeRecipe.ingredients.join('\n') : ''; elements.editInstructions.value = activeRecipe.instructions ? activeRecipe.instructions.join('\n') : ''; renderImageSelector(activeRecipe.images || []); // Set active selection if (activeRecipe.image) { elements.editImageUrl.value = activeRecipe.image; selectedCoverImage = activeRecipe.image; selectImageThumbnail(activeRecipe.image); } openModal('save-modal'); } // ------------------------------------------------------------- // IMAGE EXTRACTION CAROUSEL & SELECTOR // ------------------------------------------------------------- function renderImageSelector(images) { elements.imageSelectionGrid.innerHTML = ''; selectedCoverImage = ''; if (!images || images.length === 0) { elements.editImageUrl.value = ''; return; } // Pre-select first image selectedCoverImage = images[0]; elements.editImageUrl.value = images[0]; images.forEach((imgSrc, index) => { const option = document.createElement('div'); option.className = `image-option ${index === 0 ? 'selected' : ''}`; option.innerHTML = `extracted thumbnail`; option.addEventListener('click', () => { elements.imageSelectionGrid.querySelectorAll('.image-option').forEach(el => el.classList.remove('selected')); option.classList.add('selected'); selectedCoverImage = imgSrc; elements.editImageUrl.value = imgSrc; }); elements.imageSelectionGrid.appendChild(option); }); } function selectImageThumbnail(url) { elements.imageSelectionGrid.querySelectorAll('.image-option').forEach(el => { const img = el.querySelector('img'); if (img && img.src === url) { el.classList.add('selected'); } else { el.classList.remove('selected'); } }); } // ------------------------------------------------------------- // DASHBOARD RENDERING // ------------------------------------------------------------- function renderLibrary() { elements.recipesGrid.innerHTML = ''; const searchVal = elements.librarySearch.value.toLowerCase().trim(); const filtered = allRecipes.filter(recipe => { return recipe.title.toLowerCase().includes(searchVal) || (recipe.description && recipe.description.toLowerCase().includes(searchVal)) || (recipe.ingredients && recipe.ingredients.some(i => i.toLowerCase().includes(searchVal))); }); if (filtered.length === 0) { elements.emptyState.style.display = 'flex'; return; } elements.emptyState.style.display = 'none'; filtered.forEach(recipe => { const domain = recipe.sourceUrl ? new URL(recipe.sourceUrl).hostname.replace('www.', '') : 'Manual Recipe'; const card = document.createElement('div'); card.className = 'recipe-card'; card.addEventListener('click', () => showRecipeDetails(recipe)); let imgBlock = ''; if (recipe.image) { imgBlock = `${recipe.title}`; } else { imgBlock = `
No dish photo
`; } card.innerHTML = `
${imgBlock}
${domain}

${recipe.title}

${recipe.description || 'No description available for this recipe.'}

${recipe.servings ? ` ${recipe.servings} Servings` : ''} ${recipe.totalTime ? ` ${recipe.totalTime}` : ''}
`; elements.recipesGrid.appendChild(card); }); lucide.createIcons(); } function filterRecipes() { renderLibrary(); } // ------------------------------------------------------------- // DYNAMIC SERVINGS QUANTITY SCALER // ------------------------------------------------------------- function scaleIngredientText(text, multiplier) { if (multiplier === 1) return text; // Mixed number regex: e.g. "1 1/2" or "2 1/4" const mixedRegex = /^(\d+)\s+(\d+\/\d+)\s*(.*)/; const mixedMatch = text.match(mixedRegex); if (mixedMatch) { const whole = parseInt(mixedMatch[1], 10); const fracParts = mixedMatch[2].split('/'); const fracVal = parseInt(fracParts[0], 10) / parseInt(fracParts[1], 10); const total = (whole + fracVal) * multiplier; return `${formatScaledNumber(total)} ${mixedMatch[3]}`; } // Pure Fraction regex: e.g. "1/2" or "3/4" const fracRegex = /^(\d+\/\d+)\s*(.*)/; const fracMatch = text.match(fracRegex); if (fracMatch) { const parts = fracMatch[1].split('/'); const val = (parseInt(parts[0], 10) / parseInt(parts[1], 10)) * multiplier; return `${formatScaledNumber(val)} ${fracMatch[2]}`; } // Decimals & Integers regex: e.g. "1.5" or "4" const numRegex = /^(\d+(?:\.\d+)?)\s*(.*)/; const numMatch = text.match(numRegex); if (numMatch) { const val = parseFloat(numMatch[1]) * multiplier; return `${formatScaledNumber(val)} ${numMatch[2]}`; } return text; } function formatScaledNumber(num) { if (num % 1 === 0) return num.toString(); const rounded = Math.round(num * 100) / 100; const decimal = rounded % 1; const integer = Math.floor(rounded); // Common fraction mappings for nice reading if (Math.abs(decimal - 0.25) < 0.05) return (integer > 0 ? integer + ' ' : '') + '1/4'; if (Math.abs(decimal - 0.5) < 0.05) return (integer > 0 ? integer + ' ' : '') + '1/2'; if (Math.abs(decimal - 0.75) < 0.05) return (integer > 0 ? integer + ' ' : '') + '3/4'; if (Math.abs(decimal - 0.33) < 0.05) return (integer > 0 ? integer + ' ' : '') + '1/3'; if (Math.abs(decimal - 0.66) < 0.05) return (integer > 0 ? integer + ' ' : '') + '2/3'; return rounded.toString(); } function adjustMultiplier(change) { const newMulti = currentMultiplier + change; if (newMulti < 0.5 || newMulti > 10) return; currentMultiplier = newMulti; elements.currentServingsVal.textContent = `${currentMultiplier}x`; renderScaledIngredients(); } function renderScaledIngredients() { elements.recipeDetailIngredients.innerHTML = ''; if (!activeRecipe || !activeRecipe.ingredients) return; activeRecipe.ingredients.forEach((ing, index) => { const scaledText = scaleIngredientText(ing, currentMultiplier); const li = document.createElement('li'); li.className = 'ingredient-checkbox-item'; li.innerHTML = `
${scaledText} `; li.addEventListener('click', () => { li.classList.toggle('checked'); }); elements.recipeDetailIngredients.appendChild(li); }); lucide.createIcons(); } // ------------------------------------------------------------- // RECIPE DETAIL VIEWER COMPONENT // ------------------------------------------------------------- function showRecipeDetails(recipe) { activeRecipe = recipe; currentMultiplier = 1; elements.currentServingsVal.textContent = '1x'; // Set hero cover banner image if (recipe.image) { elements.recipeDetailBanner.style.backgroundImage = `url('${recipe.image}')`; } else { elements.recipeDetailBanner.style.backgroundImage = 'none'; elements.recipeDetailBanner.style.backgroundColor = 'var(--bg-app)'; } elements.recipeDetailTitle.textContent = recipe.title; elements.recipeDetailDescription.textContent = recipe.description || 'No description available for this recipe.'; // Setup meta tags if (recipe.servings) { elements.tagServings.style.display = 'flex'; elements.tagServings.querySelector('.val').textContent = `${recipe.servings} Servings`; } else { elements.tagServings.style.display = 'none'; } if (recipe.prepTime) { elements.tagPrep.style.display = 'flex'; elements.tagPrep.querySelector('.val').textContent = recipe.prepTime; } else { elements.tagPrep.style.display = 'none'; } if (recipe.cookTime) { elements.tagCook.style.display = 'flex'; elements.tagCook.querySelector('.val').textContent = recipe.cookTime; } else { elements.tagCook.style.display = 'none'; } // Populate Ingredients with scaler renderScaledIngredients(); // Populate Directions Steps elements.recipeDetailInstructions.innerHTML = ''; if (recipe.instructions) { recipe.instructions.forEach((step, index) => { const li = document.createElement('li'); // Look for section headers (e.g. ### Sauce) if (step.startsWith('###')) { li.className = 'instruction-step-item section-header-step'; li.innerHTML = `
${step.replace('###', '').trim()}
`; } else { li.className = 'instruction-step-item'; li.innerHTML = `
${index + 1}
${step}
`; li.addEventListener('click', () => { li.classList.toggle('checked'); }); } elements.recipeDetailInstructions.appendChild(li); }); } openModal('detail-modal'); } // ------------------------------------------------------------- // INTERACTIVE FULLSCREEN COOKING ASSISTANT // ------------------------------------------------------------- function enterCookingMode() { if (!activeRecipe || !activeRecipe.instructions || activeRecipe.instructions.length === 0) return; elements.cookRecipeTitle.textContent = activeRecipe.title; cookingActiveStep = 0; // Filter out header steps from the cooking carousel to keep steps clean const cleanInstructions = activeRecipe.instructions.filter(step => !step.startsWith('###')); if (cleanInstructions.length === 0) return; elements.cookingModeModal.classList.add('active'); document.body.style.overflow = 'hidden'; // Lock background scrolling renderCookingStep(cleanInstructions); // Reset Timer resetTimer(); startTimer(); } function exitCookingMode() { elements.cookingModeModal.classList.remove('active'); document.body.style.overflow = 'auto'; // Unlock background scrolling stopTimer(); } function renderCookingStep(instructions) { const stepText = instructions[cookingActiveStep]; elements.cookStepIndex.textContent = `Step ${cookingActiveStep + 1} of ${instructions.length}`; elements.cookStepInstruction.textContent = stepText; // Update progress bar const progressPercent = ((cookingActiveStep + 1) / instructions.length) * 100; elements.cookProgressBar.style.width = `${progressPercent}%`; // Render step navigation dot indicators elements.cookStepDots.innerHTML = ''; instructions.forEach((_, idx) => { const dot = document.createElement('div'); dot.className = `dot ${idx === cookingActiveStep ? 'active' : ''}`; dot.addEventListener('click', () => { cookingActiveStep = idx; renderCookingStep(instructions); }); elements.cookStepDots.appendChild(dot); }); // Enable/disable buttons elements.cookBtnPrev.disabled = cookingActiveStep === 0; const nextBtnIconWrapper = elements.cookBtnNext.querySelector('.btn-icon-wrapper'); if (cookingActiveStep === instructions.length - 1) { elements.cookBtnNext.querySelector('span').textContent = 'Finish Cooking'; if (nextBtnIconWrapper) nextBtnIconWrapper.innerHTML = ''; } else { elements.cookBtnNext.querySelector('span').textContent = 'Next Step'; if (nextBtnIconWrapper) nextBtnIconWrapper.innerHTML = ''; } lucide.createIcons(); } function prevCookingStep() { const cleanInstructions = activeRecipe.instructions.filter(step => !step.startsWith('###')); if (cookingActiveStep > 0) { cookingActiveStep--; renderCookingStep(cleanInstructions); } } function nextCookingStep() { const cleanInstructions = activeRecipe.instructions.filter(step => !step.startsWith('###')); if (cookingActiveStep < cleanInstructions.length - 1) { cookingActiveStep++; renderCookingStep(cleanInstructions); } else { // Finished cooking exitCookingMode(); alert('Congratulations! Enjoy your delicious meal.'); } } // Timer/Stopwatch Functions function startTimer() { if (stopwatchInterval) return; elements.cookTimerPlay.innerHTML = ''; lucide.createIcons(); stopwatchInterval = setInterval(() => { stopwatchSeconds++; const mins = Math.floor(stopwatchSeconds / 60).toString().padStart(2, '0'); const secs = (stopwatchSeconds % 60).toString().padStart(2, '0'); elements.cookTimerVal.textContent = `${mins}:${secs}`; }, 1000); } function stopTimer() { if (stopwatchInterval) { clearInterval(stopwatchInterval); stopwatchInterval = null; } elements.cookTimerPlay.innerHTML = ''; lucide.createIcons(); } function toggleTimer() { if (stopwatchInterval) { stopTimer(); } else { startTimer(); } } function resetTimer() { stopTimer(); stopwatchSeconds = 0; elements.cookTimerVal.textContent = '00:00'; }