openvibers/openvibe.mediahub Commits

Empty states: picture, video and file pages, albums and sync keys use the kit (#412)

OP OpenVibers committed 1 days ago · 3fb3fc57df · 5 files, +94 −20

Changes

ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex c31d155..9973abe 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -558,8 +558,9 @@ ${agentGrants.length ? html`<h3 class="fv-h3">Agent grants for this file</h3><ul
function notFoundFile(req, res) {
return page(req, res, {
title: 'No such file',
++ styles: ['kit.css'],
crumbs: [{ label: 'Home', href: '/' }, { label: 'Your files', href: '/files' }],
-- body: html`<h1>No such file</h1><p class="muted">It is not yours, or it is gone. <a href="/files">Your files</a>.</p>`,
++ body: html`<h1>No such file</h1>${raw(kit.empty({ icon: 'download', title: 'No such file', text: 'It is not yours, or it is gone.', action: { href: '/files', label: 'Your files', icon: 'grid' } }))}`,
}, 404);
}
@@@ -1108,7 +1109,7 @@ ${suspendedDrops.length ? html`<h2>Suspended uploads without an account</h2>${su
<div id="drop-status" class="small" role="status"></div>
</section>`;
const dropCrumbs = (drop) => [{ label: 'Home', href: '/' }, { label: drop ? drop.name : 'A file' }];
-- const dropOff = (req, res) => page(req, res, { title: 'Not found', crumbs: dropCrumbs(null), body: html`<h1>Not found</h1><p class="muted">No file here. <a href="/">OpenVibe.Download</a>.</p>` }, 404);
++ const dropOff = (req, res) => page(req, res, { title: 'Not found', styles: ['kit.css'], crumbs: dropCrumbs(null), body: html`<h1>Not found</h1>${raw(kit.empty({ icon: 'download', title: 'No file here', text: 'This file page does not exist, or the file was removed.', action: { href: '/', label: 'Open OpenVibe.Download', icon: 'download' } }))}` }, 404);
/** The drop behind /d/<slug>: active, or suspended (shown as such); removed or unknown is a 404, or a 410 when it was there. */
async function openDrop(req, res) {
if (!drops.enabled()) return dropOff(req, res) && null;
@@@ -1213,6 +1214,7 @@ ${suspended ? '' : html`<section class="card" aria-labelledby="h-report"><h2 id=
const keys = await syncKeys.list(requesterOf(req));
return page(req, res, {
title: 'Back up a folder',
++ styles: ['kit.css'],
crumbs: syncCrumbs,
body: html`<h1>Back up a folder from your computer</h1>
<p class="sc-prose">The <code>openvibe-download</code> command keeps a folder on your computer copied into your drive: run it once, or on a schedule, and it sends only what is new or changed. It needs Node.js 20 or newer and a sync key from this page.</p>
@@@ -1225,7 +1227,7 @@ ${suspended ? '' : html`<section class="card" aria-labelledby="h-report"><h2 id=
<p class="small muted">Add <code>--dry-run</code> to see what it would send, and <code>--delete</code> to also remove drive files whose local copy is gone (without it, a backup only ever adds). Files keep their folders; a file is sent again when its size or contents change. Uploads through a sync key count against your ${rules.formatBytes(config.quotas.bytes)} and up to ${config.quotas.syncUploadsPerDay} files a day.</p></section>
<section aria-labelledby="h-keys"><h2 id="h-keys">Your sync keys</h2>
${keys.length ? html`<ul class="file-list">${keys.map((k) => html`<li class="file"><span class="file-main"><span class="file-name">${k.name}</span><span class="file-meta small muted"><code>${k.prefix}…</code> · made ${time(k.created_at)} · ${keyState(k)}</span></span>
--${k.revoked_at ? '' : html`<form class="inline" method="post" action="/sync/keys/${k.id}/revoke"><button class="sc-btn" type="submit">Revoke</button></form>`}</li>`)}</ul>` : html`<p class="muted">No sync keys yet.</p>`}
++${k.revoked_at ? '' : html`<form class="inline" method="post" action="/sync/keys/${k.id}/revoke"><button class="sc-btn" type="submit">Revoke</button></form>`}</li>`)}</ul>` : raw(kit.empty({ icon: 'key', title: 'No sync keys yet', text: 'Make one below, then put it in the environment of the computer that backs up.', action: { href: '#key-name', label: 'Make a sync key', icon: 'plus' } }))}
<form class="inline" method="post" action="/sync/keys">
<label for="key-name">Computer</label> <input id="key-name" name="name" type="text" maxlength="${syncKeys.NAME_MAX}" placeholder="laptop" required> <button class="sc-btn sc-primary" type="submit">Make a sync key</button>
</form>
ddiff --git a/server/pics/pages.js b/server/pics/pages.js
iindex dfee42e..05e730f 100644
---- a/server/pics/pages.js
++++ b/server/pics/pages.js
@@@ -84,7 +84,8 @@ function createPicsRoutes(ctx) {
title: status === 410 ? 'Taken down' : 'Not found', cache: cache.htmlHeaders({ private: true }),
body: status === 410
? html`<h1>This picture was taken down</h1><p class="muted">It broke the <a href="/terms">rules</a> or the law and is no longer on OpenVibe.Pics.</p>`
-- : html`<h1>Not found</h1><p class="muted">No picture or page here. <a href="/">Upload one</a>.</p>`,
++ : html`<h1>Not found</h1>${raw(kit.empty({ icon: 'image', title: 'No picture here', text: 'This picture page does not exist, or was taken down.', action: { href: '/', label: 'Upload a picture', icon: 'image' } }))}`,
++ styles: ['kit.css'],
}, status);
const mustSignIn = (why) => (req, res, next) => (signedIn(req) ? next() : page(req, res, {
title: 'Sign in', body: html`<h1>Sign in first</h1><p>${why}</p><p>${signInLink(req.originalUrl)}</p>`,
@@@ -221,7 +222,7 @@ function createPicsRoutes(ctx) {
const labels = labelsUi.inForce(row);
return labels.length ? html`<div class="ovp-tile-cover"${labelsUi.attrs(labels, { owner: row.owner })}>${tile(row)}</div>` : tile(row);
};
-- const grid = (rows, empty) => (rows.length ? html`<div class="ovp-grid">${rows.map(thumb)}</div>` : html`<p class="muted">${empty}</p>`);
++ const grid = (rows, empty = { title: 'Nothing here yet', text: 'No picture here yet.' }) => (rows.length ? html`<div class="ovp-grid">${rows.map(thumb)}</div>` : raw(kit.empty({ icon: 'image', ...(typeof empty === 'string' ? { title: empty, text: 'No picture here yet.' } : empty) })));
const audienceOptions = (current) => [['public', 'Everyone'], ['link', 'Anyone with the link'], ['people', 'Specific people'], ['private', 'Only me']]
.map(([value, label]) => html`<option value="${value}"${value === current ? raw(' selected') : ''}>${label}</option>`);
@@@ -648,11 +649,11 @@ ${pics.REASONS.map((r) => html`<label class="ovp-radio"><input type="radio" name
const use = await store.usageOf(s, owner);
const pictureDefault = await sharing.defaultAudience(owner, 'picture');
return page(req, res, {
-- title: 'Your pictures', scripts: ['js/pics.js'],
++ title: 'Your pictures', styles: ['kit.css'], scripts: ['js/pics.js'],
body: html`<div class="ovp-section-head"><h1>Your pictures</h1><span class="muted">${use.images} picture${use.images === 1 ? '' : 's'} · ${formatBytes(use.bytes)} of ${formatBytes(cfg.quotaBytes)}</span></div>
${uploadForm(req, { compact: true, defaultAudience: pictureDefault })}
<form class="ovp-form" method="post" action="/mine/share-default"><label>New pictures <select name="audience">${audienceOptions(pictureDefault)}</select></label><label><input type="checkbox" name="apply" value="1"> Apply to all my existing pictures</label><button class="ovp-btn" type="submit">Save default</button></form>
--${grid(out.rows, 'Nothing yet. Upload a picture above.')}
++${grid(out.rows, { title: 'No pictures yet', text: 'Upload a picture above and it appears here.', action: { href: '/', label: 'Upload a picture', icon: 'image' } })}
${out.next ? html`<p><a class="ovp-btn" href="/mine?before=${out.next}">Older pictures</a></p>` : ''}`,
});
});
@@@ -677,7 +678,8 @@ ${out.next ? html`<p><a class="ovp-btn" href="/mine?before=${out.next}">Older pi
<form class="ovp-form ovp-inline" method="post" action="/albums"><label for="a-title">New album</label><input id="a-title" name="title" type="text" maxlength="120" required placeholder="Trip to the coast"><label>Who can open it <select name="audience">${audienceOptions(albumDefault)}</select></label><button class="ovp-btn ovp-btn--primary" type="submit">Create</button></form>
<form class="ovp-form" method="post" action="/albums/share-default"><label>New albums <select name="audience">${audienceOptions(albumDefault)}</select></label><label><input type="checkbox" name="apply" value="1"> Apply to all my existing albums</label><button class="ovp-btn" type="submit">Save default</button></form>
${albums.length ? html`<div class="ovp-albums">${albums.map((a) => html`<a class="ovp-album" href="/a/${a.slug}">${a.cover_slug ? html`<img src="/i/${a.cover_slug}/thumb.webp" alt="" loading="lazy">` : html`<span class="ovp-album-empty" aria-hidden="true"></span>`}<span class="ovp-album-title">${a.title}</span><span class="muted">${a.image_count} picture${Number(a.image_count) === 1 ? '' : 's'}</span></a>`)}</div>`
-- : html`<p class="muted">No albums yet. Create one here, or add a picture to a new album from its page.</p>`}`,
++ : raw(kit.empty({ icon: 'image', title: 'No albums yet', text: 'Create one above, or add a picture to a new album from its page.', action: { href: '#a-title', label: 'Create an album', icon: 'plus' } }))}`,
++ styles: ['kit.css'],
});
});
@@@ -717,13 +719,13 @@ ${albums.length ? html`<div class="ovp-albums">${albums.map((a) => html`<a class
const images = await store.albumImages(s, album.id, { visibleOnly: !owner });
const rows = owner ? images : (await Promise.all(images.map(async (image) => await sharing.canView(req.viewer, 'picture', image.id) ? image : null))).filter(Boolean);
return page(req, res, {
-- title: album.title, noindex: true, scripts: ['js/pics.js'],
++ title: album.title, noindex: true, styles: ['kit.css'], scripts: ['js/pics.js'],
image: rows[0] ? `${base}/i/${rows[0].slug}.${rows[0].ext}` : undefined,
body: html`<div class="ovp-section-head"><h1>${album.title}</h1><span class="muted">${rows.length} picture${rows.length === 1 ? '' : 's'}</span></div>
${album.description ? html`<p class="ovp-desc">${album.description}</p>` : ''}
${copyRow('Link to this album', `${base}/a/${album.slug}`, 'c-album')}
<p class="ovp-share">${raw(shareButton({ url: `${base}/a/${album.slug}`, title: album.title, text: album.description || `${album.title}: an album on ${SITE_NAME}.`, kind: 'album', id: album.id }))}</p>
--${grid(rows, owner ? 'Empty. Add pictures from their pages.' : 'Nothing to show here.')}
++${grid(rows, owner ? { title: 'No pictures in this album yet', text: 'Add pictures to it from your pictures page.', action: { href: '/mine', label: 'Your pictures', icon: 'image' } } : { title: 'Nothing to show yet', text: 'The owner has not added a picture here yet.' })}
${owner ? html`<section class="ovp-section ovp-owner" aria-labelledby="h-manage"><h2 id="h-manage">Manage</h2>
<form class="ovp-form" method="post" action="/a/${album.slug}/edit"><label for="al-title">Name</label><input id="al-title" name="title" type="text" maxlength="120" value="${album.title}" required>
<label for="al-desc">Description</label><textarea id="al-desc" name="description" rows="2" maxlength="2000">${album.description}</textarea><p><button class="ovp-btn ovp-btn--primary" type="submit">Save</button></p></form>
ddiff --git a/server/video/pages.js b/server/video/pages.js
iindex 43d3a81..3fdc447 100644
---- a/server/video/pages.js
++++ b/server/video/pages.js
@@@ -31,6 +31,7 @@ const ovServe = require('openvibe-shared/serve');
const { asyncRouter } = require('../http/router');
const { sameOrigin } = require('../http/principal');
const { html, raw, notice, askAgent } = require('../render/html');
++const kit = require('openvibe-shared/kit');
// A recording's or clip's AI overview comes from Live, which filters it; read through the same filter here too.
const frame = require('openvibe-shared/frame');
const { send, asset, assetVersion, SITE_NAME } = require('./layout');
@@@ -641,7 +642,11 @@ function clipCard(c) {
});
}
--const grid = (cards, empty = 'Nothing here yet.') => (cards && cards.length ? html`<div class="ovv-grid">${cards}</div>` : html`<p class="muted">${empty}</p>`);
++const grid = (cards, empty = null) => {
++ if (cards && cards.length) return html`<div class="ovv-grid">${cards}</div>`;
++ const e = empty == null ? { title: 'Nothing here yet', text: 'No video here yet.' } : (typeof empty === 'string' ? { title: empty, text: 'No video here yet.' } : empty);
++ return raw(kit.empty({ icon: 'play', ...e }));
++};
function createVideoRoutes(ctx) {
const { config, log, playlists } = ctx;
@@@ -698,7 +703,8 @@ function createVideoRoutes(ctx) {
const notFound = (req, res) => page(req, res, {
title: 'Not found',
cache: cache.htmlHeaders({ private: true }),
-- body: html`<h1>Not found</h1><p class="muted">No page here. <a href="/">OpenVibe.Video</a>.</p>`,
++ body: html`<h1>Not found</h1>${raw(kit.empty({ icon: 'play', title: 'No page here', text: 'This video page does not exist, or was taken down.', action: { href: '/', label: 'Open OpenVibe.Video', icon: 'play' } }))}`,
++ styles: ['kit.css'],
}, 404);
const notFoundVtt = (_req, res) => res.status(404).set('Content-Type', 'text/plain; charset=utf-8').send('Not found');
const sendVtt = (res, body) => res.set('Content-Type', 'text/vtt; charset=utf-8').set('Cache-Control', cache.htmlHeaders({ maxAge: 300 })).send(body);
@@@ -1274,6 +1280,7 @@ ${labelsUi.picker(item, { what: `the ${what}`, id: `labels-${kind}` })}
.sort((a, b) => String(b.item.created_at || '').localeCompare(String(a.item.created_at || '')));
return page(req, res, {
index: true,
++ styles: ['kit.css'],
scripts: ['js/video-home.js'],
title: 'OpenVibe.Video: watch streams again and share your videos',
description,
@@@ -1290,7 +1297,7 @@ ${[['all', 'All'], ['streams', 'Streams'], ['clips', 'Clips'], ['uploads', 'Uplo
</nav>
<section class="ovv-continue" id="continue-watching" aria-label="Continue watching" hidden></section>
<section class="ovv-app-feed" aria-labelledby="feed-title"><h2 id="feed-title">${{ all: 'For you', streams: 'Streams', clips: 'Clips', uploads: 'Uploads', following: 'Following' }[feedKind]}</h2>
--${feedKind === 'following' ? html`<p class="muted">See updates from followed channels on <a href="${livePublic}/rundown?following=1">OpenVibe.Live</a>.</p>` : grid(feedEntries.map((entry) => entry.kind === 'upload' ? uploadCard(entry.item) : feedCard(entry.item, { views: true })), 'No videos here yet.')}
++${feedKind === 'following' ? html`<p class="muted">See updates from followed channels on <a href="${livePublic}/rundown?following=1">OpenVibe.Live</a>.</p>` : grid(feedEntries.map((entry) => entry.kind === 'upload' ? uploadCard(entry.item) : feedCard(entry.item, { views: true })), { title: 'No videos here yet', text: 'New recordings, clips and uploads land here as they happen.', action: { href: '/upload', label: 'Upload a video', icon: 'plus' } })}
</section></div>`,
});
});
@@@ -1328,13 +1335,14 @@ ${feedKind === 'following' ? html`<p class="muted">See updates from followed cha
return page(req, res, {
index: !cursor && items.length >= 4, noindex: Boolean(cursor) || items.length < 4,
title, description,
++ styles: ['kit.css'],
crumbs: [{ label: 'Home', href: '/' }, { label: 'Categories', href: '/#categories' }, { label: cat.label }],
jsonLd: [
seo.jsonLd.breadcrumbs([{ name: 'OpenVibe.Video', url: `${base}/` }, { name: cat.label, url: `${base}/category/${slug}` }]),
seo.jsonLd.itemList(title, items.map((it) => ({ name: it.title || 'Stream recording', url: `${base}${it.watch_id ? `/watch?v=${it.watch_id}` : `/v/${it.id}`}` })), { url: `${base}/category/${slug}` }),
],
body: html`<header class="ovv-channel-head"><span class="ovv-avatar ovv-avatar--big" aria-hidden="true"><i class="fa-solid ${cat.icon}"></i></span><div><h1>${cat.label}</h1><p class="ovv-meta">${cat.blurb}${count ? ` ${n(count.count)} ${count.count === 1 ? 'recording' : 'recordings'}.` : ''}</p></div></header>
--<section class="ovv-section" aria-label="${title}">${grid(items.map((it) => feedCard(it)), 'Nothing in this category yet.')}</section>
++<section class="ovv-section" aria-label="${title}">${grid(items.map((it) => feedCard(it)), { title: 'Nothing in this category yet', text: 'Recordings of this kind appear here when a streamer goes live.', action: { href: '/', label: 'See the latest recordings', icon: 'play' } })}</section>
${pageOf.next ? html`<p class="ovv-pagination"><a class="ovv-btn" href="/category/${slug}?after=${encodeURIComponent(pageOf.next)}" rel="next">Older recordings</a></p>` : ''}
${others.length ? html`<section class="ovv-section" aria-labelledby="h-other"><h2 id="h-other">Other categories</h2><ul class="ovv-shelf" role="list">${others.map((c) => html`<li><a class="ovv-shelf-tile" href="/category/${c.slug}"><i class="fa-solid ${c.icon}" aria-hidden="true"></i><span class="ovv-shelf-name">${c.label}</span><span class="ovv-shelf-count">${n(c.count)} ${c.count === 1 ? 'recording' : 'recordings'}</span></a></li>`)}</ul></section>` : ''}`,
});
@@@ -1388,9 +1396,9 @@ ${categories.length ? html`<section class="ovv-section" aria-labelledby="h-cats"
const chips = vod && (vod.categories.length || category) ? html`<nav class="ovv-chips" aria-label="Recordings by category"><a href="${link({ type: 'vod' })}"${!category ? raw(' aria-current="true"') : ''}>All categories</a>${vod.categories.map((c) => html`<a href="${link({ type: 'vod', category: c.slug })}"${c.slug === category ? raw(' aria-current="true"') : ''}>${c.label} <span>${n(c.count)}</span></a>`)}</nav>` : '';
const tabs = html`<nav class="ovv-chips" aria-label="What to search"><a href="${link({})}"${!kind ? raw(' aria-current="true"') : ''}>Everything</a>${SEARCH_KINDS.map(([k, label]) => html`<a href="${link({ type: k })}"${k === kind ? raw(' aria-current="true"') : ''}>${label}</a>`)}</nav>`;
return page(req, res, {
-- title: `Search: ${q}`, noindex: true, crumbs: [{ label: 'Home', href: '/' }, { label: 'Search', href: '/search' }, { label: q }],
++ title: `Search: ${q}`, noindex: true, styles: ['kit.css'], crumbs: [{ label: 'Home', href: '/' }, { label: 'Search', href: '/search' }, { label: q }],
body: html`<h1>Results for “${q}”</h1>${searchForm(q)}${tabs}
--${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovv-section" aria-labelledby="h-${x.kind}"><div class="ovv-section-head"><h2 id="h-${x.kind}">${x.label}</h2>${!kind && x.next ? html`<a href="${link({ type: x.kind })}">More ${x.label.toLowerCase()}</a>` : ''}</div>${x.kind === 'vod' ? chips : ''}${grid(x.cards, 'Nothing matched.')}${kind && x.next ? html`<p class="ovv-pagination"><a class="ovv-btn" href="${link({ type: kind, category, after: x.next })}" rel="next">More results</a></p>` : ''}</section>` : ''))
++${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovv-section" aria-labelledby="h-${x.kind}"><div class="ovv-section-head"><h2 id="h-${x.kind}">${x.label}</h2>${!kind && x.next ? html`<a href="${link({ type: x.kind })}">More ${x.label.toLowerCase()}</a>` : ''}</div>${x.kind === 'vod' ? chips : ''}${grid(x.cards, { title: 'Nothing matched', text: 'No recording, clip or upload matches these words.', action: { href: '/', label: 'Browse the latest recordings', icon: 'play' } })}${kind && x.next ? html`<p class="ovv-pagination"><a class="ovv-btn" href="${link({ type: kind, category, after: x.next })}" rel="next">More results</a></p>` : ''}</section>` : ''))
: html`<p class="muted">Nothing matched “${q}”${category ? ' in that category' : ''}. Try fewer or other words, or <a href="/#categories">browse by category</a>.</p>`}`,
});
});
@@@ -1886,8 +1894,8 @@ ${more || ''}
description: `Videos, recordings and clips from @${username} on OpenVibe.Video.`,
body: html`${req.query.deleted === '1' ? notice('Deleted on OpenVibe.Live.', 'ok') : ''}<header class="ovv-channel-head"><span class="ovv-avatar ovv-avatar--big" aria-hidden="true">${username.slice(0, 1).toUpperCase()}<img src="${config.networkUrl}/avatar/${username}" alt="" loading="lazy"></span><div><h1>@${username}</h1><p class="ovv-meta">Recordings and clips from this channel's streams on OpenVibe.Live.</p>${raw(shareButton({ url: `${base}/@${username}`, title: `@${username}`, text: `Videos, recordings and clips from @${username} on OpenVibe.Video.`, image: `${config.networkUrl}/avatar/${username}` }))}</div><a class="ovv-btn" href="${livePublic}/@${username}"><i class="fa-solid fa-tower-broadcast" aria-hidden="true"></i> Channel on OpenVibe.Live</a></header>
${uploaded.length ? html`<section class="ovv-section" aria-labelledby="h-uploads"><h2 id="h-uploads">Videos</h2>${grid(uploaded.map(uploadCard))}</section>` : ''}
--<section class="ovv-section" aria-labelledby="h-vods"><h2 id="h-vods">Recordings</h2>${grid(vods.map(vodCard), 'No recordings yet.')}${prevLink || nextLink ? html`<nav class="ovv-pagination" aria-label="Pages">${prevLink ? html`<a href="${prevLink}">← Newer</a>` : ''}${nextLink ? html`<a href="${nextLink}">Older →</a>` : ''}</nav>` : ''}</section>
--${clips.length ? html`<section class="ovv-section" aria-labelledby="h-clips"><h2 id="h-clips">Clips</h2>${grid(clips.map(clipCard), 'No clips yet.')}</section>` : ''}
++<section class="ovv-section" aria-labelledby="h-vods"><h2 id="h-vods">Recordings</h2>${grid(vods.map(vodCard), { title: 'No recordings yet', text: 'Recordings of this channel’s streams appear here.' })}${prevLink || nextLink ? html`<nav class="ovv-pagination" aria-label="Pages">${prevLink ? html`<a href="${prevLink}">← Newer</a>` : ''}${nextLink ? html`<a href="${nextLink}">Older →</a>` : ''}</nav>` : ''}</section>
++${clips.length ? html`<section class="ovv-section" aria-labelledby="h-clips"><h2 id="h-clips">Clips</h2>${grid(clips.map(clipCard), { title: 'No clips yet', text: 'Clips cut from this channel appear here.' })}</section>` : ''}
${lists.length ? html`<section class="ovv-section" aria-labelledby="h-lists"><h2 id="h-lists">Playlists</h2><ul class="ovv-playlist-list">${lists.map(publicPlaylistRow)}</ul></section>` : ''}`,
});
});
@@@ -1898,12 +1906,13 @@ ${lists.length ? html`<section class="ovv-section" aria-labelledby="h-lists"><h2
if (req.viewer && req.viewer.username) await playlists.refreshOwnerUsername(ownerOf(req), req.viewer.username);
return page(req, res, {
title: 'Your playlists',
++ styles: ['kit.css'],
crumbs: [{ label: 'Home', href: '/' }, { label: 'Playlists' }],
body: html`<h1>Your playlists</h1>
<section class="ovv-section" aria-labelledby="h-create"><h2 id="h-create">New playlist</h2>
<form class="ovv-save-form ovv-panel" method="post" action="/playlists"><label for="new-title">Title</label> <input id="new-title" name="title" type="text" maxlength="${playlists.TITLE_MAX}" required placeholder="Watch later"> <label for="new-vis">Who can see it</label> <select id="new-vis" name="visibility">${playlists.VISIBILITIES.map((v) => html`<option value="${v}"${v === 'private' ? ' selected' : ''}>${visibilityLabel(v)}</option>`)}</select> <button class="ovv-btn ovv-btn--primary" type="submit"><i class="fa-solid fa-plus" aria-hidden="true"></i> Create</button></form></section>
<section class="ovv-section" aria-labelledby="h-lists"><h2 id="h-lists">Playlists</h2>
--${rows.length ? html`<ul class="ovv-playlist-list">${rows.map(playlistRow)}</ul>` : html`<p class="muted">No playlists yet. Save a recording or clip from its watch page.</p>`}</section>`,
++${rows.length ? html`<ul class="ovv-playlist-list">${rows.map(playlistRow)}</ul>` : raw(kit.empty({ icon: 'list', title: 'No playlists yet', text: 'Save a recording or clip from its watch page.', action: { href: '/', label: 'Find something to watch', icon: 'play' } }))}</section>`,
});
});
ddiff --git a/test/empty-states.test.js b/test/empty-states.test.js
nnew file mode 100644
iindex 0000000..704acb1
---- /dev/null
++++ b/test/empty-states.test.js
@@@ -0,0 +1,61 @@
++'use strict';
++/** Owner ask 81: the picture, video and file 404s, empty albums, pictures, playlists and sync keys use the kit. */
++const assert = require('assert');
++const http = require('http');
++const { boot, check, done } = require('./helpers/boot');
++
++(async () => {
++ const t = await boot({ env: { MEDIAHUB_VIDEO_URL: 'https://openvibe.video' } });
++ const kim = t.network.addUser('media-empty');
++ const request = (host, path, { signedIn = false } = {}) => new Promise((resolve, reject) => {
++ const target = new URL(t.base);
++ const req = http.request({ hostname: target.hostname, port: target.port, path,
++ headers: { Host: host, ...(signedIn ? { Cookie: t.signIn(kim) } : {}) } }, (res) => {
++ const chunks = [];
++ res.on('data', (part) => chunks.push(part));
++ res.on('end', () => resolve({ status: res.statusCode, text: Buffer.concat(chunks).toString('utf8') }));
++ });
++ req.on('error', reject);
++ req.end();
++ });
++ try {
++ await check('a picture that does not exist draws the kit empty state', async () => {
++ const r = await request('openvibe.pics', '/i/zzzznotapicture');
++ assert.strictEqual(r.status, 404);
++ assert.ok(r.text.includes('class="ovk-empty"'));
++ assert.ok(r.text.includes('No picture here'));
++ assert.ok(r.text.includes('Upload a picture'));
++ assert.match(r.text, /kit\.css/);
++ });
++ await check('a video that does not exist draws the kit empty state', async () => {
++ const r = await request('openvibe.video', '/v/zzzznotavideo');
++ assert.strictEqual(r.status, 404);
++ assert.ok(r.text.includes('class="ovk-empty"'));
++ assert.ok(r.text.includes('No page here'));
++ assert.ok(r.text.includes('Open OpenVibe.Video'));
++ assert.match(r.text, /kit\.css/);
++ });
++ await check('a person with no album and no sync key draws the kit empty state', async () => {
++ const albums = await request('openvibe.pics', '/albums', { signedIn: true });
++ assert.strictEqual(albums.status, 200);
++ assert.ok(albums.text.includes('class="ovk-empty"'));
++ assert.ok(albums.text.includes('No albums yet'));
++ assert.ok(albums.text.includes('Create an album'));
++ assert.match(albums.text, /kit\.css/);
++ const sync = await request('openvibe.download', '/sync', { signedIn: true });
++ assert.strictEqual(sync.status, 200);
++ assert.ok(sync.text.includes('class="ovk-empty"'));
++ assert.ok(sync.text.includes('No sync keys yet'));
++ assert.ok(sync.text.includes('Make a sync key'));
++ assert.match(sync.text, /kit\.css/);
++ });
++ await check('a file that does not exist draws the kit empty state', async () => {
++ const r = await request('openvibe.download', '/files/zzzznotafile', { signedIn: true });
++ assert.strictEqual(r.status, 404);
++ assert.ok(r.text.includes('class="ovk-empty"'));
++ assert.ok(r.text.includes('No such file'));
++ assert.match(r.text, /kit\.css/);
++ });
++ } finally { await t.close(); }
++ done();
++})();
ddiff --git a/test/sync-keys.test.js b/test/sync-keys.test.js
iindex 8402733..e978033 100644
---- a/test/sync-keys.test.js
++++ b/test/sync-keys.test.js
@@@ -29,7 +29,7 @@ const MB = 1024 * 1024;
await check('a signed-in person makes a key on /sync; it is shown once and only its hash is kept', async () => {
assert.strictEqual((await t.get('/sync')).status, 401, 'a guest is asked to sign in');
const pageBefore = await t.get('/sync', { as: kim });
-- assert.ok(pageBefore.text.includes('No sync keys yet.'));
++ assert.ok(pageBefore.text.includes('No sync keys yet'));
assert.strictEqual((await t.get('/sync/keys', { as: kim, form: { name: 'laptop' } })).status, 403, 'not from this site');
const made = await t.get('/sync/keys', { as: kim, form: { name: 'laptop' }, headers: SAME });
assert.strictEqual(made.status, 200, made.text.slice(0, 300));