recipe-manager/public/share.js

387 lines
13 KiB
JavaScript

// 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'),
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';
}
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();
}
// 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';
}