openvibers/openvibe.mediahub Commits
Video: the owner's Delete beside Edit (#337)
Changes
@@@ -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; }@@@ -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); }@@@ -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 };@@@ -153,7 +153,7 @@ function hreq(base, host, path, { method = 'GET', cookie, form, body, type, sameawait 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');nnew file mode 100644@@@ -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');
@@ -658,3 +658,8 @@ html.ov-motion .ovv-stat.is-live .ovv-dot { animation: ovv-pulse 1.6s ease-out i
@@ -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-cover > .ovv-card-media { border-radius: inherit; }
.ovv-card-body > .ovk-labels { margin: .3rem 0 0; }
.ovv-card-body > .ovk-labels { margin: .3rem 0 0; }
.ovv-head > .ovk-labels { margin: .35rem 0 .1rem; }
.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; }
@@ -45,7 +45,7 @@ const { createVideoApiRoutes } = require('./api');
@@ -45,7 +45,7 @@ const { createVideoApiRoutes } = require('./api');
const { createUploadApi, mountUploadPages } = require('./upload-routes');
const { createUploadApi, mountUploadPages } = require('./upload-routes');
const { ACCESS_COOKIE, REFRESH_COOKIE } = require('../auth/sso');
const { ACCESS_COOKIE, REFRESH_COOKIE } = require('../auth/sso');
const { mountDownloads, downloadMenu } = require('./downloads');
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 PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
const HOME_LIMIT = 10;
const HOME_LIMIT = 10;
@@ -749,7 +749,7 @@ function createVideoRoutes(ctx) {
@@ -749,7 +749,7 @@ function createVideoRoutes(ctx) {
/** The owner's badge in the meta line: who can see it now. */
/** 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>`;
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. */
/** 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
* "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.
* 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">` : ''}
@@ -780,7 +780,7 @@ ${whole ? html`<input type="hidden" name="recording_time" value="1">` : ''}
function ownerPanel(kind, id, item) {
function ownerPanel(kind, id, item) {
const what = kind === 'vod' ? 'recording' : 'clip';
const what = kind === 'vod' ? 'recording' : 'clip';
const action = `/${kind === 'vod' ? 'v' : 'c'}/${id}`;
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">
<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>
<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>` : ''}
${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')}
@@ -788,9 +788,6 @@ ${visibilityChoices(item.visibility || 'public')}
${labelsUi.picker(item, { what: `the ${what}`, id: `labels-${kind}` })}
${labelsUi.picker(item, { what: `the ${what}`, id: `labels-${kind}` })}
<button class="ovv-btn ovv-btn--primary" type="submit">Save changes</button>
<button class="ovv-btn ovv-btn--primary" type="submit">Save changes</button>
</form>
</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>`;
</details>`;
}
}
@@ -1482,7 +1479,7 @@ ${channelName ? html`<a class="ovv-avatar" href="${localChannel || liveChannel}"
@@ -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>`) : ''}
<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>
${metaParts.length ? html`<span class="ovv-meta">${metaParts.map((p, i) => (i === 0 ? p : [html` · `, p]))}</span>` : ''}</div>
</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>
</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>` : ''}
${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>
</div>
@@ -1620,7 +1617,7 @@ ${clipCredit}
@@ -1620,7 +1617,7 @@ ${clipCredit}
<div class="ovv-head-row">
<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>
<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>
${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>
</div>
</div>
</div>
@@ -1696,7 +1693,7 @@ ${up.html}
@@ -1696,7 +1693,7 @@ ${up.html}
if (!live.isId(id)) return notFound(req, res);
if (!live.isId(id)) return notFound(req, res);
const back = `/v/${id}`;
const back = `/v/${id}`;
if (!sameSite(req, res, back)) return undefined;
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 {
try {
await asPerson(req, res, (token) => live.deleteVod(token, id));
await asPerson(req, res, (token) => live.deleteVod(token, id));
} catch (err) { return ownerRefused(req, res, err, back); }
} catch (err) { return ownerRefused(req, res, err, back); }
@@ -1734,7 +1731,7 @@ ${up.html}
@@ -1734,7 +1731,7 @@ ${up.html}
if (!live.isId(id)) return notFound(req, res);
if (!live.isId(id)) return notFound(req, res);
const back = `/c/${id}`;
const back = `/c/${id}`;
if (!sameSite(req, res, back)) return undefined;
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 {
try {
await asPerson(req, res, (token) => live.deleteClip(token, id));
await asPerson(req, res, (token) => live.deleteClip(token, id));
} catch (err) { return ownerRefused(req, res, err, back); }
} catch (err) { return ownerRefused(req, res, err, back); }
@@ -111,6 +111,13 @@ function actionMenu({ icon, label, title = null, body, cls = '', iconOnly = fals
@@ -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>`;
<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? }]. */
/** The More menu's rows: [{ href, icon, label, hint? }]. */
function menuLinks(items) {
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>`;
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}
@@ -199,4 +206,4 @@ ${t.body}
</section>`;
</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 };
@@ -153,7 +153,7 @@ function hreq(base, host, path, { method = 'GET', cookie, form, body, type, same
@@ -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 () => {
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 });
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(/<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(!(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');
assert.ok(r.text.includes('value="nudity" checked>') && r.text.includes('value="18+" checked>'), 'in force');
new file mode 100644
new file mode 100644
@@ -0,0 +1,26 @@
@@ -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');