// ForkFolio client application state let allRecipes = []; let activeRecipe = null; let currentMultiplier = 1; let selectedCoverImage = ''; let stopwatchInterval = null; let stopwatchSeconds = 0; let cookingActiveStep = 0; let currentUser = null; let activeTab = 'my-cookbook'; // 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'), btnSettings: document.getElementById('btn-settings'), settingsModal: document.getElementById('settings-modal'), settingsApiForm: document.getElementById('settings-api-form'), settingsGeminiKeyInput: document.getElementById('settings-gemini-key'), btnToggleGeminiKey: document.getElementById('btn-toggle-gemini-key'), settingsStatusMsg: document.getElementById('settings-status-msg'), settingsStatusText: document.getElementById('settings-status-text'), scanImageInput: document.getElementById('scan-image-input'), btnScanImage: document.getElementById('btn-scan-image'), recipesGrid: document.getElementById('recipes-grid'), emptyState: document.getElementById('empty-state'), librarySearch: document.getElementById('library-search'), // Auth caching authModal: document.getElementById('auth-modal'), loginForm: document.getElementById('login-form'), authErrorMsg: document.getElementById('auth-error-msg'), authErrorText: document.getElementById('auth-error-text'), loginUsernameInput: document.getElementById('login-username'), loginPasswordInput: document.getElementById('login-password'), userBadgeContainer: document.getElementById('user-badge-container'), userDisplayName: document.getElementById('user-display-name'), btnLogout: document.getElementById('btn-logout'), tabMyCookbook: document.getElementById('tab-my-cookbook'), tabCommunityVault: document.getElementById('tab-community-vault'), tabUserAdmin: document.getElementById('tab-user-admin'), userAdminPanel: document.getElementById('user-admin-panel'), adminUsersList: document.getElementById('admin-users-list'), btnAdminAddUser: document.getElementById('btn-admin-add-user'), // Admin Create User Modal adminCreateUserModal: document.getElementById('admin-create-user-modal'), adminCreateUserForm: document.getElementById('admin-create-user-form'), adminNewUsername: document.getElementById('admin-new-username'), adminNewPassword: document.getElementById('admin-new-password'), adminCreateError: document.getElementById('admin-create-error'), adminCreateErrorText: document.getElementById('admin-create-error-text'), // Admin Delete User Modal adminDeleteUserModal: document.getElementById('admin-delete-user-modal'), deleteUserTargetId: document.getElementById('delete-user-target-id'), deleteUserTargetName: document.getElementById('delete-user-target-name'), deleteUserPreserveRecipes: document.getElementById('delete-user-preserve-recipes'), btnAdminConfirmDelete: document.getElementById('btn-admin-confirm-delete'), // 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'), editIsPublic: document.getElementById('edit-is-public'), // 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'), btnDetailClone: document.getElementById('btn-detail-clone'), recipeDetailCreator: document.getElementById('recipe-detail-creator'), recipeDetailCreatorName: document.getElementById('recipe-detail-creator-name'), recipeDetailTitle: document.getElementById('recipe-detail-title'), 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'), 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(); registerEvents(); initAuth(); }); 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); // Settings Modal Opening elements.btnSettings.addEventListener('click', openSettingsModal); elements.settingsApiForm.addEventListener('submit', handleSaveSettings); elements.btnToggleGeminiKey.addEventListener('click', toggleSettingsKeyVisibility); // Image Scanning Actions elements.btnScanImage.addEventListener('click', () => elements.scanImageInput.click()); elements.scanImageInput.addEventListener('change', handleRecipeImageScan); document.addEventListener('paste', handleClipboardPaste); // 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); elements.btnDetailClone.addEventListener('click', cloneActiveRecipe); // Auth Form Submissions elements.loginForm.addEventListener('submit', handleLogin); elements.btnLogout.addEventListener('click', logout); // Dashboard Tabs Switchers elements.tabMyCookbook.addEventListener('click', () => switchTab('my-cookbook')); elements.tabCommunityVault.addEventListener('click', () => switchTab('community-vault')); elements.tabUserAdmin.addEventListener('click', () => switchTab('user-admin')); // Admin Action Buttons & Forms elements.btnAdminAddUser.addEventListener('click', () => openModal('admin-create-user-modal')); elements.adminCreateUserForm.addEventListener('submit', handleAdminCreateUser); elements.btnAdminConfirmDelete.addEventListener('click', handleAdminDeleteUser); // 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 = ``; } } 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(); } } // Helper: get authentication headers for fetches function getAuthHeaders() { const token = localStorage.getItem('gourmet_token'); return token ? { 'Authorization': `Bearer ${token}` } : {}; } // ------------------------------------------------------------- // CRUD & API HANDLERS // ------------------------------------------------------------- async function fetchRecipes() { try { let url = '/api/recipes'; if (activeTab === 'community-vault') { url = '/api/recipes/public'; } const response = await fetch(url, { headers: getAuthHeaders() }); if (response.status === 401) { logout(); return; } if (!response.ok) { throw new Error('Failed to load 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', ...getAuthHeaders() }, body: JSON.stringify({ url }) }); if (response.status === 401) { logout(); hideScrapeLoading(); return; } 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(), isPublic: elements.editIsPublic.checked, 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', ...getAuthHeaders() }, body: JSON.stringify(recipePayload) }); } else { // Create response = await fetch('/api/recipes', { method: 'POST', headers: { 'Content-Type': 'application/json', ...getAuthHeaders() }, body: JSON.stringify(recipePayload) }); } if (response.status === 401) { logout(); return; } 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', headers: getAuthHeaders() }); if (response.status === 401) { logout(); return; } 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.'); } } // Clone a Community Vault recipe to current user's collection async function cloneActiveRecipe() { if (!activeRecipe) return; try { const response = await fetch(`/api/recipes/clone/${activeRecipe.id}`, { method: 'POST', headers: getAuthHeaders() }); if (response.status === 401) { logout(); return; } if (!response.ok) { const err = await response.json().catch(() => ({})); throw new Error(err.error || 'Cloning failed.'); } const cloned = await response.json(); alert(`Successfully cloned "${activeRecipe.title}" into your cookbook!`); closeModal('detail-modal'); // Switch back to "My Cookbook" tab to let them see it immediately switchTab('my-cookbook'); } catch (error) { console.error('Error cloning recipe:', error); alert(`Error cloning recipe: ${error.message}`); } } // ------------------------------------------------------------- // DRAFT / MANUAL CREATOR MODAL POPULATOR // ------------------------------------------------------------- function openManualAdd() { elements.modalActionTitle.textContent = 'Create New Recipe'; elements.editRecipeId.value = ''; elements.recipeEditForm.reset(); elements.imageSelectionGrid.innerHTML = ''; selectedCoverImage = ''; elements.editIsPublic.checked = true; 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 || ''; elements.editIsPublic.checked = true; // 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.editIsPublic.checked = activeRecipe.isPublic !== false; 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 = `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 = `${recipe.title}`; } else { imgBlock = `
No dish photo
`; } let creatorBadge = ''; if (activeTab === 'community-vault') { const creator = recipe.createdBy || 'gourmet_system'; creatorBadge = `
@${creator}
`; } card.innerHTML = `
${imgBlock}
${domain}

${recipe.title}

${creatorBadge}

${recipe.description || 'No description available for this recipe.'}

${recipe.servings ? ` ${recipe.servings} Servings` : ''} ${recipe.totalTime ? ` ${recipe.totalTime}` : ''}
`; 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 = `
${scaledText} `; 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.'; // Ownership and Permission controls const isOwner = recipe.userId === currentUser.id; const canEdit = isOwner || currentUser.isRecipeManager || currentUser.isAdmin; if (canEdit) { elements.btnDetailEdit.style.display = 'flex'; elements.btnDetailDelete.style.display = 'flex'; } else { elements.btnDetailEdit.style.display = 'none'; elements.btnDetailDelete.style.display = 'none'; } if (isOwner) { elements.btnDetailClone.style.display = 'none'; elements.recipeDetailCreator.style.display = 'none'; } else { elements.btnDetailClone.style.display = 'flex'; elements.recipeDetailCreator.style.display = 'inline-flex'; elements.recipeDetailCreatorName.textContent = `@${recipe.createdBy || 'gourmet_system'}`; } // 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'; } if (recipe.sourceUrl) { elements.tagSource.style.display = 'flex'; elements.tagSource.href = recipe.sourceUrl; } else { elements.tagSource.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 = `
${step.replace('###', '').trim()}
`; } else { li.className = 'instruction-step-item'; li.innerHTML = `
${index + 1}
${step}
`; li.addEventListener('click', () => { li.classList.toggle('checked'); }); } elements.recipeDetailInstructions.appendChild(li); }); } lucide.createIcons(); 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 = ''; } else { elements.cookBtnNext.querySelector('span').textContent = 'Next Step'; if (nextBtnIconWrapper) nextBtnIconWrapper.innerHTML = ''; } 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(); } } // Timer/Stopwatch Functions function startTimer() { if (stopwatchInterval) return; elements.cookTimerPlay.innerHTML = ''; 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 = ''; lucide.createIcons(); } function toggleTimer() { if (stopwatchInterval) { stopTimer(); } else { startTimer(); } } function resetTimer() { stopTimer(); stopwatchSeconds = 0; elements.cookTimerVal.textContent = '00:00'; } // ------------------------------------------------------------- // USER AUTHENTICATION & SESSION MANAGEMENT // ------------------------------------------------------------- function initAuth() { const token = localStorage.getItem('gourmet_token'); const userJson = localStorage.getItem('gourmet_user'); if (token && userJson) { currentUser = JSON.parse(userJson); elements.authModal.classList.remove('active'); elements.authModal.style.display = 'none'; elements.userBadgeContainer.style.display = 'flex'; elements.userDisplayName.textContent = `@${currentUser.username}`; // Toggle Admin visibility if (currentUser.isAdmin) { elements.tabUserAdmin.style.display = 'flex'; } else { elements.tabUserAdmin.style.display = 'none'; } // Reset view to My Cookbook on init switchTab('my-cookbook'); } else { elements.authModal.classList.add('active'); elements.authModal.style.display = 'flex'; elements.userBadgeContainer.style.display = 'none'; elements.tabUserAdmin.style.display = 'none'; } } async function handleLogin(e) { e.preventDefault(); elements.authErrorMsg.style.display = 'none'; const username = elements.loginUsernameInput.value.trim(); const password = elements.loginPasswordInput.value; try { const response = await fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) }); if (!response.ok) { const err = await response.json().catch(() => ({})); throw new Error(err.error || 'Login failed.'); } const data = await response.json(); localStorage.setItem('gourmet_token', data.token); localStorage.setItem('gourmet_user', JSON.stringify(data.user)); currentUser = data.user; elements.authModal.classList.remove('active'); elements.authModal.style.display = 'none'; elements.userBadgeContainer.style.display = 'flex'; elements.userDisplayName.textContent = `@${currentUser.username}`; // Toggle Admin visibility if (currentUser.isAdmin) { elements.tabUserAdmin.style.display = 'flex'; } else { elements.tabUserAdmin.style.display = 'none'; } elements.loginForm.reset(); // Default back to My Cookbook on fresh login switchTab('my-cookbook'); } catch (error) { elements.authErrorText.textContent = error.message; elements.authErrorMsg.style.display = 'flex'; } } function logout() { localStorage.removeItem('gourmet_token'); localStorage.removeItem('gourmet_user'); currentUser = null; elements.authModal.classList.add('active'); elements.authModal.style.display = 'flex'; elements.userBadgeContainer.style.display = 'none'; elements.tabUserAdmin.style.display = 'none'; allRecipes = []; // Reset tabs visual state elements.tabMyCookbook.classList.add('active'); elements.tabCommunityVault.classList.remove('active'); elements.tabUserAdmin.classList.remove('btn-primary'); elements.tabUserAdmin.classList.add('btn-outline'); elements.tabUserAdmin.classList.remove('active'); elements.userAdminPanel.style.display = 'none'; elements.recipesGrid.style.display = 'grid'; const filters = elements.librarySearch.closest('.filter-controls'); if (filters) filters.style.display = 'flex'; renderLibrary(); } function switchTab(tabName) { elements.tabMyCookbook.classList.remove('active'); elements.tabCommunityVault.classList.remove('active'); elements.tabUserAdmin.classList.remove('active'); elements.tabUserAdmin.classList.remove('btn-primary'); elements.tabUserAdmin.classList.add('btn-outline'); elements.recipesGrid.style.display = 'none'; elements.emptyState.style.display = 'none'; elements.userAdminPanel.style.display = 'none'; const filters = elements.librarySearch.closest('.filter-controls'); if (filters) filters.style.display = 'flex'; if (tabName === 'my-cookbook') { elements.tabMyCookbook.classList.add('active'); elements.recipesGrid.style.display = 'grid'; activeTab = 'my-cookbook'; fetchRecipes(); } else if (tabName === 'community-vault') { elements.tabCommunityVault.classList.add('active'); elements.recipesGrid.style.display = 'grid'; activeTab = 'community-vault'; fetchRecipes(); } else if (tabName === 'user-admin') { elements.tabUserAdmin.classList.add('btn-primary'); elements.tabUserAdmin.classList.remove('btn-outline'); elements.userAdminPanel.style.display = 'block'; if (filters) filters.style.display = 'none'; activeTab = 'user-admin'; fetchAdminUsers(); } lucide.createIcons(); } // ------------------------------------------------------------- // USER ADMINISTRATION MANAGEMENT PANEL (ADMIN ONLY) // ------------------------------------------------------------- async function fetchAdminUsers() { try { const response = await fetch('/api/admin/users', { headers: getAuthHeaders() }); if (response.status === 401) { logout(); return; } if (!response.ok) throw new Error('Failed to load registered chef accounts.'); const users = await response.json(); renderAdminUsers(users); } catch (error) { console.error('Error fetching admin users:', error); } } function renderAdminUsers(users) { elements.adminUsersList.innerHTML = ''; users.forEach(user => { const tr = document.createElement('tr'); let roleBadge = user.isAdmin ? ' Admin' : ' Chef'; if (user.isRecipeManager) { roleBadge += ' Manager'; } const dateStr = new Date(user.createdAt).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' }); const isSelf = user.id === currentUser.id; const actionCell = isSelf ? `Active Session` : ` ${user.isAdmin ? `` : `` } ${user.isRecipeManager ? `` : `` } `; tr.innerHTML = ` @${user.username} ${roleBadge} ${dateStr} ${actionCell} `; elements.adminUsersList.appendChild(tr); }); lucide.createIcons(); } async function handleAdminCreateUser(e) { e.preventDefault(); elements.adminCreateError.style.display = 'none'; const username = elements.adminNewUsername.value.trim(); const password = elements.adminNewPassword.value; try { const response = await fetch('/api/auth/register', { method: 'POST', headers: { 'Content-Type': 'application/json', ...getAuthHeaders() }, body: JSON.stringify({ username, password }) }); if (response.status === 401) { logout(); return; } if (!response.ok) { const err = await response.json().catch(() => ({})); throw new Error(err.error || 'Failed to create chef account.'); } closeModal('admin-create-user-modal'); elements.adminCreateUserForm.reset(); fetchAdminUsers(); } catch (error) { elements.adminCreateErrorText.textContent = error.message; elements.adminCreateError.style.display = 'flex'; } } window.confirmDeleteUser = function(id, username) { elements.deleteUserTargetId.value = id; elements.deleteUserTargetName.textContent = `@${username}`; elements.deleteUserPreserveRecipes.checked = true; openModal('admin-delete-user-modal'); }; async function handleAdminDeleteUser() { const id = elements.deleteUserTargetId.value; const preserveRecipes = elements.deleteUserPreserveRecipes.checked; try { const response = await fetch(`/api/admin/users/${id}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json', ...getAuthHeaders() }, body: JSON.stringify({ preserveRecipes }) }); if (response.status === 401) { logout(); return; } if (!response.ok) { const err = await response.json().catch(() => ({})); throw new Error(err.error || 'Failed to delete user.'); } closeModal('admin-delete-user-modal'); fetchAdminUsers(); } catch (error) { alert(`Error deleting user: ${error.message}`); } } window.toggleUserRole = async function(id, makeAdmin) { try { const response = await fetch(`/api/admin/users/${id}/role`, { method: 'PUT', headers: { 'Content-Type': 'application/json', ...getAuthHeaders() }, body: JSON.stringify({ isAdmin: makeAdmin }) }); if (response.status === 401) { logout(); return; } if (!response.ok) { const err = await response.json().catch(() => ({})); throw new Error(err.error || 'Failed to update user role.'); } fetchAdminUsers(); } catch (error) { alert(`Error updating role: ${error.message}`); } }; window.toggleRecipeManagerRole = async function(id, makeManager) { try { const response = await fetch(`/api/admin/users/${id}/recipe-manager`, { method: 'PUT', headers: { 'Content-Type': 'application/json', ...getAuthHeaders() }, body: JSON.stringify({ isRecipeManager: makeManager }) }); if (response.status === 401) { logout(); return; } if (!response.ok) { const err = await response.json().catch(() => ({})); throw new Error(err.error || 'Failed to update recipe manager permission.'); } fetchAdminUsers(); } catch (error) { alert(`Error updating permission: ${error.message}`); } }; // ------------------------------------------------------------- // SYSTEM SETTINGS & GEMINI AI PHOTO SCANNER // ------------------------------------------------------------- async function openSettingsModal() { // Reset visibility states and statuses elements.settingsStatusMsg.style.display = 'none'; elements.settingsGeminiKeyInput.type = 'password'; const toggleIcon = elements.btnToggleGeminiKey.querySelector('i, svg'); if (toggleIcon) toggleIcon.setAttribute('data-lucide', 'eye'); lucide.createIcons(); elements.settingsGeminiKeyInput.value = ''; elements.settingsGeminiKeyInput.placeholder = 'Loading credentials...'; openModal('settings-modal'); try { const response = await fetch('/api/settings', { headers: getAuthHeaders() }); if (response.status === 401) { logout(); closeModal('settings-modal'); return; } if (!response.ok) throw new Error('Settings could not be loaded.'); const settings = await response.json(); const key = settings.geminiApiKey ? settings.geminiApiKey.trim() : ''; const isPlaceholder = key === 'PASTE_YOUR_GEMINI_API_KEY_HERE'; elements.settingsGeminiKeyInput.placeholder = 'Enter your Gemini API key...'; if (key && !isPlaceholder) { elements.settingsGeminiKeyInput.value = key; } } catch (err) { console.error('Error loading settings:', err); elements.settingsGeminiKeyInput.placeholder = 'Failed to load settings'; } } async function handleSaveSettings(e) { e.preventDefault(); elements.settingsStatusMsg.style.display = 'none'; const key = elements.settingsGeminiKeyInput.value.trim(); try { const response = await fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json', ...getAuthHeaders() }, body: JSON.stringify({ geminiApiKey: key }) }); if (response.status === 401) { logout(); closeModal('settings-modal'); return; } if (!response.ok) throw new Error('Failed to save settings file.'); // Show success status banner elements.settingsStatusMsg.style.display = 'flex'; setTimeout(() => { elements.settingsStatusMsg.style.display = 'none'; }, 4000); } catch (err) { console.error('Error saving settings:', err); alert(`Failed to save settings: ${err.message}`); } } function toggleSettingsKeyVisibility() { const input = elements.settingsGeminiKeyInput; const toggleIcon = elements.btnToggleGeminiKey.querySelector('i, svg'); if (input.type === 'password') { input.type = 'text'; if (toggleIcon) toggleIcon.setAttribute('data-lucide', 'eye-off'); } else { input.type = 'password'; if (toggleIcon) toggleIcon.setAttribute('data-lucide', 'eye'); } lucide.createIcons(); } function showScanLoading() { elements.loadingOverlay.classList.add('active'); const title = elements.loadingOverlay.querySelector('h3'); const subtitle = elements.loadingOverlay.querySelector('.loader-subtitle'); if (title) title.textContent = 'Scanning Recipe Photo'; if (subtitle) subtitle.textContent = 'Loading parameters and initializing scanner...'; elements.loaderStepFetch.className = 'loader-step active'; elements.loaderStepParse.className = 'loader-step'; elements.loaderStepImages.className = 'loader-step'; elements.loaderStepFetch.querySelector('span:last-child').textContent = 'Loading system configuration...'; elements.loaderStepParse.querySelector('span:last-child').textContent = 'Compressing and optimizing image...'; elements.loaderStepImages.querySelector('span:last-child').textContent = 'Digitizing with Gemini Vision AI...'; setLoaderStepIcon(elements.loaderStepFetch, 'circle-dashed', true); setLoaderStepIcon(elements.loaderStepParse, 'circle', false); setLoaderStepIcon(elements.loaderStepImages, 'circle', false); lucide.createIcons(); } function hideScanLoading() { elements.loadingOverlay.classList.remove('active'); // Restore original loading screen texts const title = elements.loadingOverlay.querySelector('h3'); const subtitle = elements.loadingOverlay.querySelector('.loader-subtitle'); if (title) title.textContent = 'Extracting Recipe Data'; if (subtitle) subtitle.textContent = 'Retrieving site contents and parsing culinary structures...'; elements.loaderStepFetch.querySelector('span:last-child').textContent = 'Connecting to source URL...'; elements.loaderStepParse.querySelector('span:last-child').textContent = 'Parsing schema structures...'; elements.loaderStepImages.querySelector('span:last-child').textContent = 'Extracting high-res dish photos...'; } function compressImage(file, maxDimension = 1600) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.readAsDataURL(file); reader.onload = (event) => { const img = new Image(); img.src = event.target.result; img.onload = () => { let width = img.width; let height = img.height; // Downscale maintaining aspect ratio if (width > maxDimension || height > maxDimension) { if (width > height) { height = Math.round((height * maxDimension) / width); width = maxDimension; } else { width = Math.round((width * maxDimension) / height); height = maxDimension; } } const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height; const ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0, width, height); const dataUrl = canvas.toDataURL('image/jpeg', 0.85); const base64 = dataUrl.split(',')[1]; resolve({ dataUrl, base64, mimeType: 'image/jpeg' }); }; img.onerror = (err) => reject(err); }; reader.onerror = (err) => reject(err); }); } async function handleRecipeImageScan(e) { const file = e.target.files[0]; if (!file) return; scanRecipeFile(file); } async function handleClipboardPaste(e) { // Only trigger scan if logged in to prevent unauthenticated access attempts if (!currentUser) return; const items = (e.clipboardData || e.originalEvent?.clipboardData)?.items; if (!items) return; for (const item of items) { if (item.kind === 'file' && item.type.startsWith('image/')) { const file = item.getAsFile(); e.preventDefault(); // Intercept paste action scanRecipeFile(file); break; } } } async function scanRecipeFile(file) { showScanLoading(); try { // 1. Fetch system-wide configuration const settingsResponse = await fetch('/api/settings', { headers: getAuthHeaders() }); if (settingsResponse.status === 401) { logout(); hideScanLoading(); return; } if (!settingsResponse.ok) { throw new Error('Could not fetch settings. Please ensure system settings are configured.'); } const settings = await settingsResponse.json(); const apiKey = settings.geminiApiKey ? settings.geminiApiKey.trim() : ''; const isPlaceholder = apiKey === 'PASTE_YOUR_GEMINI_API_KEY_HERE' || apiKey === ''; if (!apiKey || isPlaceholder) { hideScanLoading(); elements.scanImageInput.value = ''; alert('Google Gemini API Key is not configured.\n\nPlease click the Settings button at the top right, configure your Google Gemini API Key, and try again.'); return; } // Step 1 Completed -> Activate Step 2 (Compression) 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(); // 2. Compress image using Canvas const compressed = await compressImage(file, 1600); // Step 2 Completed -> Activate Step 3 (Gemini transcription) 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(); // 3. Make direct call to Google Gemini Vision API const promptText = "You are a professional culinary transcriber. Analyze the image of a recipe (which could be a cookbook page, handwritten note, screenshot, or packaging) and extract it accurately. Fill in all fields as completely as possible. The response must be a JSON object matching this schema:\n{\n \"title\": \"Recipe Title\",\n \"description\": \"Brief description or review\",\n \"servings\": \"e.g., 4\",\n \"prepTime\": \"e.g., 15 mins\",\n \"cookTime\": \"e.g., 30 mins\",\n \"totalTime\": \"e.g., 45 mins\",\n \"ingredients\": [\"ingredient 1\", \"ingredient 2\"],\n \"instructions\": [\"step 1\", \"step 2\"]\n}\nDo not include any markdown styling (like ```json) or explanation. Return only the raw JSON string."; const geminiUrl = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${apiKey}`; const geminiResponse = await fetch(geminiUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ contents: [ { parts: [ { text: promptText }, { inlineData: { mimeType: compressed.mimeType, data: compressed.base64 } } ] } ], generationConfig: { responseMimeType: "application/json" } }) }); if (!geminiResponse.ok) { const errorObj = await geminiResponse.json().catch(() => ({})); const status = geminiResponse.status; let errMsg = 'Google Gemini API call failed.'; if (status === 400) { errMsg = 'Invalid parameters or model version. Please verify your settings.'; } else if (status === 403) { errMsg = 'Access denied. The Google Gemini API key is invalid or inactive.'; } else if (status === 429) { errMsg = 'Rate limit exceeded. You have made too many requests or hit your Gemini quota.'; } else if (errorObj.error && errorObj.error.message) { errMsg = `Gemini API Error: ${errorObj.error.message}`; } throw new Error(errMsg); } const result = await geminiResponse.json(); if (!result.candidates || result.candidates.length === 0 || !result.candidates[0].content) { throw new Error('Gemini API returned an empty response. The text in the image may be unreadable.'); } const rawText = result.candidates[0].content.parts[0].text; const recipeData = JSON.parse(rawText); // Auto-inject the scanned local picture as a custom visual preview option recipeData.images = [compressed.dataUrl]; recipeData.image = compressed.dataUrl; // 4. Finalize and trigger draft preview hideScanLoading(); elements.scanImageInput.value = ''; openDraftPreview(recipeData); } catch (error) { console.error('Photo scanning error occurred:', error); hideScanLoading(); elements.scanImageInput.value = ''; alert(`Scanning Failed: ${error.message}\n\nPlease verify your internet connection and check that your Gemini API Key is correct.`); } }