// ForkFolio Shared Recipe Viewer Logic let activeRecipe = null; let currentMultiplier = 1; let stopwatchInterval = null; let stopwatchSeconds = 0; let cookingActiveStep = 0; // DOM Cache const elements = { themeToggle: document.getElementById('theme-toggle'), shareRecipeBlock: document.getElementById('share-recipe-block'), recipeNotFoundBlock: document.getElementById('recipe-not-found-block'), // Recipe Details recipeDetailBanner: document.getElementById('recipe-detail-banner'), recipeDetailTitle: document.getElementById('recipe-detail-title'), recipeDetailCreatorName: document.getElementById('recipe-detail-creator-name'), tagServings: document.getElementById('tag-servings'), tagPrep: document.getElementById('tag-prep'), tagCook: document.getElementById('tag-cook'), tagTotal: document.getElementById('tag-total'), tagSource: document.getElementById('tag-source'), recipeDetailDescription: document.getElementById('recipe-detail-description'), recipeDetailIngredients: document.getElementById('recipe-detail-ingredients'), recipeDetailInstructions: document.getElementById('recipe-detail-instructions'), // Scaler 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') }; // Start Lifecycle window.addEventListener('DOMContentLoaded', () => { initTheme(); registerEvents(); fetchSharedRecipe(); }); 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'); } function registerEvents() { elements.themeToggle.addEventListener('click', toggleTheme); // Portions Adjustments elements.scalerMinus.addEventListener('click', () => adjustMultiplier(-0.5)); elements.scalerPlus.addEventListener('click', () => adjustMultiplier(0.5)); // Cooking steppers triggers elements.btnTriggerCookmode.addEventListener('click', enterCookingMode); elements.cookBtnPrev.addEventListener('click', prevCookingStep); elements.cookBtnNext.addEventListener('click', nextCookingStep); elements.cookTimerPlay.addEventListener('click', toggleTimer); elements.cookTimerReset.addEventListener('click', resetTimer); // Hotkeys for cooking stepper 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(); } } }); } // Fetch public shared recipe async function fetchSharedRecipe() { const token = new URLSearchParams(window.location.search).get('token'); if (!token) { showErrorBlock(); return; } try { const response = await fetch(`/api/share/recipes/${token}`); if (!response.ok) { throw new Error('Not found'); } activeRecipe = await response.json(); renderRecipePage(activeRecipe); } catch (err) { console.error('Failed to load shared recipe:', err); showErrorBlock(); } } function showErrorBlock() { elements.recipeNotFoundBlock.style.display = 'flex'; elements.shareRecipeBlock.style.display = 'none'; lucide.createIcons(); } function renderRecipePage(recipe) { elements.recipeNotFoundBlock.style.display = 'none'; elements.shareRecipeBlock.style.display = 'block'; // Banner Image Cover 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.recipeDetailCreatorName.textContent = `@${recipe.createdBy || 'gourmet_system'}`; elements.recipeDetailDescription.textContent = recipe.description || 'No description available for this recipe.'; // Servings and timings 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'; } const calculatedTotal = calculateTotalTime(recipe.prepTime, recipe.cookTime, recipe.totalTime); if (calculatedTotal) { elements.tagTotal.style.display = 'flex'; elements.tagTotal.querySelector('.val').textContent = calculatedTotal; } else { elements.tagTotal.style.display = 'none'; } if (recipe.sourceUrl) { elements.tagSource.style.display = 'flex'; elements.tagSource.href = recipe.sourceUrl; } else { elements.tagSource.style.display = 'none'; } // Render lists renderScaledIngredients(); renderInstructions(); lucide.createIcons(); } // DURATION & TOTAL TIME CALCULATION UTILITIES function parseDurationToMinutes(str) { if (!str || typeof str !== 'string') return 0; const s = str.trim(); if (!s) return 0; if (s.startsWith('P') || s.startsWith('p')) { const isoMatch = s.match(/P(?:(\d+)D)?T?(?:(\d+(?:\.\d+)?)H)?(?:(\d+)M)?(?:(\d+)S)?/i); if (isoMatch) { const days = parseInt(isoMatch[1] || '0', 10); const hours = parseFloat(isoMatch[2] || '0'); const mins = parseInt(isoMatch[3] || '0', 10); const secs = parseInt(isoMatch[4] || '0', 10); const totalSecs = (days * 86400) + Math.round(hours * 3600) + (mins * 60) + secs; if (totalSecs > 0) return Math.round(totalSecs / 60); } } let totalMinutes = 0; let matched = false; const hrMatch = s.match(/(\d+(?:\.\d+)?)\s*(?:hours?|hrs?|h\b)/i); if (hrMatch) { totalMinutes += Math.round(parseFloat(hrMatch[1]) * 60); matched = true; } const minMatch = s.match(/(?:(\d+)\s*[-–to]+\s*)?(\d+)\s*(?:minutes?|mins?|m\b)/i); if (minMatch) { const m = parseInt(minMatch[2], 10); if (!isNaN(m)) { totalMinutes += m; matched = true; } } if (!matched && /^\d+$/.test(s)) { return parseInt(s, 10); } return totalMinutes; } function formatMinutes(minutes) { if (!minutes || minutes <= 0) return ''; const hrs = Math.floor(minutes / 60); const mins = minutes % 60; if (hrs > 0 && mins > 0) { return `${hrs} hr${hrs > 1 ? 's' : ''} ${mins} min${mins > 1 ? 's' : ''}`; } if (hrs > 0) { return `${hrs} hr${hrs > 1 ? 's' : ''}`; } return `${mins} min${mins > 1 ? 's' : ''}`; } function calculateTotalTime(prepTime, cookTime, existingTotalTime) { if (existingTotalTime && existingTotalTime.trim()) { if (existingTotalTime.startsWith('P') || existingTotalTime.startsWith('p')) { const mins = parseDurationToMinutes(existingTotalTime); if (mins > 0) return formatMinutes(mins); } return existingTotalTime.trim(); } const prepM = parseDurationToMinutes(prepTime); const cookM = parseDurationToMinutes(cookTime); if (prepM > 0 || cookM > 0) { return formatMinutes(prepM + cookM); } if (cookTime && cookTime.trim()) { const cookMOnly = parseDurationToMinutes(cookTime); return cookMOnly > 0 ? formatMinutes(cookMOnly) : cookTime.trim(); } if (prepTime && prepTime.trim()) { const prepMOnly = parseDurationToMinutes(prepTime); return prepMOnly > 0 ? formatMinutes(prepMOnly) : prepTime.trim(); } return ''; } // Ingredient Scaling Algorithms 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) => { 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(); } function scaleIngredientText(text, multiplier) { if (multiplier === 1) return text; 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]}`; } 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]}`; } 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); 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(); } // Render Directions Steps function renderInstructions() { elements.recipeDetailInstructions.innerHTML = ''; if (!activeRecipe || !activeRecipe.instructions) return; activeRecipe.instructions.forEach((step, index) => { const li = document.createElement('li'); 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); }); } // Standalone Cooking Assistant Mode function enterCookingMode() { if (!activeRecipe || !activeRecipe.instructions || activeRecipe.instructions.length === 0) return; elements.cookRecipeTitle.textContent = activeRecipe.title; cookingActiveStep = 0; // Filter out sub-section headers (###) const cleanInstructions = activeRecipe.instructions.filter(step => !step.startsWith('###')); if (cleanInstructions.length === 0) return; elements.cookingModeModal.classList.add('active'); document.body.style.overflow = 'hidden'; renderCookingStep(cleanInstructions); resetTimer(); startTimer(); } function exitCookingMode() { elements.cookingModeModal.classList.remove('active'); document.body.style.overflow = 'auto'; 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}%`; // Navigation dots 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 navigation 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 { exitCookingMode(); } } // Timer Stopwatch Controls 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'; }