openvibers/openvibe.food Commits

Use shared picker for pantry photos (#284)

OP OpenVibers committed 1 days ago · 6b8f2256a3 · 4 files, +70 −31

Changes

ddiff --git a/public/js/pantry-photo.js b/public/js/pantry-photo.js
iindex efd87ca..271b4c4 100644
---- a/public/js/pantry-photo.js
++++ b/public/js/pantry-photo.js
@@@ -1,34 +1,68 @@
--/* OpenVibe.Food — "Add from a photo or a receipt" on /pantry: shrink the chosen photo on the device (at most 1280 px,
-- JPEG) and put it in the form as a data URL, so the server never handles a full-size camera file. */
++/* Pantry photo: the shared picker chooses locally; only a reduced JPEG goes to Food. */
(function () {
'use strict';
-- var file = document.getElementById('photo-file');
++ var root = document.getElementById('photo-picker');
var data = document.getElementById('photo-data');
var send = document.getElementById('photo-send');
-- if (!file || !data || !send || !window.FileReader) return;
-- var MAX = 1280;
-- file.addEventListener('change', function () {
++ var error = document.getElementById('photo-error');
++ var form = document.getElementById('photo-form');
++ if (!root || !data || !send || !error || !form || !window.OVUpload || !window.FileReader) return;
++
++ var MAX_SIDE = 1280;
++ var MAX_SOURCE = 20 * 1024 * 1024;
++ var MAX_RESULT = 3500000;
++ var row = null;
++ var generation = 0;
++ function fail(message) {
++ data.value = '';
send.disabled = true;
++ send.textContent = 'Read the photo';
++ error.textContent = message;
++ error.hidden = false;
++ }
++ function clear() {
++ generation++;
++ row = null;
data.value = '';
-- var f = file.files && file.files[0];
-- if (!f || !/^image\//.test(f.type)) return;
-- send.textContent = 'Preparing the photo…';
-- var reader = new FileReader();
-- var img = new Image();
-- img.onload = function () {
-- var scale = Math.min(1, MAX / Math.max(img.naturalWidth, img.naturalHeight));
-- var c = document.createElement('canvas');
-- c.width = Math.max(1, Math.round(img.naturalWidth * scale));
-- c.height = Math.max(1, Math.round(img.naturalHeight * scale));
-- c.getContext('2d').drawImage(img, 0, 0, c.width, c.height);
-- data.value = c.toDataURL('image/jpeg', 0.82);
-- send.disabled = false;
-- send.textContent = 'Read the photo';
-- };
-- img.onerror = function () { send.textContent = 'That file is not a photo this browser can open'; };
-- // A data: URL (Food's CSP allows data: images, not blob:).
-- reader.onload = function () { img.src = reader.result; };
-- reader.readAsDataURL(f);
++ send.disabled = true;
++ }
++ var pick = OVUpload.picker(root, {
++ label: 'Choose a cupboard photo or receipt', accept: 'image/*', multiple: false,
++ onRemove: clear,
++ onFile: function (file, nextRow) {
++ if (row) { row.dispatchEvent(new Event('remove')); row.remove(); }
++ row = nextRow;
++ var current = ++generation;
++ data.value = '';
++ send.disabled = true;
++ error.hidden = true;
++ if (!/^image\//.test(file.type)) return fail('Choose a photo or screenshot.');
++ if (file.size > MAX_SOURCE) return fail('Choose an image under 20 MB.');
++ send.textContent = 'Preparing the photo…';
++ var reader = new FileReader();
++ var img = new Image();
++ img.onload = function () {
++ if (current !== generation) return;
++ try {
++ var scale = Math.min(1, MAX_SIDE / Math.max(img.naturalWidth, img.naturalHeight));
++ var canvas = document.createElement('canvas');
++ canvas.width = Math.max(1, Math.round(img.naturalWidth * scale));
++ canvas.height = Math.max(1, Math.round(img.naturalHeight * scale));
++ canvas.getContext('2d').drawImage(img, 0, 0, canvas.width, canvas.height);
++ var reduced = canvas.toDataURL('image/jpeg', 0.82);
++ if (reduced.length > MAX_RESULT) return fail('The prepared photo is over 3.5 MB. Choose a smaller image.');
++ data.value = reduced;
++ send.disabled = false;
++ send.textContent = 'Read the photo';
++ } catch (_) { fail('This browser could not prepare that photo. Choose another image.'); }
++ };
++ img.onerror = function () { if (current === generation) fail('That file is not a photo this browser can open.'); };
++ reader.onerror = function () { if (current === generation) fail('The photo could not be read. Choose it again.'); };
++ reader.onload = function () { if (current === generation) img.src = reader.result; };
++ reader.readAsDataURL(file);
++ }
});
-- document.getElementById('photo-form').addEventListener('submit', function () { send.disabled = true; send.textContent = 'Reading…'; });
++ pick.input.setAttribute('capture', 'environment');
++ pick.input.setAttribute('aria-describedby', 'photo-note');
++ form.addEventListener('submit', function () { send.disabled = true; send.textContent = 'Reading…'; });
})();
ddiff --git a/server/app.js b/server/app.js
iindex 12bf9d7..2046b92 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -152,7 +152,7 @@ async function createApp(opts = {}) {
scriptSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://static.cloudflareinsights.com'],
styleSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://fonts.googleapis.com', 'https://cdnjs.cloudflare.com'],
fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com', 'https://cdnjs.cloudflare.com'],
-- imgSrc: ["'self'", 'data:', 'https://openvibe.network', 'https://openvibe.media'],
++ imgSrc: ["'self'", 'data:', 'blob:', 'https://openvibe.network', 'https://openvibe.media'],
// The navbar's Messages count asks Chat (openvibe-shared 4.130.0).
connectSrc: ["'self'", 'https://openvibe.chat', 'https://openvibe.network', 'https://cloudflareinsights.com', 'https://openvibe.events'],
frameSrc: ["'self'", 'https://openvibe.network'],
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 0d2f174..7decec3 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -742,7 +742,7 @@ ${foods.FOODS.filter((f) => f.group === id).map((f) => html`<label class="pantry
</fieldset>`);
const recipeLine = (x) => html`<li><b>${x.name}</b> — serves ${x.serves}, about ${x.minutes} minutes${x.missing && x.missing.length ? html` · missing ${x.missing.join(', ')}` : ''}</li>`;
page(req, res, {
-- description: 'Keep what is in your cupboard, added by barcode, photo or receipt, and see which budget recipes you can make with it now.', title: 'Your cupboard', crumbs: [{ label: 'Your cupboard' }], styles: [showcase.STYLESHEET], localStyles: ['css/pantry.css'], scripts: ['js/barcode-scan.js', 'js/pantry-photo.js'],
++ description: 'Keep what is in your cupboard, added by barcode, photo or receipt, and see which budget recipes you can make with it now.', title: 'Your cupboard', crumbs: [{ label: 'Your cupboard' }], styles: [showcase.STYLESHEET, 'ov-upload.css'], localStyles: ['css/pantry.css'], head: `<script src="${ovServe.url('ov-upload.js')}" defer></script>`, scripts: ['js/barcode-scan.js', 'js/pantry-photo.js'],
body: html`<h1>Your cupboard</h1>
<p class="lede">Tick what you have. Nothing leaves your account; the matching is done here, not by a model.</p>
${saved ? notice('Cupboard saved.', 'ok') : ''}
@@@ -760,10 +760,10 @@ ${addedCount > 1 && addedCount <= 80 ? notice(`Added ${addedCount} items to your
<fieldset><legend>Add from a photo or a receipt</legend>
<div class="pantry-photo-kind"><label><input type="radio" name="kind" value="shelf" checked> A cupboard, fridge or shelf</label>
<label><input type="radio" name="kind" value="receipt"> A grocery receipt</label></div>
--<input type="file" id="photo-file" accept="image/*" capture="environment" aria-describedby="photo-note">
++<div id="photo-picker"></div><p id="photo-error" role="alert" hidden></p>
<input type="hidden" name="image" id="photo-data">
<button class="sc-btn" type="submit" id="photo-send" disabled>Read the photo</button>
--<p class="muted small" id="photo-note">OpenVibe.AI reads the photo and lists what it sees from Food's list; you choose what to add. The photo is made smaller on your device first, is not kept, and a receipt's prices, card and store details are never read out. Ten photos a day. Needs JavaScript.</p>
++<p class="muted small" id="photo-note">Choose or drop an image, up to 20 MB. It is made smaller on your device first; the result must fit under 3.5 MB. OpenVibe.AI reads it and lists what it sees from Food's list; you choose what to add. The original photo is not uploaded or kept, and a receipt's prices, card and store details are never read out. Ten photos a day. Needs JavaScript.</p>
</fieldset></form>
<p><a href="/recipes">Get recipe ideas</a></p>
<form method="post" action="/pantry" class="pantry-form">
ddiff --git a/test/pantry-photo.test.js b/test/pantry-photo.test.js
iindex e14db48..1f69f7a 100644
---- a/test/pantry-photo.test.js
++++ b/test/pantry-photo.test.js
@@@ -80,7 +80,12 @@ const idx = (slug) => foods.FOODS.findIndex((f) => f.slug === slug);
await check('the cupboard page offers the photo form', async () => {
const page = await t.get('/pantry', { as: kim });
-- assert.ok(page.text.includes('action="/pantry/photo"') && page.text.includes('js/pantry-photo.js') && page.text.includes('capture="environment"'));
++ assert.ok(page.text.includes('action="/pantry/photo"') && page.text.includes('js/pantry-photo.js'));
++ assert.match(page.text, /ov-upload\.css/);
++ assert.match(page.text, /ov-upload\.js/);
++ assert.match(page.text, /id="photo-picker"/);
++ assert.match(page.text, /id="photo-error" role="alert"/);
++ assert.match(page.text, /up to 20 MB/);
});
} finally { await t.close(); }
done();