video understanding
This commit is contained in:
248
index.html
248
index.html
@@ -85,6 +85,22 @@
|
||||
.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-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; }
|
||||
|
||||
.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; }
|
||||
|
||||
@@ -132,11 +148,37 @@
|
||||
</div>
|
||||
|
||||
<div class="app" id="app-content">
|
||||
<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>
|
||||
|
||||
<div class="header">
|
||||
<h1>Recipe Generator</h1>
|
||||
<p>Tell the AI what you have and what you want — it handles the rest.</p>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<div class="form-grid">
|
||||
<div class="form-field full">
|
||||
<label>Ingredients on hand</label>
|
||||
@@ -250,8 +292,10 @@
|
||||
const ingWrapper = document.getElementById('ing-wrapper');
|
||||
const ingInput = document.getElementById('ing-input');
|
||||
const genBtn = document.getElementById('gen-btn');
|
||||
const ingestBtn = document.getElementById('ingest-btn');
|
||||
|
||||
genBtn.addEventListener('click', generate);
|
||||
ingestBtn.addEventListener('click', ingestReel);
|
||||
|
||||
ingWrapper.addEventListener('click', () => ingInput.focus());
|
||||
ingInput.addEventListener('keydown', e => {
|
||||
@@ -366,11 +410,167 @@ Rules: use provided ingredients as the base, add pantry staples as needed, 5–1
|
||||
btn.innerHTML = 'Generate my recipe';
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
document.getElementById('error-area').innerHTML = `<div class="error-msg">${msg}</div>`;
|
||||
}
|
||||
|
||||
function renderRecipe(r) {
|
||||
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;
|
||||
|
||||
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('');
|
||||
@@ -399,8 +599,54 @@ Rules: use provided ingredients as the base, add pantry staples as needed, 5–1
|
||||
<div class="section-label">Method</div>
|
||||
<ol class="steps-list">${steps}</ol>
|
||||
${r.notes ? `<div class="notes-box">${r.notes}</div>` : ''}
|
||||
<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>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// 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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user