openvibers/openvibe.inventory Commits

Use shared upload kit for Workshop badges (#263)

OP OpenVibers committed 2 days ago · 957fb52a21 · 4 files, +107 −1

Changes

ddiff --git a/public/js/workshop-upload.js b/public/js/workshop-upload.js
nnew file mode 100644
iindex 0000000..1766f20
---- /dev/null
++++ b/public/js/workshop-upload.js
@@@ -0,0 +1,93 @@
++/* Workshop's multipart form keeps its server checks and its no-script file input. */
++(function () {
++ 'use strict';
++ var form = document.querySelector('.ws-form');
++ var input = form && form.querySelector('[data-workshop-image]');
++ var root = document.querySelector('[data-workshop-picker]');
++ var error = document.querySelector('[data-workshop-error]');
++ if (!form || !input || !root || !error || !window.OVUpload || !window.DataTransfer) return;
++
++ var maxBytes = Number(root.dataset.maxBytes);
++ var selected = null;
++ var row = null;
++ var meter = null;
++ var sending = false;
++ function fail(message) {
++ error.textContent = message;
++ error.hidden = false;
++ }
++ function clear() {
++ selected = null;
++ input.value = '';
++ if (meter) meter.destroy();
++ meter = null;
++ row = null;
++ }
++ var pick = OVUpload.picker(root, {
++ label: 'Choose a badge image', accept: 'image/png,image/webp', multiple: false,
++ onRemove: clear,
++ onFile: function (file, nextRow) {
++ if (row) { row.dispatchEvent(new Event('remove')); row.remove(); }
++ error.hidden = true;
++ if (!['image/png', 'image/webp'].includes(file.type)) {
++ nextRow.dispatchEvent(new Event('remove')); nextRow.remove();
++ return fail('Choose a PNG or WebP image.');
++ }
++ if (file.size > maxBytes) {
++ nextRow.dispatchEvent(new Event('remove')); nextRow.remove();
++ return fail('A badge image can be at most ' + (maxBytes / 1024) + ' KB.');
++ }
++ var transfer = new DataTransfer();
++ transfer.items.add(file);
++ input.files = transfer.files;
++ selected = file;
++ row = nextRow;
++ var meterRoot = document.createElement('div');
++ row.append(meterRoot);
++ meter = OVUpload.progress(meterRoot, { compact: true, onRetry: send });
++ meter.update({ state: 'waiting', total: file.size });
++ }
++ });
++ input.required = false;
++ input.hidden = true;
++
++ function send() {
++ if (sending || !selected) return;
++ sending = true;
++ error.hidden = true;
++ var button = form.querySelector('[type="submit"]');
++ var remove = row.querySelector('.ov-upload__remove');
++ button.disabled = true;
++ remove.disabled = true;
++ var xhr = new XMLHttpRequest();
++ xhr.open('POST', form.action);
++ xhr.upload.onprogress = function (event) {
++ if (event.lengthComputable) meter.update({ state: 'sending', done: Math.min(event.loaded, selected.size), total: event.total });
++ };
++ xhr.upload.onload = function () { meter.update({ state: 'processing', done: selected.size, total: selected.size }); };
++ xhr.onload = function () {
++ if (xhr.status >= 200 && xhr.status < 400 && xhr.responseURL) {
++ meter.update({ state: 'done', done: selected.size, total: selected.size });
++ window.location.assign(xhr.responseURL);
++ return;
++ }
++ sending = false;
++ button.disabled = false;
++ remove.disabled = false;
++ meter.update({ state: 'failed', message: 'The badge could not be sent. Try again.' });
++ };
++ xhr.onerror = function () {
++ sending = false;
++ button.disabled = false;
++ remove.disabled = false;
++ meter.update({ state: 'failed', message: 'The connection stopped. Try again.' });
++ };
++ meter.update({ state: 'sending', done: 0, total: selected.size });
++ xhr.send(new FormData(form));
++ }
++ form.addEventListener('submit', function (event) {
++ event.preventDefault();
++ if (!selected) { fail('Choose a badge image first.'); pick.input.focus(); return; }
++ send();
++ });
++})();
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex 1b32632..c3dc387 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -107,6 +107,7 @@ function renderPage(o) {
${sharedHtml(appIcon.headTags({ site: 'inventory' }))}
<link rel="stylesheet" href="${url(asset('css/app.css'))}">
${(o.styles || []).map((name) => `<link rel="stylesheet" href="${esc(url(ovServe.url(name)))}">`).join('\n')}
++${o.uploadKit ? `<script src="${esc(url(ovServe.url('ov-upload.js')))}" defer></script>` : ''}
${render(scripts)}
<meta name="ov-boost" content="inventory@${esc(RELEASE)}">
<script src="${url(ovServe.url('boost.js'))}" data-main="#main" defer></script>`,
ddiff --git a/server/workshop/pages.js b/server/workshop/pages.js
iindex 2aacf74..569604a 100644
---- a/server/workshop/pages.js
++++ b/server/workshop/pages.js
@@@ -89,10 +89,14 @@ ${published.length ? html`<ul class="inv-grid">${published.map((d) => html`<li c
page(req, res, {
title: 'Make a badge',
robots: 'noindex',
++ styles: ['ov-upload.css'],
++ scripts: ['js/workshop-upload.js'],
++ uploadKit: true,
body: html`<h1>Make a badge</h1>
${notice(req)}
<form class="ws-form" method="post" action="${url(`/workshop/new`)}" enctype="multipart/form-data">
-- <label>Image <input type="file" name="image" accept="image/png,image/webp" required></label>
++ <label>Image <input type="file" name="image" accept="image/png,image/webp" required data-workshop-image></label>
++ <div data-workshop-picker data-max-bytes="${MAX_BYTES}"></div><p role="alert" data-workshop-error hidden></p>
<p class="muted small">A square PNG or WebP, ${MIN_SIDE} to ${MAX_SIDE} pixels on a side, at most ${MAX_BYTES / 1024} KB. It shows small, before a name: keep it simple and readable.</p>
<label>Name <input type="text" name="name" maxlength="80" required placeholder="OG Viewer"></label>
<label>What it means (optional) <textarea name="description" maxlength="500" rows="2" placeholder="For everyone who was in chat for the first stream."></textarea></label>
ddiff --git a/test/workshop.test.js b/test/workshop.test.js
iindex 8baf6ab..7f72d68 100644
---- a/test/workshop.test.js
++++ b/test/workshop.test.js
@@@ -52,6 +52,14 @@ function multipart(fields, file) {
const form = (who, path, fields) => t.get(path, { as: who, method: 'POST', form: fields, headers: origin });
const inv = t.ctx.inv;
try {
++ await check('badge form offers the shared picker, its limit and a no-script multipart input', async () => {
++ const page = (await t.get('/workshop/new', { as: alice })).text;
++ assert.match(page, /ov-upload\.css/);
++ assert.match(page, /ov-upload\.js/);
++ assert.match(page, /data-workshop-picker data-max-bytes="204800"/);
++ assert.match(page, /name="image" accept="image\/png,image\/webp" required/);
++ assert.match(page, /role="alert" data-workshop-error/);
++ });
await check('images are judged by their own bytes: square PNG or WebP, 64 to 512 pixels, at most 200 KB', async () => {
assert.deepStrictEqual(inspect(png(128, 128)), { type: 'image/png', width: 128, height: 128 });
assert.deepStrictEqual(inspect(webp(64, 64)), { type: 'image/webp', width: 64, height: 64 });