mirror of
https://gitlab.com/Zer0interrupt/recipe-manager.git
synced 2026-11-01 23:24:11 -05:00
Initial commit: GourmetVault recipe manager
This commit is contained in:
commit
97733b8570
9 changed files with 4544 additions and 0 deletions
33
.gitignore
vendored
Normal file
33
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
web_modules/
|
||||
|
||||
# Local database and volume directories
|
||||
data/
|
||||
*.db
|
||||
*.sqlite
|
||||
|
||||
# Environment secrets
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# Editor and OS files
|
||||
.vscode/
|
||||
.idea/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Scratch and test scripts
|
||||
test_scraper.js
|
||||
23
Dockerfile
Normal file
23
Dockerfile
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# Base image: Minimal LTS Node Alpine
|
||||
FROM node:20-alpine
|
||||
|
||||
# Setup workspace environment
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
# Copy dependency manifests
|
||||
COPY package*.json ./
|
||||
|
||||
# Install clean production dependencies
|
||||
RUN npm ci --only=production
|
||||
|
||||
# Copy application source files
|
||||
COPY . .
|
||||
|
||||
# Setup persistent volume mount point
|
||||
RUN mkdir -p /usr/src/app/data
|
||||
|
||||
# Default port exposure
|
||||
EXPOSE 3000
|
||||
|
||||
# Start command
|
||||
CMD [ "npm", "start" ]
|
||||
14
docker-compose.yml
Normal file
14
docker-compose.yml
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
version: '3.8'
|
||||
|
||||
services:
|
||||
recipe-manager:
|
||||
build: .
|
||||
container_name: recipe-manager-app
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- ./data:/usr/src/app/data
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- PORT=3000
|
||||
- NODE_ENV=production
|
||||
1344
package-lock.json
generated
Normal file
1344
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
17
package.json
Normal file
17
package.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"name": "recipe-manager",
|
||||
"version": "1.0.0",
|
||||
"description": "Dockerized Recipe Management and Web Scraping Application",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "node server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.7.2",
|
||||
"cheerio": "^1.0.0-rc.12",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.19.2"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
813
public/app.js
Normal file
813
public/app.js
Normal file
|
|
@ -0,0 +1,813 @@
|
|||
// GourmetVault client application state
|
||||
let allRecipes = [];
|
||||
let activeRecipe = null;
|
||||
let currentMultiplier = 1;
|
||||
let selectedCoverImage = '';
|
||||
let stopwatchInterval = null;
|
||||
let stopwatchSeconds = 0;
|
||||
let cookingActiveStep = 0;
|
||||
|
||||
// Page elements caching
|
||||
const elements = {
|
||||
themeToggle: document.getElementById('theme-toggle'),
|
||||
urlScrapeForm: document.getElementById('url-scrape-form'),
|
||||
scrapeUrlInput: document.getElementById('scrape-url-input'),
|
||||
btnScrapeSubmit: document.getElementById('btn-scrape-submit'),
|
||||
btnManualAdd: document.getElementById('btn-manual-add'),
|
||||
recipesGrid: document.getElementById('recipes-grid'),
|
||||
emptyState: document.getElementById('empty-state'),
|
||||
librarySearch: document.getElementById('library-search'),
|
||||
|
||||
// Scrape / Edit Modal
|
||||
saveModal: document.getElementById('save-modal'),
|
||||
recipeEditForm: document.getElementById('recipe-edit-form'),
|
||||
editRecipeId: document.getElementById('edit-recipe-id'),
|
||||
editTitle: document.getElementById('edit-title'),
|
||||
editSource: document.getElementById('edit-source'),
|
||||
editDescription: document.getElementById('edit-description'),
|
||||
editServings: document.getElementById('edit-servings'),
|
||||
editPrepTime: document.getElementById('edit-prep-time'),
|
||||
editCookTime: document.getElementById('edit-cook-time'),
|
||||
imageSelectionGrid: document.getElementById('image-selection-grid'),
|
||||
editImageUrl: document.getElementById('edit-image-url'),
|
||||
editIngredients: document.getElementById('edit-ingredients'),
|
||||
editInstructions: document.getElementById('edit-instructions'),
|
||||
modalActionTitle: document.getElementById('modal-action-title'),
|
||||
btnSaveConfirm: document.getElementById('btn-save-confirm'),
|
||||
|
||||
// Detail Modal
|
||||
detailModal: document.getElementById('detail-modal'),
|
||||
recipeDetailBanner: document.getElementById('recipe-detail-banner'),
|
||||
btnDetailEdit: document.getElementById('btn-detail-edit'),
|
||||
btnDetailDelete: document.getElementById('btn-detail-delete'),
|
||||
recipeDetailTitle: document.getElementById('recipe-detail-title'),
|
||||
tagServings: document.getElementById('tag-servings'),
|
||||
tagPrep: document.getElementById('tag-prep'),
|
||||
tagCook: document.getElementById('tag-cook'),
|
||||
recipeDetailDescription: document.getElementById('recipe-detail-description'),
|
||||
recipeDetailIngredients: document.getElementById('recipe-detail-ingredients'),
|
||||
recipeDetailInstructions: document.getElementById('recipe-detail-instructions'),
|
||||
scalerMinus: document.getElementById('scaler-minus'),
|
||||
scalerPlus: document.getElementById('scaler-plus'),
|
||||
currentServingsVal: document.getElementById('current-servings-val'),
|
||||
btnTriggerCookmode: document.getElementById('btn-trigger-cookmode'),
|
||||
|
||||
// Cooking Mode Modal
|
||||
cookingModeModal: document.getElementById('cooking-mode-modal'),
|
||||
cookRecipeTitle: document.getElementById('cook-recipe-title'),
|
||||
cookTimerVal: document.getElementById('cook-timer-val'),
|
||||
cookTimerPlay: document.getElementById('cook-timer-play'),
|
||||
cookTimerReset: document.getElementById('cook-timer-reset'),
|
||||
cookProgressBar: document.getElementById('cook-progress-bar'),
|
||||
cookStepIndex: document.getElementById('cook-step-index'),
|
||||
cookStepInstruction: document.getElementById('cook-step-instruction'),
|
||||
cookBtnPrev: document.getElementById('cook-btn-prev'),
|
||||
cookBtnNext: document.getElementById('cook-btn-next'),
|
||||
cookStepDots: document.getElementById('cook-step-dots'),
|
||||
|
||||
// Loader
|
||||
loadingOverlay: document.getElementById('loading-overlay'),
|
||||
loaderStepFetch: document.getElementById('loader-step-fetch'),
|
||||
loaderStepParse: document.getElementById('loader-step-parse'),
|
||||
loaderStepImages: document.getElementById('loader-step-images')
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// LIFE CYCLE AND REGISTRATION
|
||||
// -------------------------------------------------------------
|
||||
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
initTheme();
|
||||
fetchRecipes();
|
||||
registerEvents();
|
||||
});
|
||||
|
||||
function registerEvents() {
|
||||
// Theme Toggle
|
||||
elements.themeToggle.addEventListener('click', toggleTheme);
|
||||
|
||||
// URL Form Submission
|
||||
elements.urlScrapeForm.addEventListener('submit', handleScrapeSubmit);
|
||||
|
||||
// Manual Add Form Opening
|
||||
elements.btnManualAdd.addEventListener('click', openManualAdd);
|
||||
|
||||
// Library Search
|
||||
elements.librarySearch.addEventListener('input', filterRecipes);
|
||||
|
||||
// Recipe Form Save
|
||||
elements.recipeEditForm.addEventListener('submit', handleRecipeSave);
|
||||
|
||||
// Action Buttons Detail Modal
|
||||
elements.btnDetailEdit.addEventListener('click', editActiveRecipe);
|
||||
elements.btnDetailDelete.addEventListener('click', deleteActiveRecipe);
|
||||
|
||||
// Servings Scaler Buttons
|
||||
elements.scalerMinus.addEventListener('click', () => adjustMultiplier(-0.5));
|
||||
elements.scalerPlus.addEventListener('click', () => adjustMultiplier(0.5));
|
||||
|
||||
// Cooking Mode Trigger
|
||||
elements.btnTriggerCookmode.addEventListener('click', enterCookingMode);
|
||||
|
||||
// Cooking Controls
|
||||
elements.cookBtnPrev.addEventListener('click', prevCookingStep);
|
||||
elements.cookBtnNext.addEventListener('click', nextCookingStep);
|
||||
elements.cookTimerPlay.addEventListener('click', toggleTimer);
|
||||
elements.cookTimerReset.addEventListener('click', resetTimer);
|
||||
|
||||
// Global Modals click to dismiss on overlay
|
||||
document.querySelectorAll('.modal-overlay').forEach(overlay => {
|
||||
overlay.addEventListener('click', (e) => {
|
||||
if (e.target === overlay && !overlay.classList.contains('loading-overlay')) {
|
||||
closeModal(overlay.id);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Global key bindings for cooking mode
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (elements.cookingModeModal.classList.contains('active')) {
|
||||
if (e.key === 'ArrowRight' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
nextCookingStep();
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
prevCookingStep();
|
||||
} else if (e.key === 'Escape') {
|
||||
exitCookingMode();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// UTILITIES: SYSTEM THEMES
|
||||
// -------------------------------------------------------------
|
||||
|
||||
function initTheme() {
|
||||
const savedTheme = localStorage.getItem('gourmet-theme') || 'dark';
|
||||
if (savedTheme === 'dark') {
|
||||
document.body.classList.add('dark-theme');
|
||||
} else {
|
||||
document.body.classList.remove('dark-theme');
|
||||
}
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
document.body.classList.toggle('dark-theme');
|
||||
const isDark = document.body.classList.contains('dark-theme');
|
||||
localStorage.setItem('gourmet-theme', isDark ? 'dark' : 'light');
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// LOADING STATUS MACHINE
|
||||
// -------------------------------------------------------------
|
||||
|
||||
function setLoaderStepIcon(stepElement, iconName, spin = false) {
|
||||
const wrapper = stepElement.querySelector('.step-icon-wrapper');
|
||||
if (wrapper) {
|
||||
wrapper.innerHTML = `<i data-lucide="${iconName}" class="${spin ? 'spinning-icon' : ''}"></i>`;
|
||||
}
|
||||
}
|
||||
|
||||
function showScrapeLoading() {
|
||||
elements.loadingOverlay.classList.add('active');
|
||||
|
||||
// Reset Steps to default
|
||||
elements.loaderStepFetch.className = 'loader-step active';
|
||||
elements.loaderStepParse.className = 'loader-step';
|
||||
elements.loaderStepImages.className = 'loader-step';
|
||||
|
||||
setLoaderStepIcon(elements.loaderStepFetch, 'circle-dashed', true);
|
||||
setLoaderStepIcon(elements.loaderStepParse, 'circle', false);
|
||||
setLoaderStepIcon(elements.loaderStepImages, 'circle', false);
|
||||
|
||||
lucide.createIcons();
|
||||
|
||||
// Simulate process transitions
|
||||
setTimeout(() => {
|
||||
if (!elements.loadingOverlay.classList.contains('active')) return;
|
||||
elements.loaderStepFetch.className = 'loader-step completed';
|
||||
setLoaderStepIcon(elements.loaderStepFetch, 'check-circle-2', false);
|
||||
|
||||
elements.loaderStepParse.className = 'loader-step active';
|
||||
setLoaderStepIcon(elements.loaderStepParse, 'circle-dashed', true);
|
||||
lucide.createIcons();
|
||||
}, 1800);
|
||||
|
||||
setTimeout(() => {
|
||||
if (!elements.loadingOverlay.classList.contains('active')) return;
|
||||
elements.loaderStepParse.className = 'loader-step completed';
|
||||
setLoaderStepIcon(elements.loaderStepParse, 'check-circle-2', false);
|
||||
|
||||
elements.loaderStepImages.className = 'loader-step active';
|
||||
setLoaderStepIcon(elements.loaderStepImages, 'circle-dashed', true);
|
||||
lucide.createIcons();
|
||||
}, 3500);
|
||||
}
|
||||
|
||||
function hideScrapeLoading() {
|
||||
elements.loadingOverlay.classList.remove('active');
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// CORE MODAL ROUTER
|
||||
// -------------------------------------------------------------
|
||||
|
||||
function openModal(id) {
|
||||
document.getElementById(id).classList.add('active');
|
||||
}
|
||||
|
||||
function closeModal(id) {
|
||||
document.getElementById(id).classList.remove('active');
|
||||
if (id === 'cooking-mode-modal') {
|
||||
exitCookingMode();
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// CRUD & API HANDLERS
|
||||
// -------------------------------------------------------------
|
||||
|
||||
async function fetchRecipes() {
|
||||
try {
|
||||
const response = await fetch('/api/recipes');
|
||||
allRecipes = await response.json();
|
||||
renderLibrary();
|
||||
} catch (error) {
|
||||
console.error('Error fetching recipes library:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Scrape link form submission
|
||||
async function handleScrapeSubmit(e) {
|
||||
e.preventDefault();
|
||||
const url = elements.scrapeUrlInput.value.trim();
|
||||
if (!url) return;
|
||||
|
||||
showScrapeLoading();
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/scrape', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
if (response.status === 403 || errorData.isBlocked) {
|
||||
throw new Error('BLOCKED');
|
||||
}
|
||||
throw new Error(errorData.error || 'Extraction error.');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
hideScrapeLoading();
|
||||
openDraftPreview(data);
|
||||
elements.scrapeUrlInput.value = '';
|
||||
} catch (error) {
|
||||
hideScrapeLoading();
|
||||
if (error.message === 'BLOCKED') {
|
||||
alert(`Access Blocked by Site Protection\n\nThis website uses Cloudflare or bot-defense screens to prevent automated scraping.\n\nDon't worry! You can still add this recipe by clicking the "Manual Recipe" button at the top to quickly paste your ingredients and directions.`);
|
||||
} else {
|
||||
alert(`Scraping failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save or Edit submit
|
||||
async function handleRecipeSave(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const id = elements.editRecipeId.value;
|
||||
const recipePayload = {
|
||||
title: elements.editTitle.value.trim(),
|
||||
sourceUrl: elements.editSource.value.trim(),
|
||||
description: elements.editDescription.value.trim(),
|
||||
servings: elements.editServings.value.trim(),
|
||||
prepTime: elements.editPrepTime.value.trim(),
|
||||
cookTime: elements.editCookTime.value.trim(),
|
||||
image: elements.editImageUrl.value.trim(),
|
||||
images: Array.from(elements.imageSelectionGrid.querySelectorAll('.image-option img')).map(img => img.src),
|
||||
ingredients: elements.editIngredients.value.trim().split(/\r?\n/).map(line => line.trim()).filter(Boolean),
|
||||
instructions: elements.editInstructions.value.trim().split(/\r?\n/).map(line => line.trim()).filter(Boolean)
|
||||
};
|
||||
|
||||
try {
|
||||
let response;
|
||||
if (id) {
|
||||
// Edit
|
||||
response = await fetch(`/api/recipes/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(recipePayload)
|
||||
});
|
||||
} else {
|
||||
// Create
|
||||
response = await fetch('/api/recipes', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(recipePayload)
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.ok) throw new Error('Saving failed.');
|
||||
|
||||
const saved = await response.json();
|
||||
closeModal('save-modal');
|
||||
fetchRecipes();
|
||||
|
||||
// If active view was open, update it
|
||||
if (activeRecipe && activeRecipe.id === saved.id) {
|
||||
showRecipeDetails(saved);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving recipe:', error);
|
||||
alert('Error saving recipe.');
|
||||
}
|
||||
}
|
||||
|
||||
// Delete Active Recipe
|
||||
async function deleteActiveRecipe() {
|
||||
if (!activeRecipe) return;
|
||||
if (!confirm(`Are you sure you want to delete "${activeRecipe.title}"?`)) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/recipes/${activeRecipe.id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Deletion failed.');
|
||||
|
||||
closeModal('detail-modal');
|
||||
activeRecipe = null;
|
||||
fetchRecipes();
|
||||
} catch (error) {
|
||||
console.error('Error deleting recipe:', error);
|
||||
alert('Deletion failed.');
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// DRAFT / MANUAL CREATOR MODAL POPULATOR
|
||||
// -------------------------------------------------------------
|
||||
|
||||
function openManualAdd() {
|
||||
elements.modalActionTitle.textContent = 'Create New Recipe';
|
||||
elements.editRecipeId.value = '';
|
||||
elements.recipeEditForm.reset();
|
||||
elements.imageSelectionGrid.innerHTML = '';
|
||||
selectedCoverImage = '';
|
||||
|
||||
openModal('save-modal');
|
||||
}
|
||||
|
||||
function openDraftPreview(data) {
|
||||
elements.modalActionTitle.textContent = 'Save Extracted Recipe';
|
||||
elements.editRecipeId.value = '';
|
||||
|
||||
elements.editTitle.value = data.title || '';
|
||||
elements.editSource.value = data.sourceUrl || '';
|
||||
elements.editDescription.value = data.description || '';
|
||||
elements.editServings.value = data.servings || '';
|
||||
elements.editPrepTime.value = data.prepTime || '';
|
||||
elements.editCookTime.value = data.cookTime || '';
|
||||
|
||||
// Ingredients text area
|
||||
elements.editIngredients.value = data.ingredients ? data.ingredients.join('\n') : '';
|
||||
|
||||
// Instructions text area
|
||||
elements.editInstructions.value = data.instructions ? data.instructions.join('\n') : '';
|
||||
|
||||
// Render Image Selector
|
||||
renderImageSelector(data.images || []);
|
||||
|
||||
openModal('save-modal');
|
||||
}
|
||||
|
||||
function editActiveRecipe() {
|
||||
if (!activeRecipe) return;
|
||||
|
||||
elements.modalActionTitle.textContent = 'Edit Recipe Details';
|
||||
elements.editRecipeId.value = activeRecipe.id;
|
||||
|
||||
elements.editTitle.value = activeRecipe.title || '';
|
||||
elements.editSource.value = activeRecipe.sourceUrl || '';
|
||||
elements.editDescription.value = activeRecipe.description || '';
|
||||
elements.editServings.value = activeRecipe.servings || '';
|
||||
elements.editPrepTime.value = activeRecipe.prepTime || '';
|
||||
elements.editCookTime.value = activeRecipe.cookTime || '';
|
||||
|
||||
elements.editIngredients.value = activeRecipe.ingredients ? activeRecipe.ingredients.join('\n') : '';
|
||||
elements.editInstructions.value = activeRecipe.instructions ? activeRecipe.instructions.join('\n') : '';
|
||||
|
||||
renderImageSelector(activeRecipe.images || []);
|
||||
|
||||
// Set active selection
|
||||
if (activeRecipe.image) {
|
||||
elements.editImageUrl.value = activeRecipe.image;
|
||||
selectedCoverImage = activeRecipe.image;
|
||||
selectImageThumbnail(activeRecipe.image);
|
||||
}
|
||||
|
||||
openModal('save-modal');
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// IMAGE EXTRACTION CAROUSEL & SELECTOR
|
||||
// -------------------------------------------------------------
|
||||
|
||||
function renderImageSelector(images) {
|
||||
elements.imageSelectionGrid.innerHTML = '';
|
||||
selectedCoverImage = '';
|
||||
|
||||
if (!images || images.length === 0) {
|
||||
elements.editImageUrl.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Pre-select first image
|
||||
selectedCoverImage = images[0];
|
||||
elements.editImageUrl.value = images[0];
|
||||
|
||||
images.forEach((imgSrc, index) => {
|
||||
const option = document.createElement('div');
|
||||
option.className = `image-option ${index === 0 ? 'selected' : ''}`;
|
||||
option.innerHTML = `<img src="${imgSrc}" alt="extracted thumbnail">`;
|
||||
|
||||
option.addEventListener('click', () => {
|
||||
elements.imageSelectionGrid.querySelectorAll('.image-option').forEach(el => el.classList.remove('selected'));
|
||||
option.classList.add('selected');
|
||||
selectedCoverImage = imgSrc;
|
||||
elements.editImageUrl.value = imgSrc;
|
||||
});
|
||||
|
||||
elements.imageSelectionGrid.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
function selectImageThumbnail(url) {
|
||||
elements.imageSelectionGrid.querySelectorAll('.image-option').forEach(el => {
|
||||
const img = el.querySelector('img');
|
||||
if (img && img.src === url) {
|
||||
el.classList.add('selected');
|
||||
} else {
|
||||
el.classList.remove('selected');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// DASHBOARD RENDERING
|
||||
// -------------------------------------------------------------
|
||||
|
||||
function renderLibrary() {
|
||||
elements.recipesGrid.innerHTML = '';
|
||||
|
||||
const searchVal = elements.librarySearch.value.toLowerCase().trim();
|
||||
const filtered = allRecipes.filter(recipe => {
|
||||
return recipe.title.toLowerCase().includes(searchVal) ||
|
||||
(recipe.description && recipe.description.toLowerCase().includes(searchVal)) ||
|
||||
(recipe.ingredients && recipe.ingredients.some(i => i.toLowerCase().includes(searchVal)));
|
||||
});
|
||||
|
||||
if (filtered.length === 0) {
|
||||
elements.emptyState.style.display = 'flex';
|
||||
return;
|
||||
}
|
||||
|
||||
elements.emptyState.style.display = 'none';
|
||||
|
||||
filtered.forEach(recipe => {
|
||||
const domain = recipe.sourceUrl ? new URL(recipe.sourceUrl).hostname.replace('www.', '') : 'Manual Recipe';
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'recipe-card';
|
||||
card.addEventListener('click', () => showRecipeDetails(recipe));
|
||||
|
||||
let imgBlock = '';
|
||||
if (recipe.image) {
|
||||
imgBlock = `<img src="${recipe.image}" class="recipe-card-img" alt="${recipe.title}" loading="lazy">`;
|
||||
} else {
|
||||
imgBlock = `
|
||||
<div class="recipe-card-img-placeholder">
|
||||
<i data-lucide="cooking-pot"></i>
|
||||
<span>No dish photo</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="recipe-card-img-wrapper">
|
||||
${imgBlock}
|
||||
</div>
|
||||
<div class="recipe-card-content">
|
||||
<span class="recipe-card-origin">${domain}</span>
|
||||
<h3 class="recipe-card-title">${recipe.title}</h3>
|
||||
<p class="recipe-card-desc">${recipe.description || 'No description available for this recipe.'}</p>
|
||||
|
||||
<div class="recipe-card-meta">
|
||||
${recipe.servings ? `<span><i data-lucide="users"></i> ${recipe.servings} Servings</span>` : ''}
|
||||
${recipe.totalTime ? `<span><i data-lucide="clock"></i> ${recipe.totalTime}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
elements.recipesGrid.appendChild(card);
|
||||
});
|
||||
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
function filterRecipes() {
|
||||
renderLibrary();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// DYNAMIC SERVINGS QUANTITY SCALER
|
||||
// -------------------------------------------------------------
|
||||
|
||||
function scaleIngredientText(text, multiplier) {
|
||||
if (multiplier === 1) return text;
|
||||
|
||||
// Mixed number regex: e.g. "1 1/2" or "2 1/4"
|
||||
const mixedRegex = /^(\d+)\s+(\d+\/\d+)\s*(.*)/;
|
||||
const mixedMatch = text.match(mixedRegex);
|
||||
if (mixedMatch) {
|
||||
const whole = parseInt(mixedMatch[1], 10);
|
||||
const fracParts = mixedMatch[2].split('/');
|
||||
const fracVal = parseInt(fracParts[0], 10) / parseInt(fracParts[1], 10);
|
||||
const total = (whole + fracVal) * multiplier;
|
||||
return `${formatScaledNumber(total)} ${mixedMatch[3]}`;
|
||||
}
|
||||
|
||||
// Pure Fraction regex: e.g. "1/2" or "3/4"
|
||||
const fracRegex = /^(\d+\/\d+)\s*(.*)/;
|
||||
const fracMatch = text.match(fracRegex);
|
||||
if (fracMatch) {
|
||||
const parts = fracMatch[1].split('/');
|
||||
const val = (parseInt(parts[0], 10) / parseInt(parts[1], 10)) * multiplier;
|
||||
return `${formatScaledNumber(val)} ${fracMatch[2]}`;
|
||||
}
|
||||
|
||||
// Decimals & Integers regex: e.g. "1.5" or "4"
|
||||
const numRegex = /^(\d+(?:\.\d+)?)\s*(.*)/;
|
||||
const numMatch = text.match(numRegex);
|
||||
if (numMatch) {
|
||||
const val = parseFloat(numMatch[1]) * multiplier;
|
||||
return `${formatScaledNumber(val)} ${numMatch[2]}`;
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
function formatScaledNumber(num) {
|
||||
if (num % 1 === 0) return num.toString();
|
||||
|
||||
const rounded = Math.round(num * 100) / 100;
|
||||
const decimal = rounded % 1;
|
||||
const integer = Math.floor(rounded);
|
||||
|
||||
// Common fraction mappings for nice reading
|
||||
if (Math.abs(decimal - 0.25) < 0.05) return (integer > 0 ? integer + ' ' : '') + '1/4';
|
||||
if (Math.abs(decimal - 0.5) < 0.05) return (integer > 0 ? integer + ' ' : '') + '1/2';
|
||||
if (Math.abs(decimal - 0.75) < 0.05) return (integer > 0 ? integer + ' ' : '') + '3/4';
|
||||
if (Math.abs(decimal - 0.33) < 0.05) return (integer > 0 ? integer + ' ' : '') + '1/3';
|
||||
if (Math.abs(decimal - 0.66) < 0.05) return (integer > 0 ? integer + ' ' : '') + '2/3';
|
||||
|
||||
return rounded.toString();
|
||||
}
|
||||
|
||||
function adjustMultiplier(change) {
|
||||
const newMulti = currentMultiplier + change;
|
||||
if (newMulti < 0.5 || newMulti > 10) return;
|
||||
|
||||
currentMultiplier = newMulti;
|
||||
elements.currentServingsVal.textContent = `${currentMultiplier}x`;
|
||||
renderScaledIngredients();
|
||||
}
|
||||
|
||||
function renderScaledIngredients() {
|
||||
elements.recipeDetailIngredients.innerHTML = '';
|
||||
|
||||
if (!activeRecipe || !activeRecipe.ingredients) return;
|
||||
|
||||
activeRecipe.ingredients.forEach((ing, index) => {
|
||||
const scaledText = scaleIngredientText(ing, currentMultiplier);
|
||||
|
||||
const li = document.createElement('li');
|
||||
li.className = 'ingredient-checkbox-item';
|
||||
li.innerHTML = `
|
||||
<div class="checkbox-custom"><i data-lucide="check" style="width:12px;height:12px;"></i></div>
|
||||
<span class="ingredient-text">${scaledText}</span>
|
||||
`;
|
||||
|
||||
li.addEventListener('click', () => {
|
||||
li.classList.toggle('checked');
|
||||
});
|
||||
|
||||
elements.recipeDetailIngredients.appendChild(li);
|
||||
});
|
||||
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// RECIPE DETAIL VIEWER COMPONENT
|
||||
// -------------------------------------------------------------
|
||||
|
||||
function showRecipeDetails(recipe) {
|
||||
activeRecipe = recipe;
|
||||
currentMultiplier = 1;
|
||||
elements.currentServingsVal.textContent = '1x';
|
||||
|
||||
// Set hero cover banner image
|
||||
if (recipe.image) {
|
||||
elements.recipeDetailBanner.style.backgroundImage = `url('${recipe.image}')`;
|
||||
} else {
|
||||
elements.recipeDetailBanner.style.backgroundImage = 'none';
|
||||
elements.recipeDetailBanner.style.backgroundColor = 'var(--bg-app)';
|
||||
}
|
||||
|
||||
elements.recipeDetailTitle.textContent = recipe.title;
|
||||
elements.recipeDetailDescription.textContent = recipe.description || 'No description available for this recipe.';
|
||||
|
||||
// Setup meta tags
|
||||
if (recipe.servings) {
|
||||
elements.tagServings.style.display = 'flex';
|
||||
elements.tagServings.querySelector('.val').textContent = `${recipe.servings} Servings`;
|
||||
} else {
|
||||
elements.tagServings.style.display = 'none';
|
||||
}
|
||||
|
||||
if (recipe.prepTime) {
|
||||
elements.tagPrep.style.display = 'flex';
|
||||
elements.tagPrep.querySelector('.val').textContent = recipe.prepTime;
|
||||
} else {
|
||||
elements.tagPrep.style.display = 'none';
|
||||
}
|
||||
|
||||
if (recipe.cookTime) {
|
||||
elements.tagCook.style.display = 'flex';
|
||||
elements.tagCook.querySelector('.val').textContent = recipe.cookTime;
|
||||
} else {
|
||||
elements.tagCook.style.display = 'none';
|
||||
}
|
||||
|
||||
// Populate Ingredients with scaler
|
||||
renderScaledIngredients();
|
||||
|
||||
// Populate Directions Steps
|
||||
elements.recipeDetailInstructions.innerHTML = '';
|
||||
if (recipe.instructions) {
|
||||
recipe.instructions.forEach((step, index) => {
|
||||
const li = document.createElement('li');
|
||||
|
||||
// Look for section headers (e.g. ### Sauce)
|
||||
if (step.startsWith('###')) {
|
||||
li.className = 'instruction-step-item section-header-step';
|
||||
li.innerHTML = `
|
||||
<div class="step-text-block">${step.replace('###', '').trim()}</div>
|
||||
`;
|
||||
} else {
|
||||
li.className = 'instruction-step-item';
|
||||
li.innerHTML = `
|
||||
<div class="step-number-bullet">${index + 1}</div>
|
||||
<div class="step-text-block">${step}</div>
|
||||
`;
|
||||
|
||||
li.addEventListener('click', () => {
|
||||
li.classList.toggle('checked');
|
||||
});
|
||||
}
|
||||
|
||||
elements.recipeDetailInstructions.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
openModal('detail-modal');
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// INTERACTIVE FULLSCREEN COOKING ASSISTANT
|
||||
// -------------------------------------------------------------
|
||||
|
||||
function enterCookingMode() {
|
||||
if (!activeRecipe || !activeRecipe.instructions || activeRecipe.instructions.length === 0) return;
|
||||
|
||||
elements.cookRecipeTitle.textContent = activeRecipe.title;
|
||||
cookingActiveStep = 0;
|
||||
|
||||
// Filter out header steps from the cooking carousel to keep steps clean
|
||||
const cleanInstructions = activeRecipe.instructions.filter(step => !step.startsWith('###'));
|
||||
|
||||
if (cleanInstructions.length === 0) return;
|
||||
|
||||
elements.cookingModeModal.classList.add('active');
|
||||
document.body.style.overflow = 'hidden'; // Lock background scrolling
|
||||
|
||||
renderCookingStep(cleanInstructions);
|
||||
|
||||
// Reset Timer
|
||||
resetTimer();
|
||||
startTimer();
|
||||
}
|
||||
|
||||
function exitCookingMode() {
|
||||
elements.cookingModeModal.classList.remove('active');
|
||||
document.body.style.overflow = 'auto'; // Unlock background scrolling
|
||||
stopTimer();
|
||||
}
|
||||
|
||||
function renderCookingStep(instructions) {
|
||||
const stepText = instructions[cookingActiveStep];
|
||||
elements.cookStepIndex.textContent = `Step ${cookingActiveStep + 1} of ${instructions.length}`;
|
||||
elements.cookStepInstruction.textContent = stepText;
|
||||
|
||||
// Update progress bar
|
||||
const progressPercent = ((cookingActiveStep + 1) / instructions.length) * 100;
|
||||
elements.cookProgressBar.style.width = `${progressPercent}%`;
|
||||
|
||||
// Render step navigation dot indicators
|
||||
elements.cookStepDots.innerHTML = '';
|
||||
instructions.forEach((_, idx) => {
|
||||
const dot = document.createElement('div');
|
||||
dot.className = `dot ${idx === cookingActiveStep ? 'active' : ''}`;
|
||||
dot.addEventListener('click', () => {
|
||||
cookingActiveStep = idx;
|
||||
renderCookingStep(instructions);
|
||||
});
|
||||
elements.cookStepDots.appendChild(dot);
|
||||
});
|
||||
|
||||
// Enable/disable buttons
|
||||
elements.cookBtnPrev.disabled = cookingActiveStep === 0;
|
||||
const nextBtnIconWrapper = elements.cookBtnNext.querySelector('.btn-icon-wrapper');
|
||||
if (cookingActiveStep === instructions.length - 1) {
|
||||
elements.cookBtnNext.querySelector('span').textContent = 'Finish Cooking';
|
||||
if (nextBtnIconWrapper) nextBtnIconWrapper.innerHTML = '<i data-lucide="check"></i>';
|
||||
} else {
|
||||
elements.cookBtnNext.querySelector('span').textContent = 'Next Step';
|
||||
if (nextBtnIconWrapper) nextBtnIconWrapper.innerHTML = '<i data-lucide="chevron-right"></i>';
|
||||
}
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
function prevCookingStep() {
|
||||
const cleanInstructions = activeRecipe.instructions.filter(step => !step.startsWith('###'));
|
||||
if (cookingActiveStep > 0) {
|
||||
cookingActiveStep--;
|
||||
renderCookingStep(cleanInstructions);
|
||||
}
|
||||
}
|
||||
|
||||
function nextCookingStep() {
|
||||
const cleanInstructions = activeRecipe.instructions.filter(step => !step.startsWith('###'));
|
||||
if (cookingActiveStep < cleanInstructions.length - 1) {
|
||||
cookingActiveStep++;
|
||||
renderCookingStep(cleanInstructions);
|
||||
} else {
|
||||
// Finished cooking
|
||||
exitCookingMode();
|
||||
alert('Congratulations! Enjoy your delicious meal.');
|
||||
}
|
||||
}
|
||||
|
||||
// Timer/Stopwatch Functions
|
||||
function startTimer() {
|
||||
if (stopwatchInterval) return;
|
||||
|
||||
elements.cookTimerPlay.innerHTML = '<i data-lucide="pause"></i>';
|
||||
lucide.createIcons();
|
||||
|
||||
stopwatchInterval = setInterval(() => {
|
||||
stopwatchSeconds++;
|
||||
const mins = Math.floor(stopwatchSeconds / 60).toString().padStart(2, '0');
|
||||
const secs = (stopwatchSeconds % 60).toString().padStart(2, '0');
|
||||
elements.cookTimerVal.textContent = `${mins}:${secs}`;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function stopTimer() {
|
||||
if (stopwatchInterval) {
|
||||
clearInterval(stopwatchInterval);
|
||||
stopwatchInterval = null;
|
||||
}
|
||||
elements.cookTimerPlay.innerHTML = '<i data-lucide="play"></i>';
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
function toggleTimer() {
|
||||
if (stopwatchInterval) {
|
||||
stopTimer();
|
||||
} else {
|
||||
startTimer();
|
||||
}
|
||||
}
|
||||
|
||||
function resetTimer() {
|
||||
stopTimer();
|
||||
stopwatchSeconds = 0;
|
||||
elements.cookTimerVal.textContent = '00:00';
|
||||
}
|
||||
302
public/index.html
Normal file
302
public/index.html
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>GourmetVault – Curated Recipe Manager</title>
|
||||
<!-- Google Fonts Outfit & Inter -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&family=Outfit:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<!-- Lucide Icons -->
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<!-- Custom Styling -->
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body class="dark-theme">
|
||||
<!-- Top Navigation -->
|
||||
<header class="app-header">
|
||||
<div class="header-logo">
|
||||
<div class="logo-icon"><i data-lucide="cooking-pot"></i></div>
|
||||
<span class="logo-text">Gourmet<span>Vault</span></span>
|
||||
</div>
|
||||
|
||||
<div class="header-actions">
|
||||
<button class="btn btn-outline" id="btn-manual-add" title="Create Recipe Manually">
|
||||
<i data-lucide="plus"></i> <span>Manual Recipe</span>
|
||||
</button>
|
||||
<button class="theme-toggle" id="theme-toggle" title="Toggle Theme">
|
||||
<i data-lucide="sun" class="sun-icon"></i>
|
||||
<i data-lucide="moon" class="moon-icon"></i>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main Workspace -->
|
||||
<main class="app-container">
|
||||
<!-- Hero / URL Input Bar -->
|
||||
<section class="scraping-hero">
|
||||
<div class="hero-content">
|
||||
<h1>Save Recipes from Anywhere Instantly</h1>
|
||||
<p>Paste any cooking link below. Our engine will extract ingredients, directions, times, and images directly into your personal digital cookbook.</p>
|
||||
</div>
|
||||
|
||||
<form class="url-input-form" id="url-scrape-form">
|
||||
<div class="input-wrapper">
|
||||
<i data-lucide="link" class="input-icon"></i>
|
||||
<input type="url" id="scrape-url-input" placeholder="Paste cooking website link here (e.g., allrecipes.com, foodnetwork.com...)" required autocomplete="off">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" id="btn-scrape-submit">
|
||||
<i data-lucide="wand-2"></i> <span>Extract Recipe</span>
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<!-- Recipe Library Dashboard -->
|
||||
<section class="library-section">
|
||||
<div class="library-header">
|
||||
<h2 class="section-title">My Cookbook</h2>
|
||||
|
||||
<!-- Search and Filters -->
|
||||
<div class="filter-controls">
|
||||
<div class="search-wrapper">
|
||||
<i data-lucide="search" class="search-icon"></i>
|
||||
<input type="text" id="library-search" placeholder="Search saved recipes...">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recipes Grid -->
|
||||
<div class="recipes-grid" id="recipes-grid">
|
||||
<!-- Recipe cards will be loaded here dynamically -->
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div class="empty-state" id="empty-state">
|
||||
<div class="empty-icon"><i data-lucide="folder-open"></i></div>
|
||||
<h3>Your vault is empty</h3>
|
||||
<p>Insert a cooking web link above or create one manually to build your digital recipe collections.</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- SCRAPE PREVIEW & EDIT DIALOG -->
|
||||
<div class="modal-overlay" id="save-modal">
|
||||
<div class="modal-box max-width-lg">
|
||||
<div class="modal-header">
|
||||
<h3 id="modal-action-title">Scraped Recipe Draft</h3>
|
||||
<button class="close-btn" onclick="closeModal('save-modal')"><i data-lucide="x"></i></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="recipe-edit-form">
|
||||
<input type="hidden" id="edit-recipe-id">
|
||||
|
||||
<div class="form-grid-2">
|
||||
<div class="form-group">
|
||||
<label for="edit-title">Recipe Title *</label>
|
||||
<input type="text" id="edit-title" required placeholder="e.g. Garlic Butter Roast Salmon">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="edit-source">Source URL</label>
|
||||
<input type="url" id="edit-source" placeholder="Origin link">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="edit-description">Description / Short Review</label>
|
||||
<textarea id="edit-description" rows="2" placeholder="Brief note about the dish..."></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-grid-3">
|
||||
<div class="form-group">
|
||||
<label for="edit-servings">Servings</label>
|
||||
<input type="text" id="edit-servings" placeholder="e.g., 4">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit-prep-time">Prep Time</label>
|
||||
<input type="text" id="edit-prep-time" placeholder="e.g., 15 mins">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit-cook-time">Cook Time</label>
|
||||
<input type="text" id="edit-cook-time" placeholder="e.g., 30 mins">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Image Chooser -->
|
||||
<div class="form-group">
|
||||
<label>Cover Image</label>
|
||||
<p class="form-help">Select one of the images extracted from the site, or paste a custom URL below.</p>
|
||||
|
||||
<div class="image-gallery-selector" id="image-selection-grid">
|
||||
<!-- Dynamically loaded thumbnail options -->
|
||||
</div>
|
||||
|
||||
<div class="input-wrapper custom-img-url-wrapper">
|
||||
<i data-lucide="image" class="input-icon"></i>
|
||||
<input type="url" id="edit-image-url" placeholder="Or enter a direct custom image URL...">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-grid-2">
|
||||
<!-- Ingredients Editing -->
|
||||
<div class="form-group">
|
||||
<label>Ingredients (one per line)</label>
|
||||
<textarea id="edit-ingredients" rows="8" placeholder="1 lb Pasta 2 tbsp Olive oil 4 cloves Garlic..." required></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Instructions Editing -->
|
||||
<div class="form-group">
|
||||
<label>Instructions (one step per line)</label>
|
||||
<textarea id="edit-instructions" rows="8" placeholder="Boil water and cook pasta. Sauté garlic in olive oil. Combine pasta and sauce..." required></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline" onclick="closeModal('save-modal')">Discard</button>
|
||||
<button type="submit" form="recipe-edit-form" class="btn btn-primary" id="btn-save-confirm">
|
||||
<i data-lucide="save"></i> <span>Save to Vault</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- INTERACTIVE RECIPE DETAIL VIEWER -->
|
||||
<div class="modal-overlay" id="detail-modal">
|
||||
<div class="modal-box max-width-xl no-padding">
|
||||
<div class="detail-hero-banner" id="recipe-detail-banner">
|
||||
<button class="detail-back-btn" onclick="closeModal('detail-modal')"><i data-lucide="arrow-left"></i> <span>Back</span></button>
|
||||
<div class="detail-actions">
|
||||
<button class="btn btn-icon-round" id="btn-detail-edit" title="Edit Recipe"><i data-lucide="edit-3"></i></button>
|
||||
<button class="btn btn-icon-round btn-danger-hover" id="btn-detail-delete" title="Delete Recipe"><i data-lucide="trash-2"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-main-content">
|
||||
<div class="detail-header-info">
|
||||
<h2 id="recipe-detail-title">Garlic Butter Salmon</h2>
|
||||
<div class="recipe-meta-tags">
|
||||
<span id="tag-servings"><i data-lucide="users"></i> <span class="val">4 servings</span></span>
|
||||
<span id="tag-prep"><i data-lucide="clock"></i> Prep: <span class="val">15 mins</span></span>
|
||||
<span id="tag-cook"><i data-lucide="flame"></i> Cook: <span class="val">25 mins</span></span>
|
||||
</div>
|
||||
<p class="recipe-detail-desc" id="recipe-detail-description">A quick and delicious salmon dinner.</p>
|
||||
</div>
|
||||
|
||||
<div class="detail-tabs-container">
|
||||
<!-- Serving Adjuster -->
|
||||
<div class="servings-scaler-panel">
|
||||
<span class="scale-label">Scale Ingredients:</span>
|
||||
<div class="scaler-controls">
|
||||
<button class="scaler-btn" id="scaler-minus"><i data-lucide="minus"></i></button>
|
||||
<span class="scaler-val" id="current-servings-val">1x</span>
|
||||
<button class="scaler-btn" id="scaler-plus"><i data-lucide="plus"></i></button>
|
||||
</div>
|
||||
<button class="btn btn-accent cooking-mode-trigger" id="btn-trigger-cookmode">
|
||||
<i data-lucide="chef-hat"></i> <span>Start Cooking Mode</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="detail-split-layout">
|
||||
<!-- Left: Ingredients Checklist -->
|
||||
<div class="detail-ingredients-column">
|
||||
<h3>Ingredients</h3>
|
||||
<p class="section-subtext">Check off items as you gather them</p>
|
||||
<ul class="ingredients-check-list" id="recipe-detail-ingredients">
|
||||
<!-- Checkbox elements populated via JS -->
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Right: Instructions Checklist -->
|
||||
<div class="detail-instructions-column">
|
||||
<h3>Cooking Directions</h3>
|
||||
<p class="section-subtext">Track your progress step-by-step</p>
|
||||
<ol class="instructions-progress-list" id="recipe-detail-instructions">
|
||||
<!-- Steps populated via JS -->
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- FULLSCREEN INTERACTIVE COOKING MODE -->
|
||||
<div class="modal-overlay cooking-mode-overlay" id="cooking-mode-modal">
|
||||
<div class="cooking-container">
|
||||
<div class="cooking-header">
|
||||
<div class="cooking-brand">
|
||||
<i data-lucide="chef-hat"></i> <span>Cooking Assistant</span>
|
||||
</div>
|
||||
<div class="cooking-title-indicator" id="cook-recipe-title">Garlic Salmon</div>
|
||||
|
||||
<!-- Live Stopwatch / Timer -->
|
||||
<div class="cook-timer-panel">
|
||||
<div class="timer-display" id="cook-timer-val">00:00</div>
|
||||
<button class="timer-btn" id="cook-timer-play" title="Play/Pause stopwatch"><i data-lucide="play"></i></button>
|
||||
<button class="timer-btn btn-outline" id="cook-timer-reset" title="Reset stopwatch"><i data-lucide="rotate-ccw"></i></button>
|
||||
</div>
|
||||
|
||||
<button class="close-btn color-white" onclick="exitCookingMode()"><i data-lucide="x"></i></button>
|
||||
</div>
|
||||
|
||||
<div class="cooking-stepper-box">
|
||||
<div class="step-progress-track">
|
||||
<div class="step-progress-bar" id="cook-progress-bar"></div>
|
||||
</div>
|
||||
|
||||
<div class="cooking-slide">
|
||||
<span class="cooking-step-badge" id="cook-step-index">STEP 1 of 5</span>
|
||||
<div class="cooking-step-text" id="cook-step-instruction">Boil water and cook pasta.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cooking-controls">
|
||||
<button class="btn btn-outline btn-lg color-white" id="cook-btn-prev">
|
||||
<i data-lucide="chevron-left"></i> <span>Prev Step</span>
|
||||
</button>
|
||||
<div class="step-dots" id="cook-step-dots">
|
||||
<!-- Step indicator bullet points -->
|
||||
</div>
|
||||
<button class="btn btn-accent btn-lg" id="cook-btn-next">
|
||||
<span>Next Step</span> <span class="btn-icon-wrapper"><i data-lucide="chevron-right"></i></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- LOADING / SCRAPING STATUS SCREEN -->
|
||||
<div class="modal-overlay loading-overlay" id="loading-overlay">
|
||||
<div class="loader-card">
|
||||
<div class="spinner-outer">
|
||||
<div class="spinner-inner"></div>
|
||||
<i data-lucide="cooking-pot" class="spinner-logo"></i>
|
||||
</div>
|
||||
<h3>Extracting Recipe Data</h3>
|
||||
<p class="loader-subtitle">Retrieving site contents and parsing culinary structures...</p>
|
||||
|
||||
<ul class="loader-steps">
|
||||
<li class="loader-step active" id="loader-step-fetch">
|
||||
<span class="step-icon-wrapper"><i data-lucide="check-circle-2"></i></span> <span>Connecting to source URL...</span>
|
||||
</li>
|
||||
<li class="loader-step" id="loader-step-parse">
|
||||
<span class="step-icon-wrapper"><i data-lucide="circle-dashed" class="spinning-icon"></i></span> <span>Parsing schema structures...</span>
|
||||
</li>
|
||||
<li class="loader-step" id="loader-step-images">
|
||||
<span class="step-icon-wrapper"><i data-lucide="circle" class="dot-icon"></i></span> <span>Extracting high-res dish photos...</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Initialize Lucide Icons initially
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
lucide.createIcons();
|
||||
});
|
||||
</script>
|
||||
<!-- Frontend Javascript -->
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
1447
public/style.css
Normal file
1447
public/style.css
Normal file
File diff suppressed because it is too large
Load diff
551
server.js
Normal file
551
server.js
Normal file
|
|
@ -0,0 +1,551 @@
|
|||
const express = require('express');
|
||||
const axios = require('axios');
|
||||
const cheerio = require('cheerio');
|
||||
const cors = require('cors');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
// Setup directories for persistence
|
||||
const DATA_DIR = path.join(__dirname, 'data');
|
||||
const DATA_FILE = path.join(DATA_DIR, 'recipes.json');
|
||||
|
||||
if (!fs.existsSync(DATA_DIR)) {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
}
|
||||
if (!fs.existsSync(DATA_FILE)) {
|
||||
fs.writeFileSync(DATA_FILE, JSON.stringify([], null, 2));
|
||||
}
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
|
||||
// Helper: Parse ISO 8601 Durations (e.g. PT1H30M to "1 hr 30 mins")
|
||||
function parseISODuration(duration) {
|
||||
if (!duration || typeof duration !== 'string') return '';
|
||||
const match = duration.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/);
|
||||
if (!match) return duration;
|
||||
const hours = match[1] ? parseInt(match[1], 10) : 0;
|
||||
const minutes = match[2] ? parseInt(match[2], 10) : 0;
|
||||
|
||||
let result = '';
|
||||
if (hours > 0) result += `${hours} hr${hours > 1 ? 's' : ''} `;
|
||||
if (minutes > 0) result += `${minutes} min${minutes > 1 ? 's' : ''}`;
|
||||
return result.trim() || duration;
|
||||
}
|
||||
|
||||
// Helper: Extract recipe instructions from schema format
|
||||
function extractInstructions(instructions) {
|
||||
if (!instructions) return [];
|
||||
if (typeof instructions === 'string') {
|
||||
return instructions.split(/\r?\n/).map(s => s.trim()).filter(Boolean);
|
||||
}
|
||||
if (Array.isArray(instructions)) {
|
||||
const result = [];
|
||||
for (const inst of instructions) {
|
||||
if (typeof inst === 'string') {
|
||||
result.push(inst.trim());
|
||||
} else if (inst && typeof inst === 'object') {
|
||||
if (inst['@type'] === 'HowToStep') {
|
||||
result.push(inst.text || inst.name || '');
|
||||
} else if (inst['@type'] === 'HowToSection' && Array.isArray(inst.itemListElement)) {
|
||||
if (inst.name) {
|
||||
result.push(`### ${inst.name}`); // Prefix section names to identify them in the frontend
|
||||
}
|
||||
for (const step of inst.itemListElement) {
|
||||
if (typeof step === 'string') {
|
||||
result.push(step.trim());
|
||||
} else if (step && typeof step === 'object') {
|
||||
result.push(step.text || step.name || '');
|
||||
}
|
||||
}
|
||||
} else if (inst.text) {
|
||||
result.push(inst.text);
|
||||
} else if (inst.name) {
|
||||
result.push(inst.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.filter(Boolean);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// Helper: Normalize image resources from JSON-LD schema
|
||||
function normalizeImages(imageObj) {
|
||||
if (!imageObj) return [];
|
||||
if (typeof imageObj === 'string') return [imageObj];
|
||||
if (Array.isArray(imageObj)) {
|
||||
return imageObj.map(img => (typeof img === 'string' ? img : img.url)).filter(Boolean);
|
||||
}
|
||||
if (typeof imageObj === 'object' && imageObj.url) {
|
||||
return [imageObj.url];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// Helper: Retrieve saved recipes
|
||||
function readRecipes() {
|
||||
try {
|
||||
const data = fs.readFileSync(DATA_FILE, 'utf8');
|
||||
return JSON.parse(data);
|
||||
} catch (e) {
|
||||
console.error('Error reading recipes database:', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: Save recipes to storage
|
||||
function writeRecipes(recipes) {
|
||||
try {
|
||||
fs.writeFileSync(DATA_FILE, JSON.stringify(recipes, null, 2), 'utf8');
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('Error writing recipes database:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// API: List saved recipes
|
||||
app.get('/api/recipes', (req, res) => {
|
||||
const recipes = readRecipes();
|
||||
res.json(recipes);
|
||||
});
|
||||
|
||||
// API: Save new recipe
|
||||
app.post('/api/recipes', (req, res) => {
|
||||
const recipes = readRecipes();
|
||||
const newRecipe = {
|
||||
id: Date.now().toString(36) + Math.random().toString(36).substr(2, 5),
|
||||
title: req.body.title || 'Untitled Recipe',
|
||||
description: req.body.description || '',
|
||||
ingredients: Array.isArray(req.body.ingredients) ? req.body.ingredients : [],
|
||||
instructions: Array.isArray(req.body.instructions) ? req.body.instructions : [],
|
||||
servings: req.body.servings || '',
|
||||
prepTime: req.body.prepTime || '',
|
||||
cookTime: req.body.cookTime || '',
|
||||
totalTime: req.body.totalTime || '',
|
||||
image: req.body.image || '',
|
||||
images: Array.isArray(req.body.images) ? req.body.images : [],
|
||||
sourceUrl: req.body.sourceUrl || '',
|
||||
createdAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
recipes.push(newRecipe);
|
||||
writeRecipes(recipes);
|
||||
res.status(201).json(newRecipe);
|
||||
});
|
||||
|
||||
// API: Update recipe
|
||||
app.put('/api/recipes/:id', (req, res) => {
|
||||
const recipes = readRecipes();
|
||||
const index = recipes.findIndex(r => r.id === req.params.id);
|
||||
|
||||
if (index === -1) {
|
||||
return res.status(404).json({ error: 'Recipe not found' });
|
||||
}
|
||||
|
||||
const updatedRecipe = {
|
||||
...recipes[index],
|
||||
title: req.body.title || recipes[index].title,
|
||||
description: req.body.description !== undefined ? req.body.description : recipes[index].description,
|
||||
ingredients: Array.isArray(req.body.ingredients) ? req.body.ingredients : recipes[index].ingredients,
|
||||
instructions: Array.isArray(req.body.instructions) ? req.body.instructions : recipes[index].instructions,
|
||||
servings: req.body.servings !== undefined ? req.body.servings : recipes[index].servings,
|
||||
prepTime: req.body.prepTime !== undefined ? req.body.prepTime : recipes[index].prepTime,
|
||||
cookTime: req.body.cookTime !== undefined ? req.body.cookTime : recipes[index].cookTime,
|
||||
totalTime: req.body.totalTime !== undefined ? req.body.totalTime : recipes[index].totalTime,
|
||||
image: req.body.image !== undefined ? req.body.image : recipes[index].image,
|
||||
images: Array.isArray(req.body.images) ? req.body.images : recipes[index].images,
|
||||
sourceUrl: req.body.sourceUrl !== undefined ? req.body.sourceUrl : recipes[index].sourceUrl,
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
recipes[index] = updatedRecipe;
|
||||
writeRecipes(recipes);
|
||||
res.json(updatedRecipe);
|
||||
});
|
||||
|
||||
// API: Delete recipe
|
||||
app.delete('/api/recipes/:id', (req, res) => {
|
||||
let recipes = readRecipes();
|
||||
const filtered = recipes.filter(r => r.id !== req.params.id);
|
||||
|
||||
if (recipes.length === filtered.length) {
|
||||
return res.status(404).json({ error: 'Recipe not found' });
|
||||
}
|
||||
|
||||
writeRecipes(filtered);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// API: Scrape Recipe from Link
|
||||
app.post('/api/scrape', async (req, res) => {
|
||||
const { url } = req.body;
|
||||
if (!url) {
|
||||
return res.status(400).json({ error: 'URL is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.get(url, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8',
|
||||
'Accept-Language': 'en-US,en;q=0.9',
|
||||
'Accept-Encoding': 'gzip, deflate, br',
|
||||
'Connection': 'keep-alive',
|
||||
'sec-ch-ua': '"Not_A Brand";v="8", "Chromium";v="120", "Google Chrome";v="120"',
|
||||
'sec-ch-ua-mobile': '?0',
|
||||
'sec-ch-ua-platform': '"Windows"',
|
||||
'Sec-Fetch-Dest': 'document',
|
||||
'Sec-Fetch-Mode': 'navigate',
|
||||
'Sec-Fetch-Site': 'none',
|
||||
'Sec-Fetch-User': '?1',
|
||||
'Upgrade-Insecure-Requests': '1'
|
||||
},
|
||||
timeout: 10000
|
||||
});
|
||||
|
||||
const html = response.data;
|
||||
const $ = cheerio.load(html);
|
||||
|
||||
// Initial search: Extract JSON-LD structures
|
||||
let schemaRecipe = null;
|
||||
$('script[type="application/ld+json"]').each((i, el) => {
|
||||
try {
|
||||
const text = $(el).html();
|
||||
if (!text) return;
|
||||
const data = JSON.parse(text);
|
||||
|
||||
const searchForRecipe = (obj) => {
|
||||
if (!obj || typeof obj !== 'object') return;
|
||||
if (Array.isArray(obj)) {
|
||||
for (const item of obj) {
|
||||
searchForRecipe(item);
|
||||
if (schemaRecipe) return;
|
||||
}
|
||||
} else {
|
||||
const type = obj['@type'];
|
||||
if (type === 'Recipe' || (Array.isArray(type) && type.includes('Recipe'))) {
|
||||
schemaRecipe = obj;
|
||||
return;
|
||||
}
|
||||
for (const key in obj) {
|
||||
if (obj.hasOwnProperty(key)) {
|
||||
searchForRecipe(obj[key]);
|
||||
if (schemaRecipe) return;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
searchForRecipe(data);
|
||||
} catch (e) {
|
||||
// Safe to ignore malformed JSON script elements
|
||||
}
|
||||
});
|
||||
|
||||
// Populate structured data object
|
||||
let recipeData = {
|
||||
title: '',
|
||||
description: '',
|
||||
ingredients: [],
|
||||
instructions: [],
|
||||
servings: '',
|
||||
prepTime: '',
|
||||
cookTime: '',
|
||||
totalTime: '',
|
||||
images: [],
|
||||
sourceUrl: url
|
||||
};
|
||||
|
||||
// Scrape candidate image arrays from tags to enable user choice
|
||||
const extractedImages = [];
|
||||
$('img').each((i, el) => {
|
||||
const src = $(el).attr('src') || $(el).attr('data-src') || $(el).attr('data-lazy-src');
|
||||
if (src && src.startsWith('http')) {
|
||||
// Skip tracking pixels, tiny graphics or profile avatars
|
||||
const width = $(el).attr('width');
|
||||
const height = $(el).attr('height');
|
||||
const isSmall = (width && parseInt(width, 10) < 150) || (height && parseInt(height, 10) < 150);
|
||||
const containsTrashKeywords = /avatar|logo|icon|spacer|ad-|banner|pixel|tracking/i.test(src);
|
||||
|
||||
if (!isSmall && !containsTrashKeywords && !extractedImages.includes(src)) {
|
||||
extractedImages.push(src);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (schemaRecipe) {
|
||||
// Standard schema-based extraction
|
||||
recipeData.title = schemaRecipe.name || $('title').text().trim() || '';
|
||||
recipeData.description = schemaRecipe.description || '';
|
||||
|
||||
// Servings
|
||||
if (schemaRecipe.recipeYield) {
|
||||
recipeData.servings = Array.isArray(schemaRecipe.recipeYield)
|
||||
? schemaRecipe.recipeYield[0]
|
||||
: schemaRecipe.recipeYield.toString();
|
||||
}
|
||||
|
||||
// Times
|
||||
recipeData.prepTime = parseISODuration(schemaRecipe.prepTime || '');
|
||||
recipeData.cookTime = parseISODuration(schemaRecipe.cookTime || '');
|
||||
recipeData.totalTime = parseISODuration(schemaRecipe.totalTime || '');
|
||||
|
||||
// Ingredients (Support singular and plural, also support plain strings)
|
||||
const rawIngredients = schemaRecipe.recipeIngredient || schemaRecipe.recipeIngredients || [];
|
||||
if (typeof rawIngredients === 'string') {
|
||||
recipeData.ingredients = rawIngredients.split(/\r?\n/).map(i => i.trim()).filter(Boolean);
|
||||
} else if (Array.isArray(rawIngredients)) {
|
||||
recipeData.ingredients = rawIngredients;
|
||||
}
|
||||
|
||||
// Instructions (Support singular and plural)
|
||||
recipeData.instructions = extractInstructions(schemaRecipe.recipeInstructions || schemaRecipe.recipeInstruction);
|
||||
|
||||
// Schema Image fallback
|
||||
const schemaImages = normalizeImages(schemaRecipe.image);
|
||||
schemaImages.forEach(img => {
|
||||
if (!extractedImages.includes(img)) {
|
||||
extractedImages.unshift(img); // Prioritize JSON-LD schema images at the start
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Fallback Scraping strategies if JSON-LD is missing
|
||||
recipeData.title = $('meta[property="og:title"]').attr('content') || $('title').text().trim() || '';
|
||||
recipeData.description = $('meta[property="og:description"]').attr('content') || $('meta[name="description"]').attr('content') || '';
|
||||
|
||||
const ogImg = $('meta[property="og:image"]').attr('content');
|
||||
if (ogImg && !extractedImages.includes(ogImg)) {
|
||||
extractedImages.unshift(ogImg);
|
||||
}
|
||||
|
||||
// Parse ingredients structurally
|
||||
const ingredientSelectors = [
|
||||
'.wprm-recipe-ingredient',
|
||||
'.tasty-recipes-ingredients li',
|
||||
'.tasty-recipe-ingredients li',
|
||||
'.recipe-ingredients li',
|
||||
'.ingredients-list li',
|
||||
'.ingredients li',
|
||||
'[class*="ingredients"] li',
|
||||
'[class*="ingredients"] p',
|
||||
'[class*="ingredient"] li',
|
||||
'[class*="ingredient"] p',
|
||||
'ul[class*="ingredient"] li',
|
||||
'ol[class*="ingredient"] li',
|
||||
'.cooked-ingredient',
|
||||
'.recipe-ingredients-wrap li'
|
||||
];
|
||||
|
||||
for (const selector of ingredientSelectors) {
|
||||
const items = $(selector);
|
||||
if (items.length > 1) {
|
||||
items.each((i, el) => {
|
||||
const txt = $(el).text().trim().replace(/\s+/g, ' ');
|
||||
if (txt && !recipeData.ingredients.includes(txt)) {
|
||||
recipeData.ingredients.push(txt);
|
||||
}
|
||||
});
|
||||
if (recipeData.ingredients.length > 1) break;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse instructions structurally
|
||||
const instructionSelectors = [
|
||||
'.wprm-recipe-instruction',
|
||||
'.tasty-recipes-instructions li',
|
||||
'.tasty-recipe-instructions li',
|
||||
'.recipe-directions li',
|
||||
'.recipe-instructions li',
|
||||
'.instructions-list li',
|
||||
'.instructions li',
|
||||
'.recipe-steps li',
|
||||
'[class*="instructions"] li',
|
||||
'[class*="instructions"] p',
|
||||
'[class*="directions"] li',
|
||||
'[class*="directions"] p',
|
||||
'[class*="step"] li',
|
||||
'[class*="step"] p',
|
||||
'.cooked-step',
|
||||
'.recipe-method li',
|
||||
'.recipe-method p',
|
||||
'.recipe-preparation li'
|
||||
];
|
||||
|
||||
for (const selector of instructionSelectors) {
|
||||
const items = $(selector);
|
||||
if (items.length > 1) {
|
||||
items.each((i, el) => {
|
||||
const txt = $(el).text().trim().replace(/\s+/g, ' ');
|
||||
if (txt && !recipeData.instructions.includes(txt)) {
|
||||
recipeData.instructions.push(txt);
|
||||
}
|
||||
});
|
||||
if (recipeData.instructions.length > 1) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Heuristics Fallback: Heading Sibling crawl if still empty
|
||||
if (recipeData.ingredients.length === 0) {
|
||||
$('h1, h2, h3, h4, h5').each((i, heading) => {
|
||||
const text = $(heading).text().toLowerCase();
|
||||
if (text.includes('ingredients') && !text.includes('instruction') && !text.includes('direction')) {
|
||||
let next = $(heading).next();
|
||||
if (next.length === 0) {
|
||||
next = $(heading).parent().next();
|
||||
}
|
||||
|
||||
let limit = 0;
|
||||
while (next.length > 0 && limit < 15) {
|
||||
if (/h[1-6]/i.test(next[0].tagName)) break;
|
||||
|
||||
const items = next.find('li, p');
|
||||
if (items.length > 0) {
|
||||
items.each((j, el) => {
|
||||
const itemText = $(el).text().trim().replace(/\s+/g, ' ');
|
||||
if (itemText && itemText.length > 2 && itemText.length < 200 && !recipeData.ingredients.includes(itemText)) {
|
||||
recipeData.ingredients.push(itemText);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const itemText = next.text().trim().replace(/\s+/g, ' ');
|
||||
if (itemText && itemText.length > 2 && itemText.length < 200) {
|
||||
recipeData.ingredients.push(itemText);
|
||||
}
|
||||
}
|
||||
next = next.next();
|
||||
limit++;
|
||||
}
|
||||
if (recipeData.ingredients.length > 2) return false; // Break heading loop
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (recipeData.instructions.length === 0) {
|
||||
$('h1, h2, h3, h4, h5').each((i, heading) => {
|
||||
const text = $(heading).text().toLowerCase();
|
||||
if (/instruction|direction|method|preparation|steps/i.test(text) && !text.includes('ingredient')) {
|
||||
let next = $(heading).next();
|
||||
if (next.length === 0) {
|
||||
next = $(heading).parent().next();
|
||||
}
|
||||
|
||||
let limit = 0;
|
||||
while (next.length > 0 && limit < 15) {
|
||||
if (/h[1-6]/i.test(next[0].tagName)) break;
|
||||
|
||||
const items = next.find('li, p');
|
||||
if (items.length > 0) {
|
||||
items.each((j, el) => {
|
||||
const itemText = $(el).text().trim().replace(/\s+/g, ' ');
|
||||
if (itemText && itemText.length > 5 && itemText.length < 1000 && !recipeData.instructions.includes(itemText)) {
|
||||
recipeData.instructions.push(itemText);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const itemText = next.text().trim().replace(/\s+/g, ' ');
|
||||
if (itemText && itemText.length > 5 && itemText.length < 1000) {
|
||||
recipeData.instructions.push(itemText);
|
||||
}
|
||||
}
|
||||
next = next.next();
|
||||
limit++;
|
||||
}
|
||||
if (recipeData.instructions.length > 1) return false; // Break heading loop
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Heuristics Fallback: Blockquote / Multi-paragraph text classifier (for vintage/informal blogs)
|
||||
if (recipeData.ingredients.length === 0 && recipeData.instructions.length === 0) {
|
||||
const isStepText = (text) => {
|
||||
if (/^(?:step\s*)?\d+[\.\)\-]\s/i.test(text)) return true;
|
||||
if (/^\d+\s+(?:mix|stir|combine|sift|bake|heat|preheat|fry|drain|serve|boil|chop|pour|place|add|cook|whisk|beat|divide|mould|brush|roll|repeat|prepare|cut)\b/i.test(text)) return true;
|
||||
if (/^(?:mix|stir|combine|sift|bake|heat|preheat|fry|drain|serve|boil|chop|pour|place|add|cook|whisk|beat|divide|mould|brush|roll|repeat|prepare|cut)\b/i.test(text) && text.length > 25) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
const isTitleOrHeader = (text) => {
|
||||
const titleClean = recipeData.title.toLowerCase();
|
||||
const textClean = text.toLowerCase();
|
||||
if (titleClean.includes(textClean) || textClean.includes(titleClean)) return true;
|
||||
if (/^(ingredients|instructions|directions|method|results|notes|conclusion)\b/i.test(textClean)) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
// Prioritize containers: blockquote first (specific card), then entry-content, then article
|
||||
let selectedContainer = null;
|
||||
if ($('blockquote').length > 0) {
|
||||
selectedContainer = $('blockquote');
|
||||
} else if ($('.entry-content').length > 0) {
|
||||
selectedContainer = $('.entry-content');
|
||||
} else if ($('article').length > 0) {
|
||||
selectedContainer = $('article');
|
||||
}
|
||||
|
||||
if (selectedContainer) {
|
||||
selectedContainer.each((i, container) => {
|
||||
const paragraphs = $(container).find('p');
|
||||
if (paragraphs.length > 2) {
|
||||
paragraphs.each((j, el) => {
|
||||
const html = $(el).html() || '';
|
||||
if (html.includes('<br>')) {
|
||||
// Split by BR tags if it's a single block
|
||||
const lines = html.split(/<br\s*\/?>/i)
|
||||
.map(l => cheerio.load(l).text().trim().replace(/\s+/g, ' '))
|
||||
.filter(Boolean);
|
||||
|
||||
for (const line of lines) {
|
||||
if (isTitleOrHeader(line) || line.length < 3) continue;
|
||||
if (isStepText(line)) {
|
||||
recipeData.instructions.push(line);
|
||||
} else {
|
||||
recipeData.ingredients.push(line);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const txt = $(el).text().trim().replace(/\s+/g, ' ');
|
||||
if (!txt || isTitleOrHeader(txt) || txt.length < 3) return;
|
||||
|
||||
if (isStepText(txt)) {
|
||||
recipeData.instructions.push(txt);
|
||||
} else {
|
||||
recipeData.ingredients.push(txt);
|
||||
}
|
||||
}
|
||||
});
|
||||
if (recipeData.ingredients.length > 1) return false; // Break container loop
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up lists
|
||||
recipeData.ingredients = recipeData.ingredients.map(i => i.trim()).filter(Boolean);
|
||||
recipeData.instructions = recipeData.instructions.map(i => i.trim()).filter(Boolean);
|
||||
recipeData.images = extractedImages.slice(0, 15); // Send top 15 images to UI for selection
|
||||
|
||||
res.json(recipeData);
|
||||
} catch (error) {
|
||||
console.error('Error occurred while scraping URL:', error.message);
|
||||
const status = error.response ? error.response.status : 500;
|
||||
const message = error.response && error.response.status === 403
|
||||
? 'This website is protected by Cloudflare bot protection and blocks automated access.'
|
||||
: error.message;
|
||||
res.status(status).json({
|
||||
error: message,
|
||||
isBlocked: status === 403 || status === 503
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Launch server
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Recipe Manager server is active at http://localhost:${PORT}`);
|
||||
});
|
||||
Loading…
Reference in a new issue