mirror of
https://gitlab.com/Zer0interrupt/recipe-manager.git
synced 2026-11-01 23:24:11 -05:00
813 lines
27 KiB
JavaScript
813 lines
27 KiB
JavaScript
// GourmetVault client application state
|
|
let allRecipes = [];
|
|
let activeRecipe = null;
|
|
let currentMultiplier = 1;
|
|
let selectedCoverImage = '';
|
|
let stopwatchInterval = null;
|
|
let stopwatchSeconds = 0;
|
|
let cookingActiveStep = 0;
|
|
|
|
// Page elements caching
|
|
const elements = {
|
|
themeToggle: document.getElementById('theme-toggle'),
|
|
urlScrapeForm: document.getElementById('url-scrape-form'),
|
|
scrapeUrlInput: document.getElementById('scrape-url-input'),
|
|
btnScrapeSubmit: document.getElementById('btn-scrape-submit'),
|
|
btnManualAdd: document.getElementById('btn-manual-add'),
|
|
recipesGrid: document.getElementById('recipes-grid'),
|
|
emptyState: document.getElementById('empty-state'),
|
|
librarySearch: document.getElementById('library-search'),
|
|
|
|
// Scrape / Edit Modal
|
|
saveModal: document.getElementById('save-modal'),
|
|
recipeEditForm: document.getElementById('recipe-edit-form'),
|
|
editRecipeId: document.getElementById('edit-recipe-id'),
|
|
editTitle: document.getElementById('edit-title'),
|
|
editSource: document.getElementById('edit-source'),
|
|
editDescription: document.getElementById('edit-description'),
|
|
editServings: document.getElementById('edit-servings'),
|
|
editPrepTime: document.getElementById('edit-prep-time'),
|
|
editCookTime: document.getElementById('edit-cook-time'),
|
|
imageSelectionGrid: document.getElementById('image-selection-grid'),
|
|
editImageUrl: document.getElementById('edit-image-url'),
|
|
editIngredients: document.getElementById('edit-ingredients'),
|
|
editInstructions: document.getElementById('edit-instructions'),
|
|
modalActionTitle: document.getElementById('modal-action-title'),
|
|
btnSaveConfirm: document.getElementById('btn-save-confirm'),
|
|
|
|
// Detail Modal
|
|
detailModal: document.getElementById('detail-modal'),
|
|
recipeDetailBanner: document.getElementById('recipe-detail-banner'),
|
|
btnDetailEdit: document.getElementById('btn-detail-edit'),
|
|
btnDetailDelete: document.getElementById('btn-detail-delete'),
|
|
recipeDetailTitle: document.getElementById('recipe-detail-title'),
|
|
tagServings: document.getElementById('tag-servings'),
|
|
tagPrep: document.getElementById('tag-prep'),
|
|
tagCook: document.getElementById('tag-cook'),
|
|
recipeDetailDescription: document.getElementById('recipe-detail-description'),
|
|
recipeDetailIngredients: document.getElementById('recipe-detail-ingredients'),
|
|
recipeDetailInstructions: document.getElementById('recipe-detail-instructions'),
|
|
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'),
|
|
|
|
// Loader
|
|
loadingOverlay: document.getElementById('loading-overlay'),
|
|
loaderStepFetch: document.getElementById('loader-step-fetch'),
|
|
loaderStepParse: document.getElementById('loader-step-parse'),
|
|
loaderStepImages: document.getElementById('loader-step-images')
|
|
};
|
|
|
|
// -------------------------------------------------------------
|
|
// LIFE CYCLE AND REGISTRATION
|
|
// -------------------------------------------------------------
|
|
|
|
window.addEventListener('DOMContentLoaded', () => {
|
|
initTheme();
|
|
fetchRecipes();
|
|
registerEvents();
|
|
});
|
|
|
|
function registerEvents() {
|
|
// Theme Toggle
|
|
elements.themeToggle.addEventListener('click', toggleTheme);
|
|
|
|
// URL Form Submission
|
|
elements.urlScrapeForm.addEventListener('submit', handleScrapeSubmit);
|
|
|
|
// Manual Add Form Opening
|
|
elements.btnManualAdd.addEventListener('click', openManualAdd);
|
|
|
|
// Library Search
|
|
elements.librarySearch.addEventListener('input', filterRecipes);
|
|
|
|
// Recipe Form Save
|
|
elements.recipeEditForm.addEventListener('submit', handleRecipeSave);
|
|
|
|
// Action Buttons Detail Modal
|
|
elements.btnDetailEdit.addEventListener('click', editActiveRecipe);
|
|
elements.btnDetailDelete.addEventListener('click', deleteActiveRecipe);
|
|
|
|
// Servings Scaler Buttons
|
|
elements.scalerMinus.addEventListener('click', () => adjustMultiplier(-0.5));
|
|
elements.scalerPlus.addEventListener('click', () => adjustMultiplier(0.5));
|
|
|
|
// Cooking Mode Trigger
|
|
elements.btnTriggerCookmode.addEventListener('click', enterCookingMode);
|
|
|
|
// Cooking Controls
|
|
elements.cookBtnPrev.addEventListener('click', prevCookingStep);
|
|
elements.cookBtnNext.addEventListener('click', nextCookingStep);
|
|
elements.cookTimerPlay.addEventListener('click', toggleTimer);
|
|
elements.cookTimerReset.addEventListener('click', resetTimer);
|
|
|
|
// Global Modals click to dismiss on overlay
|
|
document.querySelectorAll('.modal-overlay').forEach(overlay => {
|
|
overlay.addEventListener('click', (e) => {
|
|
if (e.target === overlay && !overlay.classList.contains('loading-overlay')) {
|
|
closeModal(overlay.id);
|
|
}
|
|
});
|
|
});
|
|
|
|
// Global key bindings for cooking mode
|
|
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();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// -------------------------------------------------------------
|
|
// UTILITIES: SYSTEM THEMES
|
|
// -------------------------------------------------------------
|
|
|
|
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');
|
|
}
|
|
|
|
// -------------------------------------------------------------
|
|
// LOADING STATUS MACHINE
|
|
// -------------------------------------------------------------
|
|
|
|
function setLoaderStepIcon(stepElement, iconName, spin = false) {
|
|
const wrapper = stepElement.querySelector('.step-icon-wrapper');
|
|
if (wrapper) {
|
|
wrapper.innerHTML = `<i data-lucide="${iconName}" class="${spin ? 'spinning-icon' : ''}"></i>`;
|
|
}
|
|
}
|
|
|
|
function showScrapeLoading() {
|
|
elements.loadingOverlay.classList.add('active');
|
|
|
|
// Reset Steps to default
|
|
elements.loaderStepFetch.className = 'loader-step active';
|
|
elements.loaderStepParse.className = 'loader-step';
|
|
elements.loaderStepImages.className = 'loader-step';
|
|
|
|
setLoaderStepIcon(elements.loaderStepFetch, 'circle-dashed', true);
|
|
setLoaderStepIcon(elements.loaderStepParse, 'circle', false);
|
|
setLoaderStepIcon(elements.loaderStepImages, 'circle', false);
|
|
|
|
lucide.createIcons();
|
|
|
|
// Simulate process transitions
|
|
setTimeout(() => {
|
|
if (!elements.loadingOverlay.classList.contains('active')) return;
|
|
elements.loaderStepFetch.className = 'loader-step completed';
|
|
setLoaderStepIcon(elements.loaderStepFetch, 'check-circle-2', false);
|
|
|
|
elements.loaderStepParse.className = 'loader-step active';
|
|
setLoaderStepIcon(elements.loaderStepParse, 'circle-dashed', true);
|
|
lucide.createIcons();
|
|
}, 1800);
|
|
|
|
setTimeout(() => {
|
|
if (!elements.loadingOverlay.classList.contains('active')) return;
|
|
elements.loaderStepParse.className = 'loader-step completed';
|
|
setLoaderStepIcon(elements.loaderStepParse, 'check-circle-2', false);
|
|
|
|
elements.loaderStepImages.className = 'loader-step active';
|
|
setLoaderStepIcon(elements.loaderStepImages, 'circle-dashed', true);
|
|
lucide.createIcons();
|
|
}, 3500);
|
|
}
|
|
|
|
function hideScrapeLoading() {
|
|
elements.loadingOverlay.classList.remove('active');
|
|
}
|
|
|
|
// -------------------------------------------------------------
|
|
// CORE MODAL ROUTER
|
|
// -------------------------------------------------------------
|
|
|
|
function openModal(id) {
|
|
document.getElementById(id).classList.add('active');
|
|
}
|
|
|
|
function closeModal(id) {
|
|
document.getElementById(id).classList.remove('active');
|
|
if (id === 'cooking-mode-modal') {
|
|
exitCookingMode();
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------
|
|
// CRUD & API HANDLERS
|
|
// -------------------------------------------------------------
|
|
|
|
async function fetchRecipes() {
|
|
try {
|
|
const response = await fetch('/api/recipes');
|
|
allRecipes = await response.json();
|
|
renderLibrary();
|
|
} catch (error) {
|
|
console.error('Error fetching recipes library:', error);
|
|
}
|
|
}
|
|
|
|
// Scrape link form submission
|
|
async function handleScrapeSubmit(e) {
|
|
e.preventDefault();
|
|
const url = elements.scrapeUrlInput.value.trim();
|
|
if (!url) return;
|
|
|
|
showScrapeLoading();
|
|
|
|
try {
|
|
const response = await fetch('/api/scrape', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ url })
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json().catch(() => ({}));
|
|
if (response.status === 403 || errorData.isBlocked) {
|
|
throw new Error('BLOCKED');
|
|
}
|
|
throw new Error(errorData.error || 'Extraction error.');
|
|
}
|
|
|
|
const data = await response.json();
|
|
hideScrapeLoading();
|
|
openDraftPreview(data);
|
|
elements.scrapeUrlInput.value = '';
|
|
} catch (error) {
|
|
hideScrapeLoading();
|
|
if (error.message === 'BLOCKED') {
|
|
alert(`Access Blocked by Site Protection\n\nThis website uses Cloudflare or bot-defense screens to prevent automated scraping.\n\nDon't worry! You can still add this recipe by clicking the "Manual Recipe" button at the top to quickly paste your ingredients and directions.`);
|
|
} else {
|
|
alert(`Scraping failed: ${error.message}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Save or Edit submit
|
|
async function handleRecipeSave(e) {
|
|
e.preventDefault();
|
|
|
|
const id = elements.editRecipeId.value;
|
|
const recipePayload = {
|
|
title: elements.editTitle.value.trim(),
|
|
sourceUrl: elements.editSource.value.trim(),
|
|
description: elements.editDescription.value.trim(),
|
|
servings: elements.editServings.value.trim(),
|
|
prepTime: elements.editPrepTime.value.trim(),
|
|
cookTime: elements.editCookTime.value.trim(),
|
|
image: elements.editImageUrl.value.trim(),
|
|
images: Array.from(elements.imageSelectionGrid.querySelectorAll('.image-option img')).map(img => img.src),
|
|
ingredients: elements.editIngredients.value.trim().split(/\r?\n/).map(line => line.trim()).filter(Boolean),
|
|
instructions: elements.editInstructions.value.trim().split(/\r?\n/).map(line => line.trim()).filter(Boolean)
|
|
};
|
|
|
|
try {
|
|
let response;
|
|
if (id) {
|
|
// Edit
|
|
response = await fetch(`/api/recipes/${id}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(recipePayload)
|
|
});
|
|
} else {
|
|
// Create
|
|
response = await fetch('/api/recipes', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(recipePayload)
|
|
});
|
|
}
|
|
|
|
if (!response.ok) throw new Error('Saving failed.');
|
|
|
|
const saved = await response.json();
|
|
closeModal('save-modal');
|
|
fetchRecipes();
|
|
|
|
// If active view was open, update it
|
|
if (activeRecipe && activeRecipe.id === saved.id) {
|
|
showRecipeDetails(saved);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error saving recipe:', error);
|
|
alert('Error saving recipe.');
|
|
}
|
|
}
|
|
|
|
// Delete Active Recipe
|
|
async function deleteActiveRecipe() {
|
|
if (!activeRecipe) return;
|
|
if (!confirm(`Are you sure you want to delete "${activeRecipe.title}"?`)) return;
|
|
|
|
try {
|
|
const response = await fetch(`/api/recipes/${activeRecipe.id}`, {
|
|
method: 'DELETE'
|
|
});
|
|
|
|
if (!response.ok) throw new Error('Deletion failed.');
|
|
|
|
closeModal('detail-modal');
|
|
activeRecipe = null;
|
|
fetchRecipes();
|
|
} catch (error) {
|
|
console.error('Error deleting recipe:', error);
|
|
alert('Deletion failed.');
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------
|
|
// DRAFT / MANUAL CREATOR MODAL POPULATOR
|
|
// -------------------------------------------------------------
|
|
|
|
function openManualAdd() {
|
|
elements.modalActionTitle.textContent = 'Create New Recipe';
|
|
elements.editRecipeId.value = '';
|
|
elements.recipeEditForm.reset();
|
|
elements.imageSelectionGrid.innerHTML = '';
|
|
selectedCoverImage = '';
|
|
|
|
openModal('save-modal');
|
|
}
|
|
|
|
function openDraftPreview(data) {
|
|
elements.modalActionTitle.textContent = 'Save Extracted Recipe';
|
|
elements.editRecipeId.value = '';
|
|
|
|
elements.editTitle.value = data.title || '';
|
|
elements.editSource.value = data.sourceUrl || '';
|
|
elements.editDescription.value = data.description || '';
|
|
elements.editServings.value = data.servings || '';
|
|
elements.editPrepTime.value = data.prepTime || '';
|
|
elements.editCookTime.value = data.cookTime || '';
|
|
|
|
// Ingredients text area
|
|
elements.editIngredients.value = data.ingredients ? data.ingredients.join('\n') : '';
|
|
|
|
// Instructions text area
|
|
elements.editInstructions.value = data.instructions ? data.instructions.join('\n') : '';
|
|
|
|
// Render Image Selector
|
|
renderImageSelector(data.images || []);
|
|
|
|
openModal('save-modal');
|
|
}
|
|
|
|
function editActiveRecipe() {
|
|
if (!activeRecipe) return;
|
|
|
|
elements.modalActionTitle.textContent = 'Edit Recipe Details';
|
|
elements.editRecipeId.value = activeRecipe.id;
|
|
|
|
elements.editTitle.value = activeRecipe.title || '';
|
|
elements.editSource.value = activeRecipe.sourceUrl || '';
|
|
elements.editDescription.value = activeRecipe.description || '';
|
|
elements.editServings.value = activeRecipe.servings || '';
|
|
elements.editPrepTime.value = activeRecipe.prepTime || '';
|
|
elements.editCookTime.value = activeRecipe.cookTime || '';
|
|
|
|
elements.editIngredients.value = activeRecipe.ingredients ? activeRecipe.ingredients.join('\n') : '';
|
|
elements.editInstructions.value = activeRecipe.instructions ? activeRecipe.instructions.join('\n') : '';
|
|
|
|
renderImageSelector(activeRecipe.images || []);
|
|
|
|
// Set active selection
|
|
if (activeRecipe.image) {
|
|
elements.editImageUrl.value = activeRecipe.image;
|
|
selectedCoverImage = activeRecipe.image;
|
|
selectImageThumbnail(activeRecipe.image);
|
|
}
|
|
|
|
openModal('save-modal');
|
|
}
|
|
|
|
// -------------------------------------------------------------
|
|
// IMAGE EXTRACTION CAROUSEL & SELECTOR
|
|
// -------------------------------------------------------------
|
|
|
|
function renderImageSelector(images) {
|
|
elements.imageSelectionGrid.innerHTML = '';
|
|
selectedCoverImage = '';
|
|
|
|
if (!images || images.length === 0) {
|
|
elements.editImageUrl.value = '';
|
|
return;
|
|
}
|
|
|
|
// Pre-select first image
|
|
selectedCoverImage = images[0];
|
|
elements.editImageUrl.value = images[0];
|
|
|
|
images.forEach((imgSrc, index) => {
|
|
const option = document.createElement('div');
|
|
option.className = `image-option ${index === 0 ? 'selected' : ''}`;
|
|
option.innerHTML = `<img src="${imgSrc}" alt="extracted thumbnail">`;
|
|
|
|
option.addEventListener('click', () => {
|
|
elements.imageSelectionGrid.querySelectorAll('.image-option').forEach(el => el.classList.remove('selected'));
|
|
option.classList.add('selected');
|
|
selectedCoverImage = imgSrc;
|
|
elements.editImageUrl.value = imgSrc;
|
|
});
|
|
|
|
elements.imageSelectionGrid.appendChild(option);
|
|
});
|
|
}
|
|
|
|
function selectImageThumbnail(url) {
|
|
elements.imageSelectionGrid.querySelectorAll('.image-option').forEach(el => {
|
|
const img = el.querySelector('img');
|
|
if (img && img.src === url) {
|
|
el.classList.add('selected');
|
|
} else {
|
|
el.classList.remove('selected');
|
|
}
|
|
});
|
|
}
|
|
|
|
// -------------------------------------------------------------
|
|
// DASHBOARD RENDERING
|
|
// -------------------------------------------------------------
|
|
|
|
function renderLibrary() {
|
|
elements.recipesGrid.innerHTML = '';
|
|
|
|
const searchVal = elements.librarySearch.value.toLowerCase().trim();
|
|
const filtered = allRecipes.filter(recipe => {
|
|
return recipe.title.toLowerCase().includes(searchVal) ||
|
|
(recipe.description && recipe.description.toLowerCase().includes(searchVal)) ||
|
|
(recipe.ingredients && recipe.ingredients.some(i => i.toLowerCase().includes(searchVal)));
|
|
});
|
|
|
|
if (filtered.length === 0) {
|
|
elements.emptyState.style.display = 'flex';
|
|
return;
|
|
}
|
|
|
|
elements.emptyState.style.display = 'none';
|
|
|
|
filtered.forEach(recipe => {
|
|
const domain = recipe.sourceUrl ? new URL(recipe.sourceUrl).hostname.replace('www.', '') : 'Manual Recipe';
|
|
|
|
const card = document.createElement('div');
|
|
card.className = 'recipe-card';
|
|
card.addEventListener('click', () => showRecipeDetails(recipe));
|
|
|
|
let imgBlock = '';
|
|
if (recipe.image) {
|
|
imgBlock = `<img src="${recipe.image}" class="recipe-card-img" alt="${recipe.title}" loading="lazy">`;
|
|
} else {
|
|
imgBlock = `
|
|
<div class="recipe-card-img-placeholder">
|
|
<i data-lucide="cooking-pot"></i>
|
|
<span>No dish photo</span>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
card.innerHTML = `
|
|
<div class="recipe-card-img-wrapper">
|
|
${imgBlock}
|
|
</div>
|
|
<div class="recipe-card-content">
|
|
<span class="recipe-card-origin">${domain}</span>
|
|
<h3 class="recipe-card-title">${recipe.title}</h3>
|
|
<p class="recipe-card-desc">${recipe.description || 'No description available for this recipe.'}</p>
|
|
|
|
<div class="recipe-card-meta">
|
|
${recipe.servings ? `<span><i data-lucide="users"></i> ${recipe.servings} Servings</span>` : ''}
|
|
${recipe.totalTime ? `<span><i data-lucide="clock"></i> ${recipe.totalTime}</span>` : ''}
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
elements.recipesGrid.appendChild(card);
|
|
});
|
|
|
|
lucide.createIcons();
|
|
}
|
|
|
|
function filterRecipes() {
|
|
renderLibrary();
|
|
}
|
|
|
|
// -------------------------------------------------------------
|
|
// DYNAMIC SERVINGS QUANTITY SCALER
|
|
// -------------------------------------------------------------
|
|
|
|
function scaleIngredientText(text, multiplier) {
|
|
if (multiplier === 1) return text;
|
|
|
|
// Mixed number regex: e.g. "1 1/2" or "2 1/4"
|
|
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]}`;
|
|
}
|
|
|
|
// Pure Fraction regex: e.g. "1/2" or "3/4"
|
|
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]}`;
|
|
}
|
|
|
|
// Decimals & Integers regex: e.g. "1.5" or "4"
|
|
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);
|
|
|
|
// Common fraction mappings for nice reading
|
|
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();
|
|
}
|
|
|
|
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, index) => {
|
|
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();
|
|
}
|
|
|
|
// -------------------------------------------------------------
|
|
// RECIPE DETAIL VIEWER COMPONENT
|
|
// -------------------------------------------------------------
|
|
|
|
function showRecipeDetails(recipe) {
|
|
activeRecipe = recipe;
|
|
currentMultiplier = 1;
|
|
elements.currentServingsVal.textContent = '1x';
|
|
|
|
// Set hero cover banner image
|
|
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.recipeDetailDescription.textContent = recipe.description || 'No description available for this recipe.';
|
|
|
|
// Setup meta 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';
|
|
}
|
|
|
|
// Populate Ingredients with scaler
|
|
renderScaledIngredients();
|
|
|
|
// Populate Directions Steps
|
|
elements.recipeDetailInstructions.innerHTML = '';
|
|
if (recipe.instructions) {
|
|
recipe.instructions.forEach((step, index) => {
|
|
const li = document.createElement('li');
|
|
|
|
// Look for section headers (e.g. ### Sauce)
|
|
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);
|
|
});
|
|
}
|
|
|
|
openModal('detail-modal');
|
|
}
|
|
|
|
// -------------------------------------------------------------
|
|
// INTERACTIVE FULLSCREEN COOKING ASSISTANT
|
|
// -------------------------------------------------------------
|
|
|
|
function enterCookingMode() {
|
|
if (!activeRecipe || !activeRecipe.instructions || activeRecipe.instructions.length === 0) return;
|
|
|
|
elements.cookRecipeTitle.textContent = activeRecipe.title;
|
|
cookingActiveStep = 0;
|
|
|
|
// Filter out header steps from the cooking carousel to keep steps clean
|
|
const cleanInstructions = activeRecipe.instructions.filter(step => !step.startsWith('###'));
|
|
|
|
if (cleanInstructions.length === 0) return;
|
|
|
|
elements.cookingModeModal.classList.add('active');
|
|
document.body.style.overflow = 'hidden'; // Lock background scrolling
|
|
|
|
renderCookingStep(cleanInstructions);
|
|
|
|
// Reset Timer
|
|
resetTimer();
|
|
startTimer();
|
|
}
|
|
|
|
function exitCookingMode() {
|
|
elements.cookingModeModal.classList.remove('active');
|
|
document.body.style.overflow = 'auto'; // Unlock background scrolling
|
|
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}%`;
|
|
|
|
// Render step navigation dot indicators
|
|
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 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 {
|
|
// Finished cooking
|
|
exitCookingMode();
|
|
alert('Congratulations! Enjoy your delicious meal.');
|
|
}
|
|
}
|
|
|
|
// Timer/Stopwatch Functions
|
|
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';
|
|
}
|