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:
1 parent
a7483164b8
commit
ac55520a8c
15 files changed
+520
-62
No files matched your search
@@ -81,6 +81,7 @@
|
||||
<script defer src="/radio-view.js"></script>
|
||||
<script defer src="/store-view.js"></script>
|
||||
<script defer src="/recipes-view.js"></script>
|
||||
<script defer src="/pantry-view.js"></script>
|
||||
<script defer src="/contacts-view.js"></script>
|
||||
<script defer src="/bible-view.js"></script>
|
||||
<script defer src="/devotion-view.js"></script>
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
'use strict';
|
||||
// ---- Pantry (Recipes > Pantry): what you keep in the kitchen, how many are left, and the level that counts as running low. A phone can scan a barcode to add one or use one;
|
||||
// the first time a barcode is seen you say which item it is (so Green Giant corn and the store brand are both "Corn"). Low items go on the shopping list in a tap.
|
||||
const paApi = (p, o) => jfetch('/api/pantry' + p, o), paOpt = o => ({ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(o) });
|
||||
const paAgo = t => { const m = Math.round((Date.now() - t) / 60000); return m < 1 ? 'just now' : m < 60 ? m + ' min ago' : m < 1440 ? Math.round(m / 60) + ' h ago' : new Date(t).toLocaleDateString(undefined, { weekday: 'short' }); };
|
||||
const paRecent = r => r.length ? `<h2>Recently</h2><div class="list">${r.map(x => `<div class="row"><span class="grow"><b>${esc(x.who)}</b> ${x.delta < 0 ? 'took' : 'put back'} ${esc(x.item)}<small>${esc(paAgo(x.t))}</small></span></div>`).join('')}</div>` : '';
|
||||
/** The simple way to use things: a big tile for each item, tap to take one (with Undo). Children see only this. */
|
||||
async function viewPantryTake() {
|
||||
let st; const v = $('#view'), kid = !!(ME && ME.kid && ME.kid.on);
|
||||
try { st = await paApi('/'); } catch (e) { v.innerHTML = `<div class="wrap"><h1>Pantry</h1><p class="err">${esc(e.message)}</p></div>`; return; }
|
||||
if (!location.hash.startsWith('#/recipes')) return;
|
||||
const draw = () => {
|
||||
v.innerHTML = `<div class="wrap"><div class="ptop"><h1>${kid ? 'Pantry' : 'Take one'}</h1><span class="grow"></span>${kid ? '' : `<a class="btn ghost" href="#/recipes/pantry">Manage</a>`}</div>
|
||||
<p class="sub" style="margin-top:0">Tap what you are taking. It counts one off so the family knows when to buy more.</p>
|
||||
<div class="paTiles">${st.items.map(x => `<button type="button" class="paTile${x.qty === 0 ? ' out' : ''}${x.isLow && x.qty ? ' low' : ''}" data-u="${esc(x.id)}"${x.qty === 0 ? ' disabled' : ''} aria-label="Take one ${esc(x.name)}${x.qty != null ? ', ' + x.qty + ' left' : ''}"><b>${esc(x.name)}</b><small>${x.qty == null ? '' : x.qty === 0 ? 'none left' : x.qty + ' left'}</small></button>`).join('') || '<p class="empty">Nothing in the pantry yet.</p>'}</div>
|
||||
<div class="bar" style="justify-content:flex-start"><button class="btn ghost" id="pasuse">${icon('plus')}Scan a barcode instead</button></div>${paRecent(st.recent || [])}</div>`;
|
||||
v.querySelectorAll('[data-u]').forEach(b => b.onclick = async () => { b.disabled = true; try { const r = await paApi('/use', paOpt({ id: b.dataset.u })); st.items = r.items; st.recent = r.recent; draw(); const it = r.item;
|
||||
toast(`Took one ${it.name}${it.qty != null ? ': ' + it.qty + ' left' : ''}`, { label: 'Undo', run: async () => { try { const u = await paApi('/use', paOpt({ id: it.id, undo: true })); st.items = u.items; st.recent = u.recent; if ($('.paTiles')) draw(); toast('Put back'); } catch (e) { toast(e.message); } } }); } catch (e) { b.disabled = false; toast(e.message); } });
|
||||
$('#pasuse').onclick = () => pantryScan('use', list => { st.items = list; draw(); }, () => st.items);
|
||||
};
|
||||
draw();
|
||||
}
|
||||
async function viewPantry(sub) {
|
||||
if (sub === 'take' || (ME && ME.kid && ME.kid.on)) return viewPantryTake();
|
||||
let items = [], recent = []; const v = $('#view');
|
||||
try { const r = await paApi('/'); items = r.items; recent = r.recent || []; } catch (e) { v.innerHTML = `<div class="wrap"><h1>Pantry</h1><p class="err">${esc(e.message)}</p></div>`; return; }
|
||||
if (!location.hash.startsWith('#/recipes/pantry')) return;
|
||||
const draw = () => {
|
||||
v.innerHTML = `<div class="wrap"><div class="ptop"><h1>Recipes</h1></div>${typeof rcTabs === 'function' ? rcTabs('pantry') : ''}
|
||||
<div class="bar" style="justify-content:flex-start"><a class="btn" href="#/recipes/pantry/take">Take one</a><button class="btn ghost" id="pasadd">${icon('plus')}Scan to add</button><button class="btn ghost" id="pasuse">Scan to use one</button></div>
|
||||
<form id="paf" class="bar" style="flex-wrap:nowrap;gap:6px"><input type="text" id="pan" placeholder="Add an item" autocomplete="off" required style="flex:2;min-width:0"><input type="number" id="paq" 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="paw" 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 : ''}${x.codes.length ? ' · ' + x.codes.length + ' barcode' + (x.codes.length === 1 ? '' : 's') : ''}</small></span>
|
||||
${x.isLow && typeof rcApi === 'function' ? `<button class="btn ghost" data-shop="${esc(x.id)}" style="min-height:36px">To shopping</button>` : ''}${x.codes.length ? `<button class="ibtn" data-bc="${esc(x.id)}" aria-label="Barcodes for ${esc(x.name)}" title="Barcodes">${icon('list')}</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, or scan a barcode.</small></span></div>'}</div>
|
||||
<p class="sub" style="margin-top:12px">Counts are kept by hand (and by scanning); they do not go down when you cook. The meal plan points out ingredients you already have.</p>${paRecent(recent)}</div>`;
|
||||
const go = async (p, o) => { try { items = (await paApi(p, paOpt(o))).items; draw(); } catch (e) { toast(e.message); } };
|
||||
$('#paf').onsubmit = e => { e.preventDefault(); go('/item', { name: $('#pan').value, qty: $('#paq').value, low: $('#paw').value }); };
|
||||
v.querySelectorAll('[data-d]').forEach(b => b.onclick = () => { const [id, d] = b.dataset.d.split(':'); go('/adjust', { id, delta: +d }); });
|
||||
v.querySelectorAll('[data-x]').forEach(b => b.onclick = () => go('/delete', { id: b.dataset.x }));
|
||||
v.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); } });
|
||||
v.querySelectorAll('[data-bc]').forEach(b => b.onclick = () => { const it = items.find(x => x.id === b.dataset.bc), el = openSheet('Barcodes for ' + it.name, 'Scanning any of these counts as ' + it.name + '.', `<div class="list">${it.codes.map(c => `<div class="row"><span class="grow"><b>${esc(c)}</b></span><button class="ibtn" data-u="${esc(c)}" aria-label="Unlink ${esc(c)}">${icon('trash')}</button></div>`).join('')}</div>`); el.querySelectorAll('[data-u]').forEach(u => u.onclick = async () => { closeSheet(); go('/unlink', { id: it.id, code: u.dataset.u }); }); });
|
||||
$('#pasadd').onclick = () => pantryScan('add', list => { items = list; draw(); }, () => items); $('#pasuse').onclick = () => pantryScan('use', list => { items = list; draw(); }, () => items);
|
||||
};
|
||||
draw();
|
||||
}
|
||||
|
||||
/** The scanner: the camera looks for barcodes and each one found adds one (or uses one). Keeps going so a bag of shopping can be done in a row. */
|
||||
let paZx = null;
|
||||
const paLoadZx = () => paZx || (paZx = new Promise((ok, no) => { if (window.ZXing) return ok(window.ZXing); const s = document.createElement('script'); s.src = '/vendor/zxing/zxing.min.js'; s.onload = () => ok(window.ZXing); s.onerror = () => { paZx = null; no(new Error('Could not load the scanner')); }; document.head.appendChild(s); }));
|
||||
function pantryScan(mode, onChange, getItems) {
|
||||
const el = openSheet(mode === 'use' ? 'Scan to use one' : 'Scan to add', mode === 'use' ? 'Each barcode takes one off the count.' : 'Each barcode adds one to the count.', `<video id="pscv" playsinline muted autoplay style="width:100%;border-radius:12px;background:#000;aspect-ratio:4/3;object-fit:cover"></video>
|
||||
<p class="sub" id="pscs" role="status" style="margin:8px 0">Starting the camera…</p><div id="pscq"></div>
|
||||
<form id="pscm" class="bar" style="flex-wrap:nowrap;gap:6px"><input type="text" id="pscn" inputmode="numeric" placeholder="Or type the barcode number" autocomplete="off" aria-label="Barcode number" style="flex:1;min-width:0"><button class="btn ghost">Go</button></form>`);
|
||||
const video = el.querySelector('#pscv'), status = t => { const s = el.querySelector('#pscs'); if (s) s.textContent = t; };
|
||||
let stream = null, stopped = false, busy = false, last = '', lastAt = 0, reader = null, timer = 0;
|
||||
const stop = () => { stopped = true; clearInterval(timer); try { reader && reader.reset(); } catch { /* not started */ } if (stream) stream.getTracks().forEach(t => t.stop()); };
|
||||
const alive = () => !stopped && el.isConnected;
|
||||
const resume = () => { busy = false; if (alive()) { el.querySelector('#pscq').innerHTML = ''; status('Point the camera at a barcode'); } };
|
||||
const finish = (r, said) => { onChange(r.items); status(said); toast(said); try { navigator.vibrate && navigator.vibrate(60); } catch { /* no vibration */ } setTimeout(resume, 1300); };
|
||||
const found = async code => {
|
||||
if (busy || !alive() || (code === last && Date.now() - lastAt < 4000)) return; busy = true; last = code; lastAt = Date.now(); status('Looking up ' + code + '…');
|
||||
try {
|
||||
const r = await paApi('/lookup?code=' + code); if (!alive()) return;
|
||||
if (r.linked) { const o = await paApi('/scan', paOpt({ code, mode })); return finish(o, `${o.item.name}: ${o.item.qty} left${o.item.isLow ? ' (running low)' : ''}`); }
|
||||
const list = (getItems() || []), matches = (r.matches || []).map(m => m.id), sorted = [...list.filter(x => matches.includes(x.id)), ...list.filter(x => !matches.includes(x.id))];
|
||||
el.querySelector('#pscq').innerHTML = `<form id="pscf"><p style="margin:0 0 6px"><b>${esc(r.product ? r.product.name : 'Barcode ' + code)}</b>${r.product && r.product.brand ? ' <small>· ' + esc(r.product.brand) + '</small>' : ''}</p>
|
||||
${!r.product ? `<p class="sub" style="margin:0 0 6px">${r.lookedUp ? 'The product database does not know this one.' : 'Could not reach the product database.'} Say what it is once and it is remembered.</p>` : '<p class="sub" style="margin:0 0 6px">First time this barcode is seen. Which item is it?</p>'}
|
||||
<label for="pscw">Which item</label><select id="pscw">${sorted.map(x => `<option value="${esc(x.id)}">${esc(x.name)}</option>`).join('')}<option value=""${matches.length ? '' : ' selected'}>A new item…</option></select>
|
||||
<div id="pscnw"${matches.length ? ' hidden' : ''}><label for="pscnn">Name of the new item</label><input type="text" id="pscnn" value="${esc(r.product ? r.product.short : '')}" autocomplete="off" maxlength="60"></div>
|
||||
<div class="bar"><button type="button" class="btn ghost" id="pscx" style="flex:1;justify-content:center">Skip</button><button class="btn" style="flex:1;justify-content:center">${mode === 'use' ? 'Use one' : 'Add one'}</button></div><div class="err" id="psce"></div></form>`;
|
||||
const sel = el.querySelector('#pscw'), nw = el.querySelector('#pscnw'); sel.onchange = () => { nw.hidden = !!sel.value; }; if (sel.value === '' ) nw.hidden = false; status('Which item is this?');
|
||||
el.querySelector('#pscx').onclick = resume;
|
||||
el.querySelector('#pscf').onsubmit = async e => { e.preventDefault(); try { const o = await paApi('/scan', paOpt({ code, mode, itemId: sel.value || undefined, name: sel.value ? undefined : el.querySelector('#pscnn').value })); finish(o, `${o.item.name}: ${o.item.qty} left${o.made ? ' (new item)' : ''}`); } catch (er) { el.querySelector('#psce').textContent = er.message; } };
|
||||
} catch (e) { status(e.message); toast(e.message); setTimeout(resume, 1500); }
|
||||
};
|
||||
el.querySelector('#pscm').onsubmit = e => { e.preventDefault(); const c = el.querySelector('#pscn').value.replace(/\D/g, ''); el.querySelector('#pscn').value = ''; if (/^\d{8,14}$/.test(c)) { last = ''; found(c); } else status('A barcode is 8 to 14 digits.'); };
|
||||
(async () => {
|
||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) return status('This browser cannot use the camera here (it needs a secure page). Type the number instead.');
|
||||
try { stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: { ideal: 'environment' } }, audio: false }); } catch (e) { return status(e && e.name === 'NotAllowedError' ? 'The camera is blocked. Allow it for this site, or type the number instead.' : 'No camera could be started. Type the number instead.'); }
|
||||
if (!alive()) return stop(); video.srcObject = stream; try { await video.play(); } catch { /* autoplay is allowed on a tap */ } status('Point the camera at a barcode');
|
||||
if ('BarcodeDetector' in window) { // Chrome and Android have a built-in reader
|
||||
let det; try { det = new BarcodeDetector({ formats: ['ean_13', 'ean_8', 'upc_a', 'upc_e'] }); } catch { det = null; }
|
||||
if (det) { timer = setInterval(async () => { if (!alive()) return stop(); if (busy || video.readyState < 2) return; try { const r = await det.detect(video); if (r[0] && r[0].rawValue) found(r[0].rawValue); } catch { /* a frame that cannot be read */ } }, 300); return; }
|
||||
}
|
||||
try { // everything else (iPhone Safari): a reader written in JavaScript, loaded only now
|
||||
const Z = await paLoadZx(), hints = new Map(); hints.set(Z.DecodeHintType.POSSIBLE_FORMATS, [Z.BarcodeFormat.EAN_13, Z.BarcodeFormat.EAN_8, Z.BarcodeFormat.UPC_A, Z.BarcodeFormat.UPC_E]); reader = new Z.BrowserMultiFormatReader(hints);
|
||||
timer = setInterval(() => { if (!alive()) stop(); }, 500);
|
||||
reader.decodeFromVideoElement(video, (res) => { if (res && alive() && !busy) found(res.getText()); });
|
||||
} catch (e) { status(e.message + ' Type the number instead.'); }
|
||||
})();
|
||||
new MutationObserver((_, ob) => { if (!el.isConnected) { stop(); ob.disconnect(); } }).observe(document.body, { childList: true });
|
||||
}
|
||||
+5
-22
@@ -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();
|
||||
}
|
||||
@@ -1106,3 +1106,4 @@ body:has(#sheet) #simplehome, body:has(#sheet) #kidreq, body:has(#lyr) #simpleho
|
||||
html.rm *, html.rm *::before, html.rm *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; scroll-behavior: auto !important; }
|
||||
html.hc #view a:not(.btn):not(.row):not(.vcard):not(.seg a):not(.sgrp a) { text-decoration: underline; } html.hc .btn.ghost, html.hc select, html.hc input[type=text], html.hc input[type=search], html.hc textarea { border: 2px solid var(--line); } html.hc :focus-visible { outline-width: 3px; }
|
||||
#offnote { position: fixed; left: 50%; transform: translateX(-50%); bottom: calc(var(--sab) + 70px); z-index: 90; max-width: min(92vw, 460px); padding: 10px 16px; border-radius: 12px; background: var(--panel); border: 1px solid var(--line); box-shadow: 0 6px 24px rgba(0, 0, 0, .3); font-size: 14px; text-align: center; } #offnote[hidden] { display: none; }
|
||||
.paTiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); gap: 10px; margin: 8px 0 16px; } .paTile { min-height: 84px; padding: 12px; border-radius: 16px; border: 1px solid var(--line); background: var(--panel); text-align: center; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; font-size: 17px; } .paTile small { color: var(--muted); font-size: 14px; } .paTile:active { transform: scale(.97); } .paTile.low { border-color: var(--danger); } .paTile.out { opacity: .45; }
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
// App-shell cache only; Seafile/API traffic always goes to the network.
|
||||
const CACHE = 'hub-v122';
|
||||
const CACHE = 'hub-v125';
|
||||
const KEEP = /^hub-(offline|share|who|data-)/; // caches that outlive a new version
|
||||
const SHARE = 'hub-share'; // files shared into the app from other apps, held until the person picks a folder
|
||||
const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png'];
|
||||
|
||||
Vendored
+245
@@ -0,0 +1,245 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
========================================================================
|
||||
jai-imageio
|
||||
========================================================================
|
||||
|
||||
Copyright (c) 2005 Sun Microsystems, Inc.
|
||||
Copyright © 2010-2014 University of Manchester
|
||||
Copyright © 2010-2015 Stian Soiland-Reyes
|
||||
Copyright © 2015 Peter Hull
|
||||
All Rights Reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions
|
||||
are met:
|
||||
|
||||
- Redistribution of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
|
||||
- Redistribution in binary form must reproduce the above copyright
|
||||
notice, this list of conditions and the following disclaimer in
|
||||
the documentation and/or other materials provided with the
|
||||
distribution.
|
||||
|
||||
Neither the name of Sun Microsystems, Inc. or the names of
|
||||
contributors may be used to endorse or promote products derived
|
||||
from this software without specific prior written permission.
|
||||
|
||||
This software is provided "AS IS," without a warranty of any
|
||||
kind. ALL EXPRESS OR IMPLIED CONDITIONS, REPRESENTATIONS AND
|
||||
WARRANTIES, INCLUDING ANY IMPLIED WARRANTY OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE OR NON-INFRINGEMENT, ARE HEREBY
|
||||
EXCLUDED. SUN MIDROSYSTEMS, INC. ("SUN") AND ITS LICENSORS SHALL
|
||||
NOT BE LIABLE FOR ANY DAMAGES SUFFERED BY LICENSEE AS A RESULT OF
|
||||
USING, MODIFYING OR DISTRIBUTING THIS SOFTWARE OR ITS
|
||||
DERIVATIVES. IN NO EVENT WILL SUN OR ITS LICENSORS BE LIABLE FOR
|
||||
ANY LOST REVENUE, PROFIT OR DATA, OR FOR DIRECT, INDIRECT, SPECIAL,
|
||||
CONSEQUENTIAL, INCIDENTAL OR PUNITIVE DAMAGES, HOWEVER CAUSED AND
|
||||
REGARDLESS OF THE THEORY OF LIABILITY, ARISING OUT OF THE USE OF OR
|
||||
INABILITY TO USE THIS SOFTWARE, EVEN IF SUN HAS BEEN ADVISED OF THE
|
||||
POSSIBILITY OF SUCH DAMAGES.
|
||||
|
||||
You acknowledge that this software is not designed or intended for
|
||||
use in the design, construction, operation or maintenance of any
|
||||
nuclear facility.
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).ZXing={})}(this,(function(t){"use strict";var e,r,n=(e=function(t,r){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r])},e(t,r)},function(t,r){if("function"!=typeof r&&null!==r)throw new TypeError("Class extends value "+String(r)+" is not a constructor or null");function n(){this.constructor=t}e(t,r),t.prototype=null===r?Object.create(r):(n.prototype=r.prototype,new n)}),i=function(t){function e(e,r){var n,i,s,o=this.constructor,a=t.call(this,e,r)||this;return Object.defineProperty(a,"name",{value:o.name,enumerable:!1,configurable:!0}),n=a,i=o.prototype,(s=Object.setPrototypeOf)?s(n,i):n.__proto__=i,function(t,e){void 0===e&&(e=t.constructor);var r=Error.captureStackTrace;r&&r(t,e)}(a),a}return n(e,t),e}(Error);class s extends i{constructor(t=void 0){super(t),this.message=t}getKind(){return this.constructor.kind}}s.kind="Exception";class o extends s{}o.kind="ArgumentException";class a extends s{}a.kind="IllegalArgumentException";class h{constructor(t){if(this.binarizer=t,null===t)throw new a("Binarizer must be non-null.")}getWidth(){return this.binarizer.getWidth()}getHeight(){return this.binarizer.getHeight()}getBlackRow(t,e){return this.binarizer.getBlackRow(t,e)}getBlackMatrix(){return null!==this.matrix&&void 0!==this.matrix||(this.matrix=this.binarizer.getBlackMatrix()),this.matrix}isCropSupported(){return this.binarizer.getLuminanceSource().isCropSupported()}crop(t,e,r,n){const i=this.binarizer.getLuminanceSource().crop(t,e,r,n);return new h(this.binarizer.createBinarizer(i))}isRotateSupported(){return this.binarizer.getLuminanceSource().isRotateSupported()}rotateCounterClockwise(){const t=this.binarizer.getLuminanceSource().rotateCounterClockwise();return new h(this.binarizer.createBinarizer(t))}rotateCounterClockwise45(){const t=this.binarizer.getLuminanceSource().rotateCounterClockwise45();return new h(this.binarizer.createBinarizer(t))}toString(){try{return this.getBlackMatrix().toString()}catch(t){return""}}}class l extends s{static getChecksumInstance(){return new l}}l.kind="ChecksumException";class c{constructor(t){this.source=t}getLuminanceSource(){return this.source}getWidth(){return this.source.getWidth()}getHeight(){return this.source.getHeight()}}class d{static arraycopy(t,e,r,n,i){for(;i--;)r[n++]=t[e++]}static currentTimeMillis(){return Date.now()}}class u extends s{}u.kind="IndexOutOfBoundsException";class g extends u{constructor(t=void 0,e=void 0){super(e),this.index=t,this.message=e}}g.kind="ArrayIndexOutOfBoundsException";class f{static fill(t,e){for(let r=0,n=t.length;r<n;r++)t[r]=e}static fillWithin(t,e,r,n){f.rangeCheck(t.length,e,r);for(let i=e;i<r;i++)t[i]=n}static rangeCheck(t,e,r){if(e>r)throw new a("fromIndex("+e+") > toIndex("+r+")");if(e<0)throw new g(e);if(r>t)throw new g(r)}static asList(...t){return t}static create(t,e,r){return Array.from({length:t}).map((t=>Array.from({length:e}).fill(r)))}static createInt32Array(t,e,r){return Array.from({length:t}).map((t=>Int32Array.from({length:e}).fill(r)))}static equals(t,e){if(!t)return!1;if(!e)return!1;if(!t.length)return!1;if(!e.length)return!1;if(t.length!==e.length)return!1;for(let r=0,n=t.length;r<n;r++)if(t[r]!==e[r])return!1;return!0}static hashCode(t){if(null===t)return 0;let e=1;for(const r of t)e=31*e+r;return e}static fillUint8Array(t,e){for(let r=0;r!==t.length;r++)t[r]=e}static copyOf(t,e){return t.slice(0,e)}static copyOfUint8Array(t,e){if(t.length<=e){const r=new Uint8Array(e);return r.set(t),r}return t.slice(0,e)}static copyOfRange(t,e,r){const n=r-e,i=new Int32Array(n);return d.arraycopy(t,e,i,0,n),i}static binarySearch(t,e,r){void 0===r&&(r=f.numberComparator);let n=0,i=t.length-1;for(;n<=i;){const s=i+n>>1,o=r(e,t[s]);if(o>0)n=s+1;else{if(!(o<0))return s;i=s-1}}return-n-1}static numberComparator(t,e){return t-e}}class w{static numberOfTrailingZeros(t){let e;if(0===t)return 32;let r=31;return e=t<<16,0!==e&&(r-=16,t=e),e=t<<8,0!==e&&(r-=8,t=e),e=t<<4,0!==e&&(r-=4,t=e),e=t<<2,0!==e&&(r-=2,t=e),r-(t<<1>>>31)}static numberOfLeadingZeros(t){if(0===t)return 32;let e=1;return t>>>16==0&&(e+=16,t<<=16),t>>>24==0&&(e+=8,t<<=8),t>>>28==0&&(e+=4,t<<=4),t>>>30==0&&(e+=2,t<<=2),e-=t>>>31,e}static toHexString(t){return t.toString(16)}static toBinaryString(t){return String(parseInt(String(t),2))}static bitCount(t){return t=(t=(858993459&(t-=t>>>1&1431655765))+(t>>>2&858993459))+(t>>>4)&252645135,t+=t>>>8,63&(t+=t>>>16)}static truncDivision(t,e){return Math.trunc(t/e)}static parseInt(t,e=void 0){return parseInt(t,e)}}w.MIN_VALUE_32_BITS=-2147483648,w.MAX_VALUE=Number.MAX_SAFE_INTEGER;class C{constructor(t,e){void 0===t?(this.size=0,this.bits=new Int32Array(1)):(this.size=t,this.bits=null==e?C.makeArray(t):e)}gLine truncated
|
||||
Reference in new issue
Block a user