openvibers/openvibe.vip Commits
VIP: post pictures use the shared upload kit (#239)
Changes
nnew file mode 100644@@@ -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);++ });++})();@@@ -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>`,@@@ -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>';@@@ -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',@@@ -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', {
new file mode 100644
new file mode 100644
@@ -0,0 +1,55 @@
@@ -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);
+ });
+})();
@@ -91,6 +91,8 @@ function createLayout({ config, release }) {
@@ -91,6 +91,8 @@ function createLayout({ config, release }) {
`<link rel="stylesheet" href="${asset('css/vip.css')}">`,
`<link rel="stylesheet" href="${asset('css/vip.css')}">`,
// openvibe-shared stylesheets a page asks for by name (the home's showcase.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.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)}">`,
`<meta name="ov-boost" content="vip@${esc(RELEASE)}">`,
`<script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>`,
`<script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>`,
`<script src="${asset('js/vip.js')}" defer></script>`,
`<script src="${asset('js/vip.js')}" defer></script>`,
@@ -199,11 +199,15 @@ function postsSection(posts) {
@@ -199,11 +199,15 @@ function postsSection(posts) {
*/
*/
function postsAdmin({ posts, tiers, moderation, token, base }) {
function postsAdmin({ posts, tiers, moderation, token, base }) {
const composer = tiers.length ? `<details class="card"><summary>New post</summary>
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>Title <input name="title" maxlength="140" required></label>
<label>Text (Markdown) <textarea name="body" rows="6" required></textarea></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>
<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>
<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>`
<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>';
: '<p class="muted">Publish a plan first: a post is written for the members of one of your tiers.</p>';
@@ -286,6 +286,7 @@ function createWebRoutes({ domain, config, layout, userAuth, liveNow }) {
@@ -286,6 +286,7 @@ function createWebRoutes({ domain, config, layout, userAuth, liveNow }) {
})) : [];
})) : [];
send(res, 200, {
send(res, 200, {
title: isNetwork ? 'Network plans' : 'Dashboard', active: 'dashboard', robots: 'noindex,nofollow', canonicalPath: '/dashboard', viewer: req.viewer,
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({
body: pages.dashboard({
viewer: req.viewer, creator, isNetwork, isStaff: isStaff(req.viewer), plans: planRows, allPerks, ownPerks, members, membersSource, priceTable,
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',
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',
@@ -203,6 +203,8 @@ test('an image on a post goes to Media and is shown to the members of the tier',
@@ -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');
const member = t.network.newUser('mika');
await publishTiers(t, creator, [1]);
await publishTiers(t, creator, [1]);
const dash = await t.page('/dashboard', { user: creator });
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 token = t.csrf(dash.text);
const png = Buffer.from('89504e470d0a1a0a', 'hex');
const png = Buffer.from('89504e470d0a1a0a', 'hex');
const res = await t.pageMultipart('/dashboard/posts', {
const res = await t.pageMultipart('/dashboard/posts', {