recipe-manager/public/app.js
2026-05-28 22:30:24 -04:00

1765 lines
62 KiB
JavaScript

// 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'),
// Mobile Components
fabAddRecipe: document.getElementById('mobile-header-btn-add'),
mobileTabCookbook: document.getElementById('mobile-header-tab-cookbook'),
mobileTabCommunity: document.getElementById('mobile-header-tab-community'),
mobileTabProfile: document.getElementById('mobile-header-btn-profile'),
mobileProfileModal: document.getElementById('mobile-profile-modal'),
mobileProfileUsername: document.getElementById('mobile-profile-username'),
mobileProfileRole: document.getElementById('mobile-profile-role'),
mobileBtnThemeToggle: document.getElementById('mobile-btn-theme-toggle'),
mobileBtnSettings: document.getElementById('mobile-btn-settings'),
mobileBtnAdmin: document.getElementById('mobile-btn-admin'),
mobileBtnLogout: document.getElementById('mobile-btn-logout'),
headerLogo: document.getElementById('header-logo'),
libraryViewTitle: document.getElementById('library-view-title')
};
// -------------------------------------------------------------
// 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();
}
}
});
// Mobile UI Events
elements.mobileTabCookbook.addEventListener('click', () => switchTab('my-cookbook'));
elements.mobileTabCommunity.addEventListener('click', () => switchTab('community-vault'));
elements.mobileTabProfile.addEventListener('click', openMobileProfile);
elements.headerLogo.addEventListener('click', () => switchTab('my-cookbook'));
elements.fabAddRecipe.addEventListener('click', handleFabClick);
elements.mobileBtnThemeToggle.addEventListener('click', toggleTheme);
elements.mobileBtnSettings.addEventListener('click', () => {
closeModal('mobile-profile-modal');
openSettingsModal();
});
elements.mobileBtnAdmin.addEventListener('click', () => {
closeModal('mobile-profile-modal');
switchTab('user-admin');
});
elements.mobileBtnLogout.addEventListener('click', () => {
closeModal('mobile-profile-modal');
logout();
});
}
// -------------------------------------------------------------
// 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();
}
}
// 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 Shared Cookbook 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 = `<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" onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';">
<div class="recipe-card-img-placeholder" style="display: none;">
<i data-lucide="cooking-pot"></i>
<span>No dish photo</span>
</div>
`;
} else {
imgBlock = `
<div class="recipe-card-img-placeholder">
<i data-lucide="cooking-pot"></i>
<span>No dish photo</span>
</div>
`;
}
let creatorBadge = '';
if (activeTab === 'community-vault') {
const creator = recipe.createdBy || 'gourmet_system';
creatorBadge = `
<div class="recipe-card-creator" style="margin-top: 4px; margin-bottom: 8px;">
<i data-lucide="user"></i>
<span>@${creator}</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>
${creatorBadge}
<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.';
// 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 = `
<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);
});
}
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 = '<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();
}
}
// 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';
}
// -------------------------------------------------------------
// 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';
elements.mobileBtnAdmin.style.display = 'block';
} else {
elements.tabUserAdmin.style.display = 'none';
elements.mobileBtnAdmin.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';
elements.mobileBtnAdmin.style.display = 'block';
} else {
elements.tabUserAdmin.style.display = 'none';
elements.mobileBtnAdmin.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.mobileBtnAdmin.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');
// Mobile Tab Bar sync
elements.mobileTabCookbook.classList.remove('active');
elements.mobileTabCommunity.classList.remove('active');
elements.mobileTabProfile.classList.remove('active');
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';
// Update Dynamic Mobile Header Title
if (elements.libraryViewTitle) {
if (tabName === 'my-cookbook') {
elements.libraryViewTitle.textContent = 'My Cookbook';
} else if (tabName === 'community-vault') {
elements.libraryViewTitle.textContent = 'Shared Cookbook';
} else if (tabName === 'user-admin') {
elements.libraryViewTitle.textContent = 'User Administration';
}
}
if (tabName === 'my-cookbook') {
elements.tabMyCookbook.classList.add('active');
elements.mobileTabCookbook.classList.add('active');
elements.recipesGrid.style.display = 'grid';
activeTab = 'my-cookbook';
fetchRecipes();
} else if (tabName === 'community-vault') {
elements.tabCommunityVault.classList.add('active');
elements.mobileTabCommunity.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.mobileTabProfile.classList.add('active');
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
? '<span class="admin-role-badge admin"><i data-lucide="shield"></i> Admin</span>'
: '<span class="admin-role-badge chef"><i data-lucide="chef-hat"></i> Chef</span>';
if (user.isRecipeManager) {
roleBadge += ' <span class="admin-role-badge manager" style="margin-left: 6px;"><i data-lucide="shield-check"></i> Manager</span>';
}
const dateStr = new Date(user.createdAt).toLocaleDateString(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric'
});
const isSelf = user.id === currentUser.id;
const actionCell = isSelf
? `<span style="font-size: 0.88rem; color: var(--text-muted); font-style: italic;">Active Session</span>`
: `
${user.isAdmin
? `<button class="btn btn-outline" style="padding: 6px 12px; font-size: 0.8rem; border-radius: 6px; margin-right: 8px;" onclick="toggleUserRole('${user.id}', false)"><i data-lucide="user-check" style="width: 12px; height: 12px;"></i> Demote to Chef</button>`
: `<button class="btn btn-accent" style="padding: 6px 12px; font-size: 0.8rem; border-radius: 6px; margin-right: 8px;" onclick="toggleUserRole('${user.id}', true)"><i data-lucide="shield" style="width: 12px; height: 12px;"></i> Promote to Admin</button>`
}
${user.isRecipeManager
? `<button class="btn btn-outline" style="padding: 6px 12px; font-size: 0.8rem; border-radius: 6px; margin-right: 8px; background-color: rgba(99, 102, 241, 0.1); border-color: #6366f1; color: #6366f1;" onclick="toggleRecipeManagerRole('${user.id}', false)"><i data-lucide="user-x" style="width: 12px; height: 12px;"></i> Revoke Manager</button>`
: `<button class="btn btn-outline" style="padding: 6px 12px; font-size: 0.8rem; border-radius: 6px; margin-right: 8px; border-color: rgba(99, 102, 241, 0.5); color: #6366f1;" onclick="toggleRecipeManagerRole('${user.id}', true)"><i data-lucide="user-cog" style="width: 12px; height: 12px;"></i> Grant Manager</button>`
}
<button class="btn btn-danger" style="padding: 6px 12px; font-size: 0.8rem; border-radius: 6px;" onclick="confirmDeleteUser('${user.id}', '${user.username}')"><i data-lucide="trash-2" style="width: 12px; height: 12px;"></i> Delete</button>
`;
tr.innerHTML = `
<td style="font-weight: 600; color: var(--text-main);">@${user.username}</td>
<td>${roleBadge}</td>
<td style="color: var(--text-muted); font-size: 0.9rem;">${dateStr}</td>
<td style="text-align: right;">${actionCell}</td>
`;
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.`);
}
}
// -------------------------------------------------------------
// MOBILE PORTAL CONTROLS & GESTURES
// -------------------------------------------------------------
function openMobileProfile() {
if (!currentUser) return;
elements.mobileProfileUsername.textContent = `@${currentUser.username}`;
// Set role text
let roleText = 'Chef Account';
if (currentUser.isAdmin) {
roleText = 'Administrator';
} else if (currentUser.isRecipeManager) {
roleText = 'Recipe Manager';
}
elements.mobileProfileRole.textContent = roleText;
// Handle theme icon inside profile drawer
const isDark = document.body.classList.contains('dark-theme');
const sunIcon = elements.mobileBtnThemeToggle.querySelector('.sun-icon');
const moonIcon = elements.mobileBtnThemeToggle.querySelector('.moon-icon');
if (sunIcon && moonIcon) {
if (isDark) {
sunIcon.style.display = 'block';
moonIcon.style.display = 'none';
} else {
sunIcon.style.display = 'none';
moonIcon.style.display = 'block';
}
}
openModal('mobile-profile-modal');
}
function handleFabClick() {
// If not on cookbook tab, switch back to list first
if (activeTab !== 'my-cookbook') {
switchTab('my-cookbook');
}
// Open the manual recipe creator
openManualAdd();
}