openvibers/openvibe.mediahub Commits

Video: the owner's Delete beside Edit (#337)

OP OpenVibers committed 5 days ago · cfd99b7903 · 5 files, +47 −12

* Video: the owner's Delete sits beside Edit, one question and one button

A streamer deleting the recording of a test stream found it, but not easily: Edit, then a
fold inside the edit panel, then a checkbox. Recordings and clips now have Delete in the
action row beside Edit: a small question (it goes for good) with one Delete for good
button; the form carries the confirm the route checks. The edit panel no longer hides a
second delete.

* labels-ui test: the edit panel's summary no longer lists delete (it is in the action row)

Changes

ddiff --git a/public/css/video.css b/public/css/video.css
iindex 2b0bed8..9d28b97 100644
---- a/public/css/video.css
++++ b/public/css/video.css
@@@ -658,3 +658,8 @@ html.ov-motion .ovv-stat.is-live .ovv-dot { animation: ovv-pulse 1.6s ease-out i
.ovv-card-cover > .ovv-card-media { border-radius: inherit; }
.ovv-card-body > .ovk-labels { margin: .3rem 0 0; }
.ovv-head > .ovk-labels { margin: .35rem 0 .1rem; }
++
++/* The owner's Delete in the action row (beside Edit): a quiet button, a red one inside its question. */
++.ovv-deleter > summary { color: var(--danger-text, var(--danger, #f87171)); }
++.ovv-deleter .ovv-pop-panel form { margin-top: 10px; }
++.ovv-deleter .ovv-pop-panel .ovv-btn--danger { width: 100%; justify-content: center; }
ddiff --git a/server/video/pages.js b/server/video/pages.js
iindex 20a7377..2506fa0 100644
---- a/server/video/pages.js
++++ b/server/video/pages.js
@@@ -45,7 +45,7 @@ const { createVideoApiRoutes } = require('./api');
const { createUploadApi, mountUploadPages } = require('./upload-routes');
const { ACCESS_COOKIE, REFRESH_COOKIE } = require('../auth/sso');
const { mountDownloads, downloadMenu } = require('./downloads');
--const { actionMenu, menuLinks, companion, whatItIs, shareButton, lengthWords, dayLabel, streamDate } = require('./watch-ui');
++const { actionMenu, deleteAction, menuLinks, companion, whatItIs, shareButton, lengthWords, dayLabel, streamDate } = require('./watch-ui');
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
const HOME_LIMIT = 10;
@@@ -749,7 +749,7 @@ function createVideoRoutes(ctx) {
/** The owner's badge in the meta line: who can see it now. */
const visibilityBadge = (v) => html`<span class="ovv-vis-badge ovv-vis-badge--${VISIBILITIES.includes(v) ? v : 'public'}"><i class="${(VISIBILITY_LOOK[v] || VISIBILITY_LOOK.public)[0]}" aria-hidden="true"></i> ${visibilityLabel(v || 'public')}</span>`;
/** The owner's Edit button, first in the action row: opens the edit panel under the player. */
-- const editButton = (what) => html`<a class="ovv-btn ovv-btn--edit" href="#manage" data-ovv-open="manage" title="Edit this ${what}: title, description, who can see it, delete"><i class="fa-solid fa-pen" aria-hidden="true"></i> Edit</a>`;
++ const editButton = (what) => html`<a class="ovv-btn ovv-btn--edit" href="#manage" data-ovv-open="manage" title="Edit this ${what}: title, description, who can see it"><i class="fa-solid fa-pen" aria-hidden="true"></i> Edit</a>`;
/**
* "Clip" in the action row: a finished recording, any signed-in viewer who can see it (Live's rule, as on Live). A
* small form under the button; signed out, the button signs in first and comes back.
@@@ -780,7 +780,7 @@ ${whole ? html`<input type="hidden" name="recording_time" value="1">` : ''}
function ownerPanel(kind, id, item) {
const what = kind === 'vod' ? 'recording' : 'clip';
const action = `/${kind === 'vod' ? 'v' : 'c'}/${id}`;
-- return html`<details class="ovv-panel ovv-owner" id="manage"><summary><h2 id="h-owner"><i class="fa-solid fa-pen" aria-hidden="true"></i> Edit this ${what}</h2><span class="ovv-fine">Title, ${kind === 'vod' ? 'description, ' : ''}who can see it, labels, delete</span></summary>
++ return html`<details class="ovv-panel ovv-owner" id="manage"><summary><h2 id="h-owner"><i class="fa-solid fa-pen" aria-hidden="true"></i> Edit this ${what}</h2><span class="ovv-fine">Title, ${kind === 'vod' ? 'description, ' : ''}who can see it, labels</span></summary>
<form class="ovv-owner-form" method="post" action="${action}/manage">
<label class="ovv-field"><span>Title</span><input name="title" type="text" maxlength="200" value="${item.title || ''}" required></label>
${kind === 'vod' ? html`<label class="ovv-field"><span>Description</span><textarea name="description" rows="3" maxlength="2000">${item.description || ''}</textarea></label>` : ''}
@@@ -788,9 +788,6 @@ ${visibilityChoices(item.visibility || 'public')}
${labelsUi.picker(item, { what: `the ${what}`, id: `labels-${kind}` })}
<button class="ovv-btn ovv-btn--primary" type="submit">Save changes</button>
</form>
--<details class="ovv-danger"><summary>Delete this ${what}</summary>
--<form class="ovv-owner-delete" method="post" action="${action}/delete"><label class="ovv-confirm"><input type="checkbox" name="confirm" value="1" required> I want to delete it for good</label> <button class="ovv-btn ovv-btn--danger" type="submit">Delete</button></form>
--</details>
</details>`;
}
@@@ -1482,7 +1479,7 @@ ${channelName ? html`<a class="ovv-avatar" href="${localChannel || liveChannel}"
<div class="ovv-byline-text">${channelName ? (localChannel ? html`<a class="ovv-channel-name" href="${localChannel}">@${channelName}</a>` : html`<span class="ovv-channel-name">@${channelName}</span>`) : ''}
${metaParts.length ? html`<span class="ovv-meta">${metaParts.map((p, i) => (i === 0 ? p : [html` · `, p]))}</span>` : ''}</div>
</div>
--<div class="ovv-actions">${manageable ? editButton('recording') : ''}${saveForm}${partyLink(watchPath, vod.title || 'Stream recording', vod)}${ownOnly ? '' : clipAction(req, id, vod)}${downloadMenu({ kind: 'vod', id, item: vod, has: { transcript: segs.length, chat: chatRows ? chatRows.rows.length + chatRows.leftOut : 0, chapters: mems.length } })}${raw(shareButton({ cls: 'ovv-btn', url: `${base}${watchPath}`, title: vod.title || 'Stream recording', text: about, image: httpsUrl(vod.thumbnail_url) || '', timeFrom: '.ovv-player' }))}${raw(askAgent({ text: `About the video “${vod.title || 'Stream recording'}” on OpenVibe.Video (${base}${watchPath}): ` }))}${moreItems.length ? actionMenu({ icon: 'fa-solid fa-ellipsis', label: 'More', iconOnly: true, cls: 'ovv-more-menu', body: menuLinks(moreItems) }) : ''}</div>
++<div class="ovv-actions">${manageable ? html`${editButton('recording')}${deleteAction({ action: `/v/${id}`, what: 'recording' })}` : ''}${saveForm}${partyLink(watchPath, vod.title || 'Stream recording', vod)}${ownOnly ? '' : clipAction(req, id, vod)}${downloadMenu({ kind: 'vod', id, item: vod, has: { transcript: segs.length, chat: chatRows ? chatRows.rows.length + chatRows.leftOut : 0, chapters: mems.length } })}${raw(shareButton({ cls: 'ovv-btn', url: `${base}${watchPath}`, title: vod.title || 'Stream recording', text: about, image: httpsUrl(vod.thumbnail_url) || '', timeFrom: '.ovv-player' }))}${raw(askAgent({ text: `About the video “${vod.title || 'Stream recording'}” on OpenVibe.Video (${base}${watchPath}): ` }))}${moreItems.length ? actionMenu({ icon: 'fa-solid fa-ellipsis', label: 'More', iconOnly: true, cls: 'ovv-more-menu', body: menuLinks(moreItems) }) : ''}</div>
</div>
${vod.is_recording ? html`<p class="ovv-recording" role="status"><i class="fa-solid fa-circle" aria-hidden="true"></i> This recording is still in progress. <a href="${liveChannel}">Watch the live channel</a>.</p>` : ''}
</div>
@@@ -1620,7 +1617,7 @@ ${clipCredit}
<div class="ovv-head-row">
<div class="ovv-byline"><div class="ovv-byline-text"><span class="ovv-channel-name">${clip.auto_generated ? html`<span class="ovv-tag ovv-tag--inline">AI clip</span> ` : ''}${attribution}</span>
${clip.created_at ? html`<span class="ovv-meta"><time datetime="${clip.created_at}">${dateLabel(clip.created_at)}</time>${manageable ? html` · ${visibilityBadge(clip.visibility)}` : ''}</span>` : ''}</div></div>
--<div class="ovv-actions">${manageable ? editButton('clip') : ''}${fullRecording ? html`<a class="ovv-btn ovv-btn--primary" href="${fullRecording}" title="Watch the recording it was cut from, from this moment"><i class="fa-solid fa-film" aria-hidden="true"></i> Full recording</a>` : ''}${saveForm}${cutting ? '' : partyLink(watchPath, clip.title || 'Clip', clip)}${cutting ? '' : downloadMenu({ kind: 'clip', id, item: clip, has: { transcript: segs.length } })}${raw(shareButton({ cls: 'ovv-btn', url: `${base}${watchPath}`, title: clip.title || 'Clip', text: clip.ai_overview || attribution, image: httpsUrl(clip.thumbnail_url) || '', timeFrom: '.ovv-player' }))}${raw(askAgent({ text: `About the clip “${clip.title || 'Clip'}” on OpenVibe.Video (${base}${watchPath}): ` }))}${moreItems.length ? actionMenu({ icon: 'fa-solid fa-ellipsis', label: 'More', iconOnly: true, cls: 'ovv-more-menu', body: menuLinks(moreItems) }) : ''}</div>
++<div class="ovv-actions">${manageable ? html`${editButton('clip')}${deleteAction({ action: `/c/${id}`, what: 'clip' })}` : ''}${fullRecording ? html`<a class="ovv-btn ovv-btn--primary" href="${fullRecording}" title="Watch the recording it was cut from, from this moment"><i class="fa-solid fa-film" aria-hidden="true"></i> Full recording</a>` : ''}${saveForm}${cutting ? '' : partyLink(watchPath, clip.title || 'Clip', clip)}${cutting ? '' : downloadMenu({ kind: 'clip', id, item: clip, has: { transcript: segs.length } })}${raw(shareButton({ cls: 'ovv-btn', url: `${base}${watchPath}`, title: clip.title || 'Clip', text: clip.ai_overview || attribution, image: httpsUrl(clip.thumbnail_url) || '', timeFrom: '.ovv-player' }))}${raw(askAgent({ text: `About the clip “${clip.title || 'Clip'}” on OpenVibe.Video (${base}${watchPath}): ` }))}${moreItems.length ? actionMenu({ icon: 'fa-solid fa-ellipsis', label: 'More', iconOnly: true, cls: 'ovv-more-menu', body: menuLinks(moreItems) }) : ''}</div>
</div>
</div>
</div>
@@@ -1696,7 +1693,7 @@ ${up.html}
if (!live.isId(id)) return notFound(req, res);
const back = `/v/${id}`;
if (!sameSite(req, res, back)) return undefined;
-- if ((req.body || {}).confirm !== '1') return fieldError(req, res, 'Tick the box to confirm the deletion.', back);
++ if ((req.body || {}).confirm !== '1') return fieldError(req, res, 'Confirm with Delete for good.', back);
try {
await asPerson(req, res, (token) => live.deleteVod(token, id));
} catch (err) { return ownerRefused(req, res, err, back); }
@@@ -1734,7 +1731,7 @@ ${up.html}
if (!live.isId(id)) return notFound(req, res);
const back = `/c/${id}`;
if (!sameSite(req, res, back)) return undefined;
-- if ((req.body || {}).confirm !== '1') return fieldError(req, res, 'Tick the box to confirm the deletion.', back);
++ if ((req.body || {}).confirm !== '1') return fieldError(req, res, 'Confirm with Delete for good.', back);
try {
await asPerson(req, res, (token) => live.deleteClip(token, id));
} catch (err) { return ownerRefused(req, res, err, back); }
ddiff --git a/server/video/watch-ui.js b/server/video/watch-ui.js
iindex 6a94d72..eb2f345 100644
---- a/server/video/watch-ui.js
++++ b/server/video/watch-ui.js
@@@ -111,6 +111,13 @@ function actionMenu({ icon, label, title = null, body, cls = '', iconOnly = fals
<div class="ovv-pop-panel" role="group" aria-label="${label}">${body}</div></details>`;
}
++/** The owner's Delete in the action row: one tap opens the question, one more deletes (the route checks confirm=1). */
++function deleteAction({ action, what }) {
++ return actionMenu({ icon: 'fa-solid fa-trash-can', label: 'Delete', title: `Delete this ${what}`, cls: 'ovv-deleter', body: html`<p class="ovv-pop-title">Delete this ${what}?</p>
++<p class="ovv-fine">It goes for good: nobody can watch it again, and it cannot be brought back.</p>
++<form method="post" action="${action}/delete"><input type="hidden" name="confirm" value="1"><button class="ovv-btn ovv-btn--danger" type="submit"><i class="fa-solid fa-trash-can" aria-hidden="true"></i> Delete for good</button></form>` });
++}
++
/** The More menu's rows: [{ href, icon, label, hint? }]. */
function menuLinks(items) {
return html`<ul class="ovv-pop-list">${items.map((it) => html`<li><a href="${it.href}"${it.external ? html` rel="noopener"` : ''}${it.data ? html` ${it.data}` : ''}><i class="${it.icon}" aria-hidden="true"></i><span>${it.label}${it.hint ? html`<small>${it.hint}</small>` : ''}</span></a></li>`)}</ul>`;
@@@ -199,4 +206,4 @@ ${t.body}
</section>`;
}
--module.exports = { clockOf, lengthWords, dayLabel, streamDate, whatItIs, shareButton, actionMenu, menuLinks, cornerMenu, downloadsFor, companion };
++module.exports = { clockOf, lengthWords, dayLabel, streamDate, whatItIs, shareButton, actionMenu, deleteAction, menuLinks, cornerMenu, downloadsFor, companion };
ddiff --git a/test/labels-ui.test.js b/test/labels-ui.test.js
iindex 6ecb8db..5d3c736 100644
---- a/test/labels-ui.test.js
++++ b/test/labels-ui.test.js
@@@ -153,7 +153,7 @@ function hreq(base, host, path, { method = 'GET', cookie, form, body, type, same
await check('the owner\'s form: in force ticked, the AI\'s suggestion marked and not ticked', async () => {
const r = await video('/v/301', { cookie: kimCookie });
-- assert.ok(r.text.includes('who can see it, labels, delete'), 'the panel says it holds the labels');
++ assert.ok(r.text.includes('who can see it, labels</span>'), 'the panel says it holds the labels');
assert.ok(/<link rel="stylesheet" href="\/css\/labels\.css(\?v=[0-9a-f]+)?">/.test(r.text), 'the picker\'s sheet, on the owner\'s page only');
assert.ok(!(await video('/v/301')).text.includes('css/labels.css'), 'a viewer\'s page does not load it');
assert.ok(r.text.includes('value="nudity" checked>') && r.text.includes('value="18+" checked>'), 'in force');
ddiff --git a/test/video-owner-delete.test.js b/test/video-owner-delete.test.js
nnew file mode 100644
iindex 0000000..6766e63
---- /dev/null
++++ b/test/video-owner-delete.test.js
@@@ -0,0 +1,26 @@
++'use strict';
++/**
++ * The owner deletes a recording or clip from the action row (a streamer, 11-08: deleting a test stream's recording
++ * "wasn't immediately obvious": it was Edit, then a fold inside the edit panel, then a checkbox). Delete sits beside
++ * Edit as a small question with one button; the form carries confirm=1 for the route's check; the edit panel no
++ * longer hides a second delete.
++ *
++ * node test/video-owner-delete.test.js
++ */
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const { deleteAction } = require('../server/video/watch-ui');
++
++const out = String(deleteAction({ action: '/v/42', what: 'recording' }));
++assert.match(out, /<details class="ovv-pop ovv-deleter" data-ovv-menu>/, 'a small menu in the action row');
++assert.match(out, /Delete this recording\?/, 'it asks first');
++assert.match(out, /<form method="post" action="\/v\/42\/delete"><input type="hidden" name="confirm" value="1">/, 'the form confirms itself');
++assert.match(out, /Delete for good<\/button>/, 'one button says what happens');
++
++const pages = fs.readFileSync(path.join(__dirname, '..', 'server', 'video', 'pages.js'), 'utf8');
++assert.ok(pages.includes("deleteAction({ action: `/v/${id}`, what: 'recording' })"), 'the recording page offers it beside Edit');
++assert.ok(pages.includes("deleteAction({ action: `/c/${id}`, what: 'clip' })"), 'the clip page too');
++const panel = pages.slice(pages.indexOf('function ownerPanel('), pages.indexOf('// The shell\'s navbar/footer scripts'));
++assert.ok(!/ovv-danger|I want to delete it for good/.test(panel), 'the edit panel no longer hides a second delete');
++console.log('video: Delete is beside Edit, one question, one button');