openvibers/openvibe.inventory Commits
Use shared upload kit for Workshop badges (#263)
Changes
nnew file mode 100644@@@ -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();++ });++})();@@@ -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>`,@@@ -89,10 +89,14 @@ ${published.length ? html`<ul class="inv-grid">${published.map((d) => html`<li cpage(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>@@@ -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 });
new file mode 100644
new file mode 100644
@@ -0,0 +1,93 @@
@@ -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();
+ });
+})();
@@ -107,6 +107,7 @@ function renderPage(o) {
@@ -107,6 +107,7 @@ function renderPage(o) {
${sharedHtml(appIcon.headTags({ site: 'inventory' }))}
${sharedHtml(appIcon.headTags({ site: 'inventory' }))}
<link rel="stylesheet" href="${url(asset('css/app.css'))}">
<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.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)}
${render(scripts)}
<meta name="ov-boost" content="inventory@${esc(RELEASE)}">
<meta name="ov-boost" content="inventory@${esc(RELEASE)}">
<script src="${url(ovServe.url('boost.js'))}" data-main="#main" defer></script>`,
<script src="${url(ovServe.url('boost.js'))}" data-main="#main" defer></script>`,
@@ -89,10 +89,14 @@ ${published.length ? html`<ul class="inv-grid">${published.map((d) => html`<li c
@@ -89,10 +89,14 @@ ${published.length ? html`<ul class="inv-grid">${published.map((d) => html`<li c
page(req, res, {
page(req, res, {
title: 'Make a badge',
title: 'Make a badge',
robots: 'noindex',
robots: 'noindex',
+ styles: ['ov-upload.css'],
+ scripts: ['js/workshop-upload.js'],
+ uploadKit: true,
body: html`<h1>Make a badge</h1>
body: html`<h1>Make a badge</h1>
${notice(req)}
${notice(req)}
<form class="ws-form" method="post" action="${url(`/workshop/new`)}" enctype="multipart/form-data">
<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>
<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>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>
<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>
@@ -52,6 +52,14 @@ function multipart(fields, file) {
@@ -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 form = (who, path, fields) => t.get(path, { as: who, method: 'POST', form: fields, headers: origin });
const inv = t.ctx.inv;
const inv = t.ctx.inv;
try {
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 () => {
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(png(128, 128)), { type: 'image/png', width: 128, height: 128 });
assert.deepStrictEqual(inspect(webp(64, 64)), { type: 'image/webp', width: 64, height: 64 });
assert.deepStrictEqual(inspect(webp(64, 64)), { type: 'image/webp', width: 64, height: 64 });