recipe-manager/public/share.js

476 lines
16 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 = `
<div class="checkbox-custom"><i data-lucide="check" style="width:12px;height:12px;"></i></div>
<span class="ingredient-text">${scaledText}</span>
`;
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 = `<div class="step-text-block">${step.replace('###', '').trim()}</div>`;
} else {
li.className = 'instruction-step-item';
li.innerHTML = `
<div class="step-number-bullet">${index + 1}</div>
<div class="step-text-block">${step}</div>
`;
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 = '<i data-lucide="check"></i>';
} else {
elements.cookBtnNext.querySelector('span').textContent = 'Next Step';
if (nextBtnIconWrapper) nextBtnIconWrapper.innerHTML = '<i data-lucide="chevron-right"></i>';
}
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 = '<i data-lucide="pause"></i>';
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 = '<i data-lucide="play"></i>';
lucide.createIcons();
}
function toggleTimer() {
if (stopwatchInterval) {
stopTimer();
} else {
startTimer();
}
}
function resetTimer() {
stopTimer();
stopwatchSeconds = 0;
elements.cookTimerVal.textContent = '00:00';
}