build-container / build (push) Successful in 8m12s
- Ticks (sent, delivered, read), typing, presence, disappearing messages, @mentions, pins, forwarding - Voice messages with a waveform, link previews, polls, emoji picker - Message to task, shopping item or calendar event; Share to Chat from recipes and songs; quiet hours; family announcement on Home - Roadmap: chat items ticked off, and Browse and search by genre added Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
105 lines
11 KiB
JavaScript
105 lines
11 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>${allowed('chat') ? '<button class="btn ghost" id="rcshare">Share to Chat</button>' : ''}</div>
|
|
<div class="rccols"><section><div class="rchead"><h2>Ingredients</h2><button class="btn ghost" id="rcreset" style="min-height:32px">Uncheck all</button></div>
|
|
<div class="list">${r.ingredients.map((i, n) => i.title && !i.text ? `<p class="sub" style="margin:10px 0 2px"><b>${esc(i.title)}</b></p>` : `<label class="row rcing" style="cursor:pointer"><input type="checkbox" data-n="${n}"><span class="grow">${esc(i.text)}</span></label>`).join('') || '<p class="empty">No ingredients.</p>'}</div></section>
|
|
<section><div class="rchead"><h2>Steps</h2></div><ol class="rcsteps">${r.steps.map((s, n) => `<li data-s="${n}">${s.title ? `<b>${esc(s.title)}</b> ` : ''}${esc(s.text).replace(/\n/g, '<br>')}</li>`).join('') || '<li class="empty">No steps.</li>'}</ol></section></div>
|
|
${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>`;
|
|
// What you have used and done is remembered on this device for each recipe, so you can leave and come back while cooking.
|
|
const sk = 'rc_done:' + r.id; let done = store.get(sk, { i: [], s: [] }); if (!done || !Array.isArray(done.i)) done = { i: [], s: [] };
|
|
const paint = () => {
|
|
document.querySelectorAll('.rcing input').forEach(c => { const on = done.i.includes(+c.dataset.n); c.checked = on; c.nextElementSibling.style.cssText = on ? 'text-decoration:line-through;color:var(--muted)' : ''; });
|
|
document.querySelectorAll('.rcsteps li[data-s]').forEach(li => li.classList.toggle('done', done.s.includes(+li.dataset.s)));
|
|
$('#rcreset').hidden = !done.i.length && !done.s.length;
|
|
};
|
|
const flip = (list, n) => { const k = done[list].indexOf(n); if (k < 0) done[list].push(n); else done[list].splice(k, 1); if (done.i.length || done.s.length) store.set(sk, done); else store.del(sk); paint(); };
|
|
document.querySelectorAll('.rcing input').forEach(c => c.onchange = () => flip('i', +c.dataset.n));
|
|
document.querySelectorAll('.rcsteps li[data-s]').forEach(li => li.onclick = () => flip('s', +li.dataset.s));
|
|
$('#rcreset').onclick = () => { done = { i: [], s: [] }; store.del(sk); paint(); };
|
|
paint();
|
|
if ($('#rcshare')) $('#rcshare').onclick = () => shareToChat(r.name + ' ' + location.origin + '/#/recipes/r/' + encodeURIComponent(slug));
|
|
$('#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'));
|
|
}
|