mirror of
https://gitlab.com/Zer0interrupt/recipe-manager.git
synced 2026-11-01 23:24:11 -05:00
476 lines
16 KiB
JavaScript
476 lines
16 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'),
|
||
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';
|
||
}
|