openvibers/openvibe.vip Commits

VIP: post pictures use the shared upload kit (#239)

OP OpenVibers committed 3 days ago · 49346f63fa · 5 files, +66 −2

The dashboard loads openvibe-shared/ov-upload.js and ov-upload.css. The
optional image on a members-only post is chosen with the shared picker and
published with the shared progress meter (bytes and time left); a post
without a picture still posts the same form. The image still goes to
OpenVibe.Media through VIP's service principal.

Changes

ddiff --git a/public/js/post-upload.js b/public/js/post-upload.js
nnew file mode 100644
iindex 0000000..5dfe1e8
---- /dev/null
++++ b/public/js/post-upload.js
@@@ -0,0 +1,55 @@
++/* VIP's post picture: the shared picker and progress meter, then the post form's own multipart endpoint.
++ The dashboard loads openvibe-shared/ov-upload.js and ov-upload.css. Without a picture the form posts as it
++ always did; with one, the same form goes by XHR so the person sees bytes and time left. */
++(function () {
++ 'use strict';
++ var root = document.querySelector('[data-post-upload]');
++ var form = root && root.closest('form');
++ if (!root || !form || !window.OVUpload) return;
++ var status = form.querySelector('[data-post-upload-status]');
++ var progressEl = form.querySelector('[data-post-upload-progress]');
++ var meter = progressEl ? window.OVUpload.progress(progressEl) : null;
++ var file = null, busy = false;
++ function say(text, bad) { if (status) { status.textContent = text || ''; status.classList.toggle('error', !!bad); } }
++ window.OVUpload.picker(root, {
++ label: 'Choose a picture', accept: root.getAttribute('data-accept') || 'image/*',
++ onFile: function (f) { file = f; say('Ready to publish with ' + f.name + '.'); },
++ onRemove: function () { file = null; say(''); }
++ });
++ form.addEventListener('submit', function (e) {
++ if (!file || busy) return;
++ e.preventDefault();
++ busy = true;
++ var button = form.querySelector('button[type=submit]');
++ if (button) button.disabled = true;
++ var fd = new FormData(form);
++ fd.append('image', file, file.name);
++ if (progressEl) progressEl.hidden = false;
++ if (meter) meter.update({ state: 'sending', done: 0, total: file.size });
++ say('Publishing with ' + file.name + '…');
++ var xhr = new XMLHttpRequest();
++ xhr.open('POST', form.getAttribute('action') || location.pathname);
++ xhr.withCredentials = true;
++ xhr.upload.onprogress = function (ev) { if (ev.lengthComputable && meter) meter.update({ state: 'sending', done: ev.loaded, total: ev.total }); };
++ xhr.onload = function () {
++ busy = false;
++ if (button) button.disabled = false;
++ if (xhr.status >= 200 && xhr.status < 400) {
++ if (meter) meter.update({ state: 'done', done: file.size, total: file.size });
++ location.assign(xhr.responseURL || (form.getAttribute('action') || location.href));
++ return;
++ }
++ var message = 'The picture did not publish (' + xhr.status + ').';
++ if (meter) meter.update({ state: 'failed', message: message });
++ say(message, true);
++ };
++ xhr.onerror = function () {
++ busy = false;
++ if (button) button.disabled = false;
++ var message = 'The connection dropped. Try again.';
++ if (meter) meter.update({ state: 'failed', message: message });
++ say(message, true);
++ };
++ xhr.send(fd);
++ });
++})();
ddiff --git a/server/web/layout.js b/server/web/layout.js
iindex fcb2f43..f5a7cdf 100644
---- a/server/web/layout.js
++++ b/server/web/layout.js
@@@ -91,6 +91,8 @@ function createLayout({ config, release }) {
`<link rel="stylesheet" href="${asset('css/vip.css')}">`,
// openvibe-shared stylesheets a page asks for by name (the home's showcase.css).
...(o.styles || []).map((name) => `<link rel="stylesheet" href="${ovServe.url(name)}">`),
++ ...(o.scripts || []).map((name) => `<script src="${ovServe.url(name)}" defer></script>`),
++ ...(o.pageScripts || []).map((name) => `<script src="${asset(name)}" defer></script>`),
`<meta name="ov-boost" content="vip@${esc(RELEASE)}">`,
`<script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>`,
`<script src="${asset('js/vip.js')}" defer></script>`,
ddiff --git a/server/web/pages.js b/server/web/pages.js
iindex 934a94d..a87a4a4 100644
---- a/server/web/pages.js
++++ b/server/web/pages.js
@@@ -199,11 +199,15 @@ function postsSection(posts) {
*/
function postsAdmin({ posts, tiers, moderation, token, base }) {
const composer = tiers.length ? `<details class="card"><summary>New post</summary>
--<form method="post" action="${base}/posts" enctype="multipart/form-data">${csrf(token)}
++<form method="post" action="${base}/posts" data-post-upload-form>${csrf(token)}
<label>Title <input name="title" maxlength="140" required></label>
<label>Text (Markdown) <textarea name="body" rows="6" required></textarea></label>
<fieldset><legend>Who reads it</legend>${tiers.map((t) => `<label class="check"><input type="checkbox" name="tiers" value="${t}"> Tier ${t}</label>`).join('')}</fieldset>
--<label>Image (optional) <input type="file" name="image" accept="image/png,image/jpeg,image/webp,image/gif"></label>
++<div class="field"><span>Image (optional)</span>
++<div data-post-upload data-accept="image/png,image/jpeg,image/webp,image/gif"></div>
++<p class="muted small" data-post-upload-status role="status" aria-live="polite"></p>
++<div data-post-upload-progress hidden></div>
++</div>
<p class="muted small">A member of a higher tier reads posts written for a lower tier too. The text is only ever sent to members.</p>
<button type="submit">Publish post</button></form></details>`
: '<p class="muted">Publish a plan first: a post is written for the members of one of your tiers.</p>';
ddiff --git a/server/web/routes.js b/server/web/routes.js
iindex ffebdf0..ce7e8e7 100644
---- a/server/web/routes.js
++++ b/server/web/routes.js
@@@ -286,6 +286,7 @@ function createWebRoutes({ domain, config, layout, userAuth, liveNow }) {
})) : [];
send(res, 200, {
title: isNetwork ? 'Network plans' : 'Dashboard', active: 'dashboard', robots: 'noindex,nofollow', canonicalPath: '/dashboard', viewer: req.viewer,
++ styles: ['ov-upload.css'], scripts: ['ov-upload.js'], pageScripts: ['js/post-upload.js'],
body: pages.dashboard({
viewer: req.viewer, creator, isNetwork, isStaff: isStaff(req.viewer), plans: planRows, allPerks, ownPerks, members, membersSource, priceTable,
rules: isNetwork ? [] : await Promise.all((await policies.list(creator.id)).map(policies.present)), token: forms.token(req.viewer.subject), query: req.query, base: isNetwork ? '/dashboard/network' : '/dashboard',
ddiff --git a/test/member-posts.test.js b/test/member-posts.test.js
iindex 5ba9df6..2bc249b 100644
---- a/test/member-posts.test.js
++++ b/test/member-posts.test.js
@@@ -203,6 +203,8 @@ test('an image on a post goes to Media and is shown to the members of the tier',
const member = t.network.newUser('mika');
await publishTiers(t, creator, [1]);
const dash = await t.page('/dashboard', { user: creator });
++ assert.ok(dash.text.includes('data-post-upload') && dash.text.includes('/shared/ov-upload.js') && dash.text.includes('/shared/ov-upload.css'), 'the composer loads the shared upload kit');
++ assert.ok(!dash.text.includes('type="file" name="image"'), 'the hand-rolled file input is gone');
const token = t.csrf(dash.text);
const png = Buffer.from('89504e470d0a1a0a', 'hex');
const res = await t.pageMultipart('/dashboard/posts', {