Pantry: barcode scanning with several barcodes per item, a Take one screen for everyone including children, and no longer tied to Mealie

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-27 11:47:14 -04:00
1 parent a7483164b8
commit ac55520a8c
15 files changed
+520 -62

No files matched your search

+5 -22
View File
@@ -4,7 +4,7 @@
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 === 'plan' ? '' : ' ghost'}" href="#/recipes/plan">${icon('cal')}Meal plan</a><a class="btn${on === 'shop' ? '' : ' ghost'}" href="#/recipes/shop">${icon('check')}Shopping list</a></div>`;
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 === 'plan' ? '' : ' ghost'}" href="#/recipes/plan">${icon('cal')}Meal plan</a><a class="btn${on === 'pantry' ? '' : ' ghost'}" href="#/recipes/pantry">${icon('list')}Pantry</a><a class="btn${on === 'shop' ? '' : ' ghost'}" href="#/recipes/shop">${icon('check')}Shopping list</a></div>`;
let rcQuery = '', rcPage = 1;
async function viewRecipes(args = []) {
@@ -12,12 +12,14 @@ async function viewRecipes(args = []) {
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] === 'pantry') return await viewPantry(args[1]);
if (ME && ME.kid && ME.kid.on && !args[0]) return await viewPantry('take'); // children see the pantry to take from
if (args[0] === 'shop') return await rcShop();
if (args[0] === 'plan') return await rcPlan(args[1]);
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>`;
v.innerHTML = `<div class="wrap"><h1>Recipes</h1>${rcTabs('')}<p class="err">${esc(e.message)}</p><div class="bar"><button class="btn ghost" id="rcretry">Try again</button></div></div>`; // (the Pantry does not need Mealie, so its tab is here even when recipes are not set up)
$('#rcretry').onclick = () => route();
}
}
@@ -122,7 +124,7 @@ async function rcPlan(start) {
<div class="bar"><button class="btn" id="rcpshop"${count ? '' : ' disabled'}>${icon('check')}Add this week's ingredients to the shopping list</button><button class="btn ghost" id="rcpantry">Pantry</button></div><div id="rcphave"></div></div>`;
document.querySelectorAll('[data-add]').forEach(b => b.onclick = () => rcPlanPick(null, '', '', b.dataset.add));
document.querySelectorAll('[data-rm]').forEach(b => b.onclick = async () => { try { await rcApi('/plan/remove', rcOpt('POST', { id: b.dataset.rm })); rcPlan(rcYmd(mon)); } catch (e) { toast(e.message); } });
$('#rcpantry').onclick = () => rcPantry(() => rcPlan(rcYmd(mon)));
$('#rcpantry').onclick = () => { location.hash = '#/recipes/pantry'; };
if (count) rcApi(`/plan/pantry?from=${from}&to=${to}`).then(r => { const box = $('#rcphave'); if (!box || !r.items.length) return; const names = [...new Set(r.items.map(i => i.have))]; box.innerHTML = `<p class="sub" style="white-space:normal">You may already have ${esc(names.slice(0, 8).join(', '))}${names.length > 8 ? '…' : ''} (${r.items.length} ingredient${r.items.length === 1 ? '' : 's'} this week). They still go on the shopping list; tick them off there.</p>`; }).catch(() => {});
$('#rcpshop').onclick = async e => { const b = e.currentTarget; b.disabled = true; try { const o = await rcApi('/plan/shop', rcOpt('POST', { from, to })); toast(`Ingredients for ${o.recipes} recipe${o.recipes === 1 ? '' : 's'} added`, { label: 'Open', run: () => { location.hash = '#/recipes/shop'; } }); } catch (er) { toast(er.message); } b.disabled = false; };
}
@@ -137,22 +139,3 @@ function rcPlanPick(slug, name, rid, date) {
let t = 0; const find = async () => { try { const r = await rcApi('/?page=1&q=' + encodeURIComponent(el.querySelector('#rcpq').value)); el.querySelector('#rcpr').innerHTML = r.items.slice(0, 8).map(x => `<a href="#" class="row" data-s="${esc(x.slug)}" data-n="${esc(x.name)}" data-i="${esc(x.id)}"><span class="grow">${esc(x.name)}</span></a>`).join('') || '<div class="row"><span class="grow"><small>No recipes found</small></span></div>'; el.querySelectorAll('#rcpr [data-s]').forEach(a => a.onclick = ev => { ev.preventDefault(); save(a.dataset.s, a.dataset.n, a.dataset.i); }); } catch (e) { el.querySelector('#rcpe').textContent = e.message; } };
el.querySelector('#rcpq').oninput = () => { clearTimeout(t); t = setTimeout(find, 250); }; find();
}
/** The pantry: what you keep in stock, how many are left, and the level that counts as running low (low ones go on the shopping list in a tap). */
async function rcPantry(onClose) {
let items = []; try { items = (await rcApi('/pantry')).items; } catch (e) { return toast(e.message); }
const el = openSheet('Pantry', 'What you keep in stock. The meal plan points out ingredients you have, and an item running low can start an automation.', '<div id="pyl"></div>');
const draw = () => {
el.querySelector('#pyl').innerHTML = `<form id="pyf" class="bar" style="flex-wrap:nowrap;gap:6px"><input type="text" id="pyn" placeholder="Add an item" autocomplete="off" required style="flex:2;min-width:0"><input type="number" id="pyq" placeholder="How many" min="0" step="any" inputmode="decimal" style="flex:1;min-width:0" aria-label="How many are left"><input type="number" id="pyw" placeholder="Low at" min="0" step="any" inputmode="decimal" style="flex:1;min-width:0" aria-label="Counts as running low at"><button class="btn">Add</button></form>
<div class="list">${items.map(x => `<div class="row"><span class="grow" style="white-space:normal"><b>${esc(x.name)}</b>${x.isLow ? ' <small style="color:var(--danger)">running low</small>' : ''}<small>${x.qty == null ? 'not counted' : x.qty + ' left'}${x.low != null ? ' · low at ' + x.low : ''}</small></span>
${x.isLow ? `<button class="btn ghost" data-shop="${esc(x.id)}" style="min-height:36px">To shopping</button>` : ''}${x.qty != null ? `<button class="ibtn" data-d="${esc(x.id)}:-1" aria-label="One less ${esc(x.name)}">−</button><button class="ibtn" data-d="${esc(x.id)}:1" aria-label="One more ${esc(x.name)}">+</button>` : ''}<button class="ibtn" data-x="${esc(x.id)}" aria-label="Remove ${esc(x.name)}">${icon('trash')}</button></div>`).join('') || '<div class="row"><span class="grow"><small>Nothing yet. Add what you keep in the kitchen.</small></span></div>'}</div>
<div class="bar"><button class="btn" id="pydone" style="flex:1;justify-content:center">Done</button></div>`;
const go = async (p, o) => { try { items = (await rcApi(p, rcOpt('POST', o))).items; draw(); } catch (e) { toast(e.message); } };
el.querySelector('#pyf').onsubmit = e => { e.preventDefault(); go('/pantry/item', { name: el.querySelector('#pyn').value, qty: el.querySelector('#pyq').value, low: el.querySelector('#pyw').value }); };
el.querySelectorAll('[data-d]').forEach(b => b.onclick = () => { const [id, d] = b.dataset.d.split(':'); go('/pantry/adjust', { id, delta: +d }); });
el.querySelectorAll('[data-x]').forEach(b => b.onclick = () => go('/pantry/delete', { id: b.dataset.x }));
el.querySelectorAll('[data-shop]').forEach(b => b.onclick = async () => { const it = items.find(x => x.id === b.dataset.shop); try { await rcApi('/shopping/add', rcOpt('POST', { text: it.name })); toast(it.name + ' added to the shopping list'); } catch (e) { toast(e.message); } });
el.querySelector('#pydone').onclick = () => { closeSheet(); if (onClose) onClose(); };
};
draw();
}