build-container / build (push) Successful in 7m21s
- Store: Open Library search, books fetched from the Internet Archive (free editions only) - Podcast: Podcast Index discovery by name and topic (optional key); search what is said across transcripts and play from that moment - Recipes: browse, import from a link, add ingredients to Mealie's shopping list, mirrored two ways with a Shopping list in Tasks - Contacts: per-person address book, vCard import/export, recipient suggestions in Mail, birthdays in the Calendar Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
91 lines
8.9 KiB
JavaScript
91 lines
8.9 KiB
JavaScript
'use strict';
|
|
// ---- Recipes: your Mealie recipes and shopping list, through the hub (the server holds the Mealie token). A recipe's ingredients go on one shopping list, which is
|
|
// also kept in Tasks (a "Shopping" list there), so it can be ticked off from either place.
|
|
const rcApi = (p, opts) => jfetch('/api/recipes' + p, opts);
|
|
const rcOpt = (m, o) => ({ method: m, headers: { 'content-type': 'application/json' }, body: o ? JSON.stringify(o) : undefined });
|
|
const rcTime = m => { m = +m || 0; if (!m) return ''; const h = Math.floor(m / 60); return h ? h + ' h' + (m % 60 ? ' ' + (m % 60) + ' min' : '') : m + ' min'; };
|
|
const rcTabs = on => `<div class="bar" style="margin:0 0 12px;justify-content:flex-start"><a class="btn${on === 'all' ? '' : ' ghost'}" href="#/recipes">${icon('chef')}Recipes</a><a class="btn${on === 'shop' ? '' : ' ghost'}" href="#/recipes/shop">${icon('check')}Shopping list</a></div>`;
|
|
let rcQuery = '', rcPage = 1;
|
|
|
|
async function viewRecipes(args = []) {
|
|
const v = $('#view');
|
|
v.innerHTML = '<div class="wrap"><h1>Recipes</h1><p class="empty">Loading…</p></div>';
|
|
try {
|
|
if (args[0] === 'r' && args[1]) return await rcOne(decodeURIComponent(args[1]));
|
|
if (args[0] === 'shop') return await rcShop();
|
|
return await rcHome();
|
|
} catch (e) {
|
|
if (!location.hash.startsWith('#/recipes')) return;
|
|
v.innerHTML = `<div class="wrap"><h1>Recipes</h1><p class="err">${esc(e.message)}</p><div class="bar"><button class="btn ghost" id="rcretry">Try again</button></div></div>`;
|
|
$('#rcretry').onclick = () => route();
|
|
}
|
|
}
|
|
|
|
async function rcHome() {
|
|
const draw = (items, more, page) => {
|
|
const g = $('#rcgrid'); if (!g) return;
|
|
const html = items.map(r => `<a class="vcard" href="#/recipes/r/${encodeURIComponent(r.slug)}"><span class="vimg" style="aspect-ratio:4/3">${r.image ? `<img loading="lazy" alt="" src="/api/recipes/image/${encodeURIComponent(r.id)}" onerror="this.remove()">` : ''}</span>
|
|
<b>${esc(r.name)}</b><small>${esc([rcTime(r.minutes), r.rating ? '★'.repeat(Math.round(r.rating)) : ''].filter(Boolean).join(' · '))}</small></a>`).join('');
|
|
if (page > 1) g.insertAdjacentHTML('beforeend', html); else g.innerHTML = html || '<p class="empty">No recipes found.</p>';
|
|
const mb = $('#rcmore'); if (mb) mb.hidden = !more;
|
|
};
|
|
const first = await rcApi('/?page=1&q=' + encodeURIComponent(rcQuery)); if (!location.hash.startsWith('#/recipes')) return;
|
|
$('#view').innerHTML = `<div class="wrap"><div class="ptop"><h1>Recipes</h1><span class="pspace grow"></span><button class="btn" id="rcimp">${icon('plus')}Import from a link</button></div>${rcTabs('all')}
|
|
<input type="search" id="rcq" placeholder="Search your recipes" value="${esc(rcQuery)}" autocomplete="off" aria-label="Search recipes" style="width:100%;margin-bottom:12px">
|
|
<div class="vgrid" id="rcgrid"></div><div class="bar"><button class="btn ghost" id="rcmore" hidden>Show more</button></div></div>`;
|
|
rcPage = 1; draw(first.items, first.more, 1);
|
|
let timer, seq = 0;
|
|
const load = async page => { const my = ++seq; try { const j = await rcApi('/?page=' + page + '&q=' + encodeURIComponent(rcQuery)); if (my === seq) { rcPage = page; draw(j.items, j.more, page); } } catch (e) { toast(e.message); } };
|
|
$('#rcq').oninput = e => { clearTimeout(timer); timer = setTimeout(() => { rcQuery = e.target.value.trim(); load(1); }, 350); };
|
|
$('#rcmore').onclick = () => load(rcPage + 1);
|
|
$('#rcimp').onclick = () => {
|
|
const el = openSheet('Import a recipe', 'Mealie reads the recipe from the web page', `<input type="url" id="rcurl" placeholder="https://…" autocomplete="off" autocapitalize="off" spellcheck="false"><div class="err" id="rce"></div>
|
|
<div class="bar"><button class="btn ghost" id="rcx" style="flex:1;justify-content:center">Cancel</button><button class="btn" id="rcgo" style="flex:1;justify-content:center">Import</button></div>`);
|
|
el.querySelector('#rcx').onclick = closeSheet; const go = el.querySelector('#rcgo'), err = el.querySelector('#rce'), inp = el.querySelector('#rcurl'); inp.focus();
|
|
const run = async () => {
|
|
err.textContent = ''; go.disabled = true; go.textContent = 'Importing…';
|
|
try { const r = await rcApi('/import', rcOpt('POST', { url: inp.value.trim() })); closeSheet(); toast('Imported'); location.hash = '#/recipes/r/' + encodeURIComponent(r.slug); }
|
|
catch (e) { err.textContent = e.message; go.disabled = false; go.textContent = 'Import'; }
|
|
};
|
|
go.onclick = run; inp.onkeydown = e => { if (e.key === 'Enter') run(); };
|
|
};
|
|
}
|
|
|
|
async function rcOne(slug) {
|
|
const r = await rcApi('/r/' + encodeURIComponent(slug)); if (!location.hash.startsWith('#/recipes')) return;
|
|
const facts = [['Prep', rcTime(r.prep)], ['Cook', rcTime(r.cook)], ['Total', rcTime(r.total)], ['Serves', r.servings]].filter(f => f[1]);
|
|
$('#view').innerHTML = `<div class="wrap"><div class="ptop"><a class="btn ghost" href="#/recipes">${icon('back')}Recipes</a></div>
|
|
<div class="rchero">${r.image ? `<span class="vimg" style="width:min(240px,40%);flex:none;aspect-ratio:4/3"><img alt="" src="/api/recipes/image/${encodeURIComponent(r.id)}?big=1" onerror="this.remove()"></span>` : ''}
|
|
<div style="min-width:0"><h1 style="margin:0">${esc(r.name)}</h1>${r.description ? `<p class="sub" style="margin:6px 0 0;white-space:normal">${esc(r.description)}</p>` : ''}
|
|
${facts.length ? `<p class="sub" style="margin:8px 0 0">${facts.map(f => `<b>${esc(f[0])}</b> ${esc(f[1])}`).join(' · ')}</p>` : ''}
|
|
${r.orgUrl ? `<p class="sub" style="margin:4px 0 0"><a href="${esc(r.orgUrl)}" target="_blank" rel="noopener noreferrer" style="color:var(--accent)">Original page</a></p>` : ''}</div></div>
|
|
<div class="bar" style="justify-content:flex-start"><button class="btn" id="rcadd">${icon('plus')}Add ingredients to shopping list</button><a class="btn ghost" href="#/recipes/shop">Shopping list</a></div>
|
|
<h2>Ingredients</h2><div class="list">${r.ingredients.map(i => i.title && !i.text ? `<p class="sub" style="margin:10px 0 2px"><b>${esc(i.title)}</b></p>` : `<div class="row"><span class="grow">${esc(i.text)}</span></div>`).join('') || '<p class="empty">No ingredients.</p>'}</div>
|
|
<h2>Steps</h2><ol class="rcsteps">${r.steps.map(s => `<li>${s.title ? `<b>${esc(s.title)}</b> ` : ''}${esc(s.text).replace(/\n/g, '<br>')}</li>`).join('') || '<li class="empty">No steps.</li>'}</ol>
|
|
${r.notes.length ? `<h2>Notes</h2>${r.notes.map(n => `<p>${n.title ? `<b>${esc(n.title)}</b><br>` : ''}${esc(n.text).replace(/\n/g, '<br>')}</p>`).join('')}` : ''}</div>`;
|
|
$('#rcadd').onclick = async e => {
|
|
const b = e.currentTarget; b.disabled = true;
|
|
try { const o = await rcApi('/shopping/add-recipe', rcOpt('POST', { slug })); toast('Added to your shopping list'); b.disabled = false; return o; }
|
|
catch (err) { toast(err.message); b.disabled = false; }
|
|
};
|
|
}
|
|
|
|
async function rcShop() {
|
|
const draw = async l => {
|
|
if (!location.hash.startsWith('#/recipes/shop')) return;
|
|
const open = l.items.filter(i => !i.checked), done = l.items.filter(i => i.checked), row = i => `<label class="row rcitem" style="cursor:pointer"><input type="checkbox" data-id="${esc(i.id)}"${i.checked ? ' checked' : ''}><span class="grow"${i.checked ? ' style="text-decoration:line-through;color:var(--muted)"' : ''}>${esc(i.text)}</span></label>`;
|
|
$('#view').innerHTML = `<div class="wrap"><div class="ptop"><h1>Shopping list</h1></div>${rcTabs('shop')}
|
|
<form id="rcnew" class="bar" style="margin:0 0 12px;gap:8px"><input type="text" id="rctext" placeholder="Add an item" autocomplete="off" style="flex:1"><button class="btn">Add</button></form>
|
|
<div class="list">${open.map(row).join('') || '<p class="empty">Nothing to buy. Open a recipe and add its ingredients.</p>'}</div>
|
|
${done.length ? `<h2>Got it</h2><div class="list">${done.map(row).join('')}</div><div class="bar" style="justify-content:flex-start"><button class="btn ghost" id="rcclear">Remove ticked items</button></div>` : ''}
|
|
<p class="sub" style="margin-top:16px">Also in Tasks as the “Shopping” list: ticking an item there ticks it here.</p></div>`;
|
|
document.querySelectorAll('.rcitem input').forEach(c => c.onchange = async () => {
|
|
c.disabled = true; try { await rcApi('/shopping/check', rcOpt('POST', { id: c.dataset.id, checked: c.checked })); draw(await rcApi('/shopping')); } catch (e) { toast(e.message); c.checked = !c.checked; c.disabled = false; }
|
|
});
|
|
$('#rcnew').onsubmit = async e => { e.preventDefault(); const t = $('#rctext').value.trim(); if (!t) return; try { await rcApi('/shopping/add', rcOpt('POST', { text: t })); draw(await rcApi('/shopping')); } catch (err) { toast(err.message); } };
|
|
if ($('#rcclear')) $('#rcclear').onclick = async () => { try { await rcApi('/shopping/clear', rcOpt('POST', {})); draw(await rcApi('/shopping')); } catch (e) { toast(e.message); } };
|
|
};
|
|
try { await rcApi('/shopping/sync', rcOpt('POST', {})); } catch {} // (opening the list also brings Tasks up to date)
|
|
await draw(await rcApi('/shopping'));
|
|
}
|