2026-04-28 19:06:37 -05:00
<!DOCTYPE html>
< html lang = "en" >
< head >
< meta charset = "UTF-8" / >
< meta name = "viewport" content = "width=device-width, initial-scale=1.0" / >
< title > Recipe Generator< / title >
< style >
@import url('https://fonts.googleapis.com/css2?family=Lora:ital,wght@0,400;0,600;1,400&family=DM+Sans:wght@300; 400;500&display=swap');
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--color-text-primary: #1a1a1a;
--color-text-secondary: #6b6b6b;
--color-background-primary: #ffffff;
--color-background-secondary: #f5f5f4;
--color-background-danger: #fef2f2;
--color-text-danger: #b91c1c;
--color-border-primary: #a3a3a3;
--color-border-secondary: #d4d4d4;
--color-border-tertiary: #e5e5e5;
--border-radius-md: 8px;
--border-radius-lg: 12px;
}
body {
font-family: 'DM Sans', sans-serif;
background: var(--color-background-primary);
color: var(--color-text-primary);
padding: 2rem;
display: flex;
justify-content: center;
}
2026-04-29 06:25:42 +00:00
.app { display: none; width: 100%; max-width: 680px; padding: 2rem 0; }
#login-overlay { position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: var(--color-background-secondary); display: flex; align-items: center; justify-content: center; z-index: 1000; }
.login-card { background: var(--color-background-primary); padding: 2rem; border: 0.5px solid var(--color-border-secondary); border-radius: var(--border-radius-lg); width: 100%; max-width: 320px; box-shadow: 0 4px 12px rgba(0,0,0,0.05); }
.login-card h2 { font-family: 'Lora', serif; font-size: 24px; margin-bottom: 1.25rem; text-align: center; }
2026-04-28 19:06:37 -05:00
.header { margin-bottom: 2rem; border-bottom: 0.5px solid var(--color-border-tertiary); padding-bottom: 1.25rem; }
.header h1 { font-family: 'Lora', serif; font-size: 26px; font-weight: 600; letter-spacing: -0.5px; margin-bottom: 4px; }
.header p { font-size: 14px; color: var(--color-text-secondary); font-weight: 300; }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-bottom: 14px; }
.form-field { display: flex; flex-direction: column; gap: 6px; }
.form-field.full { grid-column: 1 / -1; }
label { font-size: 12px; font-weight: 500; color: var(--color-text-secondary); letter-spacing: 0.04em; text-transform: uppercase; }
input, textarea, select {
font-family: 'DM Sans', sans-serif; font-size: 14px; padding: 9px 12px;
border: 0.5px solid var(--color-border-secondary); border-radius: var(--border-radius-md);
background: var(--color-background-primary); color: var(--color-text-primary);
width: 100%; transition: border-color 0.15s; outline: none;
}
input:focus, textarea:focus, select:focus { border-color: var(--color-border-primary); }
textarea { resize: vertical; min-height: 72px; line-height: 1.5; }
.tags-input-wrapper {
border: 0.5px solid var(--color-border-secondary); border-radius: var(--border-radius-md);
background: var(--color-background-primary); padding: 6px 8px;
display: flex; flex-wrap: wrap; gap: 6px; align-items: center;
cursor: text; min-height: 40px; transition: border-color 0.15s;
}
.tags-input-wrapper:focus-within { border-color: var(--color-border-primary); }
.tag { background: var(--color-background-secondary); border: 0.5px solid var(--color-border-tertiary); border-radius: 4px; padding: 2px 8px; font-size: 13px; display: flex; align-items: center; gap: 5px; white-space: nowrap; }
.tag-x { cursor: pointer; color: var(--color-text-secondary); font-size: 15px; line-height: 1; }
.tag-x:hover { color: var(--color-text-primary); }
.tags-input { border: none !important; padding: 2px 4px !important; flex: 1; min-width: 100px; font-size: 14px; background: transparent !important; outline: none !important; }
.checkboxes { display: flex; flex-wrap: wrap; gap: 8px; }
.check-pill { display: flex; align-items: center; gap: 6px; padding: 5px 12px; border: 0.5px solid var(--color-border-secondary); border-radius: 20px; font-size: 13px; cursor: pointer; transition: all 0.12s; background: var(--color-background-primary); user-select: none; }
.check-pill input { display: none; }
.check-pill.checked { background: var(--color-background-secondary); border-color: var(--color-border-primary); font-weight: 500; }
.dot { width: 7px; height: 7px; border-radius: 50%; background: var(--color-border-secondary); flex-shrink: 0; }
.check-pill.checked .dot { background: var(--color-text-primary); }
.generate-btn { width: 100%; padding: 11px; background: var(--color-text-primary); color: var(--color-background-primary); border: none; border-radius: var(--border-radius-md); font-family: 'DM Sans', sans-serif; font-size: 15px; font-weight: 500; cursor: pointer; margin-top: 8px; transition: opacity 0.15s; }
.generate-btn:hover { opacity: 0.85; }
.generate-btn:disabled { opacity: 0.45; cursor: not-allowed; }
.status { text-align: center; padding: 2rem 0; color: var(--color-text-secondary); font-size: 14px; font-style: italic; }
.recipe-out { margin-top: 2.5rem; border-top: 0.5px solid var(--color-border-tertiary); padding-top: 2rem; animation: fadein 0.4s ease; }
@keyframes fadein { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
2026-04-29 11:50:44 -05:00
.recipe-actions { display: flex; gap: 10px; margin-top: 1.5rem; }
.action-btn { flex: 1; padding: 10px; background: var(--color-background-primary); border: 0.5px solid var(--color-border-primary); border-radius: var(--border-radius-md); font-family: 'DM Sans', sans-serif; font-size: 14px; font-weight: 500; cursor: pointer; transition: all 0.15s; }
.action-btn:hover { background: var(--color-background-secondary); }
.saved-menu-btn { position: fixed; top: 20px; right: 20px; padding: 8px 16px; background: var(--color-text-primary); color: white; border: none; border-radius: 20px; font-size: 13px; font-weight: 500; cursor: pointer; z-index: 100; box-shadow: 0 2px 8px rgba(0,0,0,0.15); }
#saved-menu { position: fixed; top: 0; right: -350px; width: 320px; height: 100vh; background: var(--color-background-primary); box-shadow: -4px 0 15px rgba(0,0,0,0.05); transition: right 0.3s ease; z-index: 200; padding: 2rem 1.5rem; overflow-y: auto; border-left: 1px solid var(--color-border-tertiary); }
#saved-menu.open { right: 0; }
.saved-menu-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem; }
.saved-menu-header h2 { font-family: 'Lora', serif; font-size: 20px; }
.close-menu { cursor: pointer; font-size: 24px; color: var(--color-text-secondary); line-height: 1; background: none; border: none; }
.saved-recipe-item { padding: 12px; border: 0.5px solid var(--color-border-secondary); border-radius: var(--border-radius-md); margin-bottom: 10px; cursor: pointer; transition: border-color 0.15s; }
.saved-recipe-item:hover { border-color: var(--color-text-primary); }
.saved-recipe-item h3 { font-size: 15px; margin-bottom: 4px; }
.saved-recipe-item p { font-size: 12px; color: var(--color-text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
2026-04-28 19:06:37 -05:00
.recipe-title { font-family: 'Lora', serif; font-size: 28px; font-weight: 600; letter-spacing: -0.5px; margin-bottom: 6px; }
.recipe-desc { font-size: 14px; color: var(--color-text-secondary); font-style: italic; margin-bottom: 1.25rem; line-height: 1.6; }
.meta-row { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 1.75rem; }
.meta-chip { background: var(--color-background-secondary); border-radius: var(--border-radius-md); padding: 6px 14px; font-size: 13px; color: var(--color-text-secondary); }
.meta-chip strong { color: var(--color-text-primary); font-weight: 500; }
.section-label { font-size: 11px; font-weight: 500; letter-spacing: 0.08em; text-transform: uppercase; color: var(--color-text-secondary); margin-bottom: 10px; margin-top: 1.5rem; }
.ingredients-list { list-style: none; border: 0.5px solid var(--color-border-tertiary); border-radius: var(--border-radius-lg); overflow: hidden; }
.ingredients-list li { padding: 9px 16px; font-size: 14px; border-bottom: 0.5px solid var(--color-border-tertiary); display: flex; gap: 10px; align-items: baseline; }
.ingredients-list li:last-child { border-bottom: none; }
.ing-amount { font-weight: 500; min-width: 60px; }
.ing-name { color: var(--color-text-secondary); }
.steps-list { list-style: none; }
.step-item { display: flex; gap: 16px; margin-bottom: 1.25rem; }
.step-num { font-family: 'Lora', serif; font-size: 18px; font-weight: 600; color: var(--color-text-secondary); min-width: 28px; line-height: 1.5; }
.step-title { font-weight: 500; font-size: 14px; margin-bottom: 4px; }
.step-instr { font-size: 14px; line-height: 1.7; color: var(--color-text-secondary); }
.notes-box { background: var(--color-background-secondary); border-radius: 0 var(--border-radius-md) var(--border-radius-md) 0; padding: 12px 16px; font-size: 14px; line-height: 1.6; color: var(--color-text-secondary); margin-top: 1.5rem; border-left: 2px solid var(--color-border-primary); }
.error-msg { background: var(--color-background-danger); color: var(--color-text-danger); border-radius: var(--border-radius-md); padding: 12px 16px; font-size: 13px; margin-top: 1rem; line-height: 1.5; }
.spinner { display: inline-block; width: 16px; height: 16px; border: 2px solid rgba(255,255,255,0.35); border-top-color: #fff; border-radius: 50%; animation: spin 0.7s linear infinite; vertical-align: middle; margin-right: 6px; }
@keyframes spin { to { transform: rotate(360deg); } }
< / style >
< / head >
< body >
2026-04-29 06:25:42 +00:00
< div id = "login-overlay" >
< div class = "login-card" >
< h2 style = "font-family: 'Lora', serif; font-size: 26px; font-weight: 600; letter-spacing: -0.5px; margin-bottom: 20px;" > Welcome< / h2 >
< div class = "form-field" style = "margin-bottom: 12px;" >
< label > Username< / label >
< input type = "text" id = "login-username" / >
< / div >
< div class = "form-field" style = "margin-bottom: 12px;" >
< label > Password< / label >
< input type = "password" id = "login-password" / >
< / div >
< button class = "generate-btn" id = "login-btn" style = "margin-top: 12px;" > Sign in< / button >
< div id = "login-error" style = "color: var(--color-text-danger); font-size: 13px; margin-top: 10px; text-align: center; display: none;" > Invalid credentials< / div >
< / div >
< / div >
< div class = "app" id = "app-content" >
2026-04-29 11:50:44 -05:00
< button class = "saved-menu-btn" id = "open-menu-btn" > View Saved Recipes< / button >
< div id = "saved-menu" >
< div class = "saved-menu-header" >
< h2 > Saved Recipes< / h2 >
< button class = "close-menu" id = "close-menu-btn" > × < / button >
< / div >
< div id = "saved-list" >
<!-- Saved items go here -->
< / div >
< / div >
2026-04-28 19:06:37 -05:00
< div class = "header" >
< h1 > Recipe Generator< / h1 >
< p > Tell the AI what you have and what you want — it handles the rest.< / p >
< / div >
2026-04-29 11:50:44 -05:00
<!-- Reel Ingestion Section -->
< div class = "form-grid" style = "margin-bottom: 2rem; border: 1px solid var(--color-border-primary); padding: 1.5rem; border-radius: var(--border-radius-md); background: rgba(0, 0, 0, 0.02);" >
< div class = "form-field full" style = "margin-bottom: 0;" >
< label style = "font-weight: 600; color: var(--color-text-primary);" > Extract Recipe from Instagram/TikTok Reel< / label >
< p style = "font-size: 13px; color: var(--color-text-secondary); margin-top: 4px; margin-bottom: 12px;" > Paste a link to extract frames and audio, and let the VLM structure the recipe.< / p >
< div style = "display: flex; gap: 10px;" >
< input type = "text" id = "reel-url" placeholder = "https://www.instagram.com/reel/..." style = "flex: 1;" / >
< / div >
< button class = "generate-btn" id = "ingest-btn" style = "margin-top: 12px; background: var(--color-background-primary); color: var(--color-text-primary); border: 1px solid var(--color-text-primary);" > Extract from Reel< / button >
< / div >
< / div >
< div style = "text-align: center; margin-bottom: 2rem; font-family: 'Lora', serif; color: var(--color-text-secondary); font-style: italic;" > — OR GENERATE FROM SCRATCH —< / div >
2026-04-28 19:06:37 -05:00
< div class = "form-grid" >
< div class = "form-field full" >
< label > Ingredients on hand< / label >
< div class = "tags-input-wrapper" id = "ing-wrapper" >
< input class = "tags-input" id = "ing-input" placeholder = "Type an ingredient, press Enter..." / >
< / div >
< / div >
< div class = "form-field full" >
< label > What do you want to make?< / label >
< input type = "text" id = "goal" placeholder = "e.g. a hearty weeknight pasta, a light summer salad..." / >
< / div >
< div class = "form-field full" >
< label > Dietary restrictions< / label >
< div class = "checkboxes" >
< label class = "check-pill" > < input type = "checkbox" value = "vegan" > < span class = "dot" > < / span > Vegan< / label >
< label class = "check-pill" > < input type = "checkbox" value = "vegetarian" > < span class = "dot" > < / span > Vegetarian< / label >
< label class = "check-pill" > < input type = "checkbox" value = "gluten-free" > < span class = "dot" > < / span > Gluten-free< / label >
< label class = "check-pill" > < input type = "checkbox" value = "dairy-free" > < span class = "dot" > < / span > Dairy-free< / label >
< label class = "check-pill" > < input type = "checkbox" value = "nut-free" > < span class = "dot" > < / span > Nut-free< / label >
< label class = "check-pill" > < input type = "checkbox" value = "low-carb" > < span class = "dot" > < / span > Low-carb< / label >
< / div >
< / div >
< div class = "form-field" >
< label > Cuisine style< / label >
< select id = "cuisine" >
< option value = "" > Any cuisine< / option >
< option > Italian< / option > < option > Mexican< / option > < option > Asian fusion< / option >
< option > Mediterranean< / option > < option > French< / option > < option > Indian< / option >
< option > American< / option > < option > Middle Eastern< / option > < option > Japanese< / option > < option > Thai< / option >
< / select >
< / div >
< div class = "form-field" >
< label > Time available< / label >
< select id = "time" >
< option value = "" > No limit< / option >
< option value = "under 20 minutes" > Under 20 minutes< / option >
< option value = "under 30 minutes" > Under 30 minutes< / option >
< option value = "under 45 minutes" > Under 45 minutes< / option >
< option value = "under 1 hour" > Under 1 hour< / option >
< option value = "1– 2 hours" > 1– 2 hours< / option >
< / select >
< / div >
< div class = "form-field" >
< label > Skill level< / label >
< select id = "skill" >
< option value = "" > Any level< / option >
< option value = "beginner-friendly" > Beginner-friendly< / option >
< option value = "intermediate" > Intermediate< / option >
< option value = "advanced" > Advanced chef techniques< / option >
< / select >
< / div >
< div class = "form-field" >
< label > Servings< / label >
< select id = "servings" >
< option value = "1" > 1< / option > < option value = "2" selected > 2< / option >
< option value = "4" > 4< / option > < option value = "6" > 6< / option > < option value = "8" > 8< / option >
< / select >
< / div >
< div class = "form-field full" >
< label > Any other notes or preferences< / label >
< textarea id = "notes" placeholder = "e.g. my kids hate mushrooms, I want it spicy, make it impressive enough for guests..." > < / textarea >
< / div >
< / div >
2026-04-28 19:24:03 -05:00
< button class = "generate-btn" id = "gen-btn" > Generate my recipe< / button >
2026-04-28 19:06:37 -05:00
< div id = "error-area" > < / div >
< div id = "recipe-area" > < / div >
< / div >
2026-04-28 19:24:03 -05:00
< script type = "module" >
2026-04-29 06:25:42 +00:00
let sessionUser = '';
let sessionPass = '';
const loginBtn = document.getElementById('login-btn');
const loginOverlay = document.getElementById('login-overlay');
const appContent = document.getElementById('app-content');
loginBtn.addEventListener('click', async () => {
const user = document.getElementById('login-username').value;
const pass = document.getElementById('login-password').value;
const btn = document.getElementById('login-btn');
btn.disabled = true;
btn.innerText = 'Verifying...';
try {
const res = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: user, password: pass })
});
if (res.ok) {
sessionUser = user;
sessionPass = pass;
loginOverlay.style.display = 'none';
appContent.style.display = 'block';
} else {
document.getElementById('login-error').style.display = 'block';
}
} catch (err) {
document.getElementById('login-error').innerText = 'Network error. Please try again.';
document.getElementById('login-error').style.display = 'block';
}
btn.disabled = false;
btn.innerText = 'Sign in';
});
2026-04-28 19:06:37 -05:00
const ingredients = [];
const ingWrapper = document.getElementById('ing-wrapper');
const ingInput = document.getElementById('ing-input');
2026-04-28 19:24:03 -05:00
const genBtn = document.getElementById('gen-btn');
2026-04-29 11:50:44 -05:00
const ingestBtn = document.getElementById('ingest-btn');
2026-04-28 19:24:03 -05:00
genBtn.addEventListener('click', generate);
2026-04-29 11:50:44 -05:00
ingestBtn.addEventListener('click', ingestReel);
2026-04-28 19:06:37 -05:00
ingWrapper.addEventListener('click', () => ingInput.focus());
ingInput.addEventListener('keydown', e => {
if ((e.key === 'Enter' || e.key === ',') & & ingInput.value.trim()) {
e.preventDefault();
addTag(ingInput.value.trim().replace(/,$/, ''));
ingInput.value = '';
} else if (e.key === 'Backspace' & & !ingInput.value & & ingredients.length) {
removeTag(ingredients.length - 1);
}
});
function addTag(val) {
if (!val || ingredients.includes(val.toLowerCase())) return;
ingredients.push(val.toLowerCase());
const tag = document.createElement('div');
tag.className = 'tag';
tag.innerHTML = val + '< span class = "tag-x" > × < / span > ';
tag.querySelector('.tag-x').addEventListener('click', (e) => { e.stopPropagation(); removeTagByVal(val.toLowerCase()); });
ingWrapper.insertBefore(tag, ingInput);
}
function removeTagByVal(val) {
const i = ingredients.indexOf(val);
if (i > -1) { ingredients.splice(i, 1); renderTags(); }
}
function removeTag(idx) { ingredients.splice(idx, 1); renderTags(); }
function renderTags() {
ingWrapper.querySelectorAll('.tag').forEach(t => t.remove());
const copy = [...ingredients]; ingredients.length = 0;
copy.forEach(i => addTag(i));
}
document.querySelectorAll('.check-pill').forEach(pill => {
pill.addEventListener('click', () => {
const cb = pill.querySelector('input');
cb.checked = !cb.checked;
pill.classList.toggle('checked', cb.checked);
});
});
async function generate() {
const goal = document.getElementById('goal').value.trim();
if (!goal & & ingredients.length === 0) {
showError('Please add some ingredients or describe what you want to make.');
return;
}
const restrictions = [...document.querySelectorAll('.check-pill input:checked')].map(c => c.value);
const cuisine = document.getElementById('cuisine').value;
const time = document.getElementById('time').value;
const skill = document.getElementById('skill').value;
const servings = document.getElementById('servings').value;
const notes = document.getElementById('notes').value.trim();
const btn = document.getElementById('gen-btn');
btn.disabled = true;
btn.innerHTML = '< span class = "spinner" > < / span > Generating…';
document.getElementById('error-area').innerHTML = '';
document.getElementById('recipe-area').innerHTML = '< div class = "status" > Crafting your recipe with care…< / div > ';
const userPrompt = [
ingredients.length ? `Ingredients available: ${ingredients.join(', ')}` : '',
goal ? `Food goal: ${goal}` : '',
restrictions.length ? `Dietary restrictions: ${restrictions.join(', ')}` : '',
cuisine ? `Preferred cuisine: ${cuisine}` : '',
time ? `Time constraint: ${time}` : '',
skill ? `Skill level: ${skill}` : '',
`Servings: ${servings}`,
notes ? `Additional preferences: ${notes}` : '',
].filter(Boolean).join('\n');
const systemPrompt = `You are an expert chef and culinary writer. Create a complete, precise recipe based on the user's inputs.
CRITICAL: Instructions must be specific — never vague. Include exact temperatures, times, visual cues, sounds, smells, and textures. Example: instead of "cook the onions", write "cook the onions in the butter over medium heat, stirring occasionally, for 8– 10 minutes until they turn translucent and just begin to turn golden at the edges."
2026-04-28 19:24:03 -05:00
Respond with ONLY a valid JSON object. Use this exact structure:
2026-04-28 19:06:37 -05:00
{"title":"string","description":"string","servings":2,"prepTime":"string","cookTime":"string","ingredients":[{"amount":"string","unit":"string","name":"string"}],"steps":[{"title":"string","instruction":"string"}],"notes":"string or null"}
Rules: use provided ingredients as the base, add pantry staples as needed, 5– 10 steps, each instruction 1– 3 richly detailed sentences, respect all dietary restrictions strictly.`;
try {
2026-04-29 06:25:42 +00:00
const resp = await fetch('/api/generate', {
2026-04-28 19:06:37 -05:00
method: 'POST',
2026-04-29 06:25:42 +00:00
headers: { 'Content-Type': 'application/json' },
2026-04-28 19:06:37 -05:00
body: JSON.stringify({
2026-04-29 06:25:42 +00:00
username: sessionUser,
password: sessionPass,
systemPrompt: systemPrompt,
userPrompt: userPrompt
2026-04-28 19:06:37 -05:00
})
});
const data = await resp.json();
if (!resp.ok) {
2026-04-29 06:25:42 +00:00
throw new Error(data.error || `Server error ${resp.status}`);
2026-04-28 19:06:37 -05:00
}
2026-04-28 19:24:03 -05:00
const raw = data.candidates[0].content.parts[0].text;
const recipe = JSON.parse(raw);
2026-04-28 19:06:37 -05:00
renderRecipe(recipe);
} catch (err) {
document.getElementById('recipe-area').innerHTML = '';
showError('Error: ' + (err.message || 'Unknown error. Please try again.'));
}
btn.disabled = false;
btn.innerHTML = 'Generate my recipe';
}
2026-04-29 11:50:44 -05:00
async function ingestReel() {
const url = document.getElementById('reel-url').value.trim();
if (!url) {
showError('Please paste a valid Instagram or TikTok Reel URL.');
return;
}
const btn = document.getElementById('ingest-btn');
btn.disabled = true;
btn.innerHTML = '< span class = "spinner" > < / span > Extracting & Analyzing Video... (This takes 15-30s) ';
document.getElementById('error-area').innerHTML = '';
document.getElementById('recipe-area').innerHTML = '< div class = "status" > Downloading reel and sending to Gemini Vision...< / div > ';
try {
const resp = await fetch('/api/ingest', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: sessionUser,
password: sessionPass,
url: url
})
});
const data = await resp.json();
if (!resp.ok) {
throw new Error(data.error || `Server error ${resp.status}`);
}
// Render the recipe
renderRecipe(data.recipe);
currentRecipeId = data.id;
document.getElementById('recipe-area').scrollIntoView({ behavior: 'smooth' });
} catch (err) {
document.getElementById('recipe-area').innerHTML = '';
showError('Error processing reel: ' + (err.message || 'Unknown error. Please try again.'));
} finally {
btn.disabled = false;
btn.innerHTML = 'Extract from Reel';
}
}
2026-04-28 19:06:37 -05:00
function showError(msg) {
document.getElementById('error-area').innerHTML = `< div class = "error-msg" > ${msg}< / div > `;
}
2026-04-29 11:50:44 -05:00
let currentRecipe = null;
let currentRecipeId = null;
const savedMenu = document.getElementById('saved-menu');
const savedList = document.getElementById('saved-list');
document.getElementById('open-menu-btn').addEventListener('click', async () => {
savedMenu.classList.add('open');
await fetchSavedRecipes();
});
document.getElementById('close-menu-btn').addEventListener('click', () => {
savedMenu.classList.remove('open');
});
async function fetchSavedRecipes() {
savedList.innerHTML = '< p style = "font-size: 13px; color: var(--color-text-secondary); text-align: center;" > Loading...< / p > ';
try {
const res = await fetch('/api/recipes', {
headers: {
'x-username': sessionUser,
'x-password': sessionPass
}
});
if (!res.ok) throw new Error('Failed to fetch');
const recipes = await res.json();
if (recipes.length === 0) {
savedList.innerHTML = '< p style = "font-size: 13px; color: var(--color-text-secondary); text-align: center;" > No saved recipes yet.< / p > ';
return;
}
// Reverse so newest is first
savedList.innerHTML = recipes.reverse().map(r => `
< div class = "saved-recipe-item" onclick = "loadSavedRecipe('${r.id}')" >
< h3 > ${r.title}< / h3 >
< p > ${r.description}< / p >
< / div >
`).join('');
} catch (e) {
savedList.innerHTML = '< p style = "font-size: 13px; color: var(--color-text-danger); text-align: center;" > Error loading recipes.< / p > ';
}
}
window.loadSavedRecipe = async function(id) {
savedMenu.classList.remove('open');
try {
const res = await fetch(`/api/recipes/${id}`);
if (!res.ok) throw new Error('Failed to fetch recipe');
const recipe = await res.json();
recipe.id = id;
renderRecipe(recipe);
document.getElementById('recipe-area').scrollIntoView({ behavior: 'smooth' });
} catch (err) {
console.error(err);
}
};
window.saveCurrentRecipe = async function() {
if (!currentRecipe) return;
const btn = document.getElementById('save-recipe-btn');
const originalText = btn.innerText;
btn.innerText = 'Saving...';
btn.disabled = true;
try {
const res = await fetch('/api/recipes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: sessionUser,
password: sessionPass,
recipe: currentRecipe
})
});
if (res.ok) {
const data = await res.json();
currentRecipeId = data.id;
currentRecipe.id = data.id;
btn.innerText = 'Saved!';
} else {
throw new Error('Save failed');
}
} catch (err) {
btn.innerText = "Error Saving";
setTimeout(() => {
btn.innerText = originalText;
btn.disabled = false;
}, 2000);
}
};
window.shareRecipeLink = function() {
if (!currentRecipeId) {
alert("Please save the recipe first to share it!");
return;
}
const url = `${window.location.origin}${window.location.pathname}#shared=${currentRecipeId}`;
navigator.clipboard.writeText(url).then(() => {
const btn = document.getElementById('share-recipe-btn');
const oldText = btn.innerText;
btn.innerText = 'Link Copied!';
setTimeout(() => btn.innerText = oldText, 2000);
});
};
function renderRecipe(r, isSharedView = false) {
currentRecipe = r;
currentRecipeId = r.id || null;
2026-04-28 19:06:37 -05:00
const ings = (r.ingredients || []).map(ing =>
`< li > < span class = "ing-amount" > ${ing.amount}${ing.unit ? ' ' + ing.unit : ''}< / span > < span class = "ing-name" > ${ing.name}< / span > < / li > `
).join('');
const steps = (r.steps || []).map((s, i) =>
`< li class = "step-item" >
< span class = "step-num" > ${i + 1}< / span >
< div >
< div class = "step-title" > ${s.title}< / div >
< div class = "step-instr" > ${s.instruction}< / div >
< / div >
< / li > `
).join('');
document.getElementById('recipe-area').innerHTML = `
< div class = "recipe-out" >
< div class = "recipe-title" > ${r.title}< / div >
< div class = "recipe-desc" > ${r.description}< / div >
< div class = "meta-row" >
< div class = "meta-chip" > < strong > ${r.servings}< / strong > servings< / div >
< div class = "meta-chip" > Prep < strong > ${r.prepTime}< / strong > < / div >
< div class = "meta-chip" > Cook < strong > ${r.cookTime}< / strong > < / div >
< / div >
< div class = "section-label" > Ingredients< / div >
< ul class = "ingredients-list" > ${ings}< / ul >
< div class = "section-label" > Method< / div >
< ol class = "steps-list" > ${steps}< / ol >
${r.notes ? `< div class = "notes-box" > ${r.notes}< / div > ` : ''}
2026-04-29 11:50:44 -05:00
< div class = "recipe-actions" >
${isSharedView ? `
< button class = "action-btn" style = "background: var(--color-text-primary); color: white;" onclick = "window.location.href=window.location.pathname" > Return to Login< / button >
` : `
< button class = "action-btn" id = "save-recipe-btn" onclick = "saveCurrentRecipe()" > Save to Menu< / button >
< button class = "action-btn" id = "share-recipe-btn" onclick = "shareRecipeLink()" > Copy Share Link< / button >
`}
< / div >
2026-04-28 19:06:37 -05:00
< / div > `;
}
2026-04-29 11:50:44 -05:00
// Check for shared recipe in URL hash on load
window.addEventListener('DOMContentLoaded', async () => {
if (window.location.hash.startsWith('#shared=')) {
try {
const recipeId = window.location.hash.slice(8);
document.getElementById('login-overlay').innerText = 'Loading Shared Recipe...';
document.getElementById('login-overlay').style.color = '#1a1a1a';
const res = await fetch(`/api/recipes/${recipeId}`);
if (!res.ok) throw new Error('Shared recipe not found');
const r = await res.json();
r.id = recipeId;
// Hide generation UI elements so ONLY the recipe is visible
document.getElementById('login-overlay').style.display = 'none';
document.querySelectorAll('#open-menu-btn, .header, .form-grid, #gen-btn, #error-area').forEach(el => {
if(el) el.style.display = 'none';
});
document.getElementById('app-content').style.display = 'block';
// Disable save button or generator elements since we are in a shared view state if you desire,
// but for now let's just render the recipe directly.
renderRecipe(r, true);
} catch (e) {
console.error("Failed to load shared recipe", e);
document.getElementById('login-overlay').innerHTML = `
< div class = "login-card" style = "text-align:center;" >
< h2 style = "font-family: 'Lora', serif; font-size: 20px;" > Recipe Not Found< / h2 >
< p style = "font-size: 14px; margin-top: 10px;" > The shared recipe link appears to be invalid or has been deleted.< / p >
< button class = "generate-btn" style = "margin-top: 16px;" onclick = "window.location.href=window.location.pathname" > Return to Login< / button >
< / div >
`;
}
}
});
2026-04-28 19:06:37 -05:00
< / script >
< / body >
< / html >