openvibers/openvibe.food Commits
Use shared picker for pantry photos (#284)
Changes
@@@ -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…'; });})();@@@ -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'],@@@ -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">@@@ -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();
@@ -1,34 +1,68 @@
@@ -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 () {
(function () {
'use strict';
'use strict';
- var file = document.getElementById('photo-file');
+ var root = document.getElementById('photo-picker');
var data = document.getElementById('photo-data');
var data = document.getElementById('photo-data');
var send = document.getElementById('photo-send');
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.disabled = true;
+ send.textContent = 'Read the photo';
+ error.textContent = message;
+ error.hidden = false;
+ }
+ function clear() {
+ generation++;
+ row = null;
data.value = '';
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…'; });
})();
})();
@@ -152,7 +152,7 @@ async function createApp(opts = {}) {
@@ -152,7 +152,7 @@ async function createApp(opts = {}) {
scriptSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://static.cloudflareinsights.com'],
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'],
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'],
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).
// 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'],
connectSrc: ["'self'", 'https://openvibe.chat', 'https://openvibe.network', 'https://cloudflareinsights.com', 'https://openvibe.events'],
frameSrc: ["'self'", 'https://openvibe.network'],
frameSrc: ["'self'", 'https://openvibe.network'],
@@ -742,7 +742,7 @@ ${foods.FOODS.filter((f) => f.group === id).map((f) => html`<label class="pantry
@@ -742,7 +742,7 @@ ${foods.FOODS.filter((f) => f.group === id).map((f) => html`<label class="pantry
</fieldset>`);
</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>`;
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, {
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>
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>
<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') : ''}
${saved ? notice('Cupboard saved.', 'ok') : ''}
@@ -760,10 +760,10 @@ ${addedCount > 1 && addedCount <= 80 ? notice(`Added ${addedCount} items to your
@@ -760,10 +760,10 @@ ${addedCount > 1 && addedCount <= 80 ? notice(`Added ${addedCount} items to your
<fieldset><legend>Add from a photo or a receipt</legend>
<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>
<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>
<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">
<input type="hidden" name="image" id="photo-data">
<button class="sc-btn" type="submit" id="photo-send" disabled>Read the photo</button>
<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>
</fieldset></form>
<p><a href="/recipes">Get recipe ideas</a></p>
<p><a href="/recipes">Get recipe ideas</a></p>
<form method="post" action="/pantry" class="pantry-form">
<form method="post" action="/pantry" class="pantry-form">
@@ -80,7 +80,12 @@ const idx = (slug) => foods.FOODS.findIndex((f) => f.slug === slug);
@@ -80,7 +80,12 @@ const idx = (slug) => foods.FOODS.findIndex((f) => f.slug === slug);
await check('the cupboard page offers the photo form', async () => {
await check('the cupboard page offers the photo form', async () => {
const page = await t.get('/pantry', { as: kim });
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(); }
} finally { await t.close(); }
done();
done();