357
index.html
Normal file
357
index.html
Normal file
@@ -0,0 +1,357 @@
|
||||
<!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;
|
||||
}
|
||||
|
||||
.app { width: 100%; max-width: 680px; padding: 2rem 0; }
|
||||
|
||||
.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); } }
|
||||
|
||||
.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>
|
||||
<div class="app">
|
||||
<div class="header">
|
||||
<h1>Recipe Generator</h1>
|
||||
<p>Tell the AI what you have and what you want — it handles the rest.</p>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<button class="generate-btn" id="gen-btn">Generate my recipe</button>
|
||||
<div id="error-area"></div>
|
||||
<div id="recipe-area"></div>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
const ingredients = [];
|
||||
const ingWrapper = document.getElementById('ing-wrapper');
|
||||
const ingInput = document.getElementById('ing-input');
|
||||
const genBtn = document.getElementById('gen-btn');
|
||||
|
||||
genBtn.addEventListener('click', generate);
|
||||
|
||||
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."
|
||||
|
||||
Respond with ONLY a valid JSON object. Use this exact structure:
|
||||
{"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 {
|
||||
// Vite will inject this automatically from the .env file
|
||||
const API_KEY = import.meta.env.VITE_GEMINI_API_KEY;
|
||||
|
||||
const resp = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${API_KEY}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
systemInstruction: {
|
||||
parts: [{ text: systemPrompt }]
|
||||
},
|
||||
contents: [{
|
||||
parts: [{ text: userPrompt }]
|
||||
}],
|
||||
generationConfig: {
|
||||
responseMimeType: "application/json"
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const data = await resp.json();
|
||||
|
||||
if (!resp.ok) {
|
||||
throw new Error(data.error?.message || `API error ${resp.status}`);
|
||||
}
|
||||
|
||||
const raw = data.candidates[0].content.parts[0].text;
|
||||
const recipe = JSON.parse(raw);
|
||||
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';
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
document.getElementById('error-area').innerHTML = `<div class="error-msg">${msg}</div>`;
|
||||
}
|
||||
|
||||
function renderRecipe(r) {
|
||||
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>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user