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