openvibers/openvibe.mediahub Commits
Empty states: picture, video and file pages, albums and sync keys use the kit (#412)
Changes
@@@ -558,8 +558,9 @@ ${agentGrants.length ? html`<h3 class="fv-h3">Agent grants for this file</h3><ulfunction 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>@@@ -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" nameconst 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 classconst 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>@@@ -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 chareturn 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"><h2if (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>`,});});nnew file mode 100644@@@ -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();++})();@@@ -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));
@@ -558,8 +558,9 @@ ${agentGrants.length ? html`<h3 class="fv-h3">Agent grants for this file</h3><ul
@@ -558,8 +558,9 @@ ${agentGrants.length ? html`<h3 class="fv-h3">Agent grants for this file</h3><ul
function notFoundFile(req, res) {
function notFoundFile(req, res) {
return page(req, res, {
return page(req, res, {
title: 'No such file',
title: 'No such file',
+ styles: ['kit.css'],
crumbs: [{ label: 'Home', href: '/' }, { label: 'Your files', href: '/files' }],
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);
}, 404);
}
}
@@ -1108,7 +1109,7 @@ ${suspendedDrops.length ? html`<h2>Suspended uploads without an account</h2>${su
@@ -1108,7 +1109,7 @@ ${suspendedDrops.length ? html`<h2>Suspended uploads without an account</h2>${su
<div id="drop-status" class="small" role="status"></div>
<div id="drop-status" class="small" role="status"></div>
</section>`;
</section>`;
const dropCrumbs = (drop) => [{ label: 'Home', href: '/' }, { label: drop ? drop.name : 'A file' }];
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. */
/** 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) {
async function openDrop(req, res) {
if (!drops.enabled()) return dropOff(req, res) && null;
if (!drops.enabled()) return dropOff(req, res) && null;
@@ -1213,6 +1214,7 @@ ${suspended ? '' : html`<section class="card" aria-labelledby="h-report"><h2 id=
@@ -1213,6 +1214,7 @@ ${suspended ? '' : html`<section class="card" aria-labelledby="h-report"><h2 id=
const keys = await syncKeys.list(requesterOf(req));
const keys = await syncKeys.list(requesterOf(req));
return page(req, res, {
return page(req, res, {
title: 'Back up a folder',
title: 'Back up a folder',
+ styles: ['kit.css'],
crumbs: syncCrumbs,
crumbs: syncCrumbs,
body: html`<h1>Back up a folder from your computer</h1>
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>
<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=
@@ -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>
<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>
<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>
${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">
<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>
<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>
</form>
@@ -84,7 +84,8 @@ function createPicsRoutes(ctx) {
@@ -84,7 +84,8 @@ function createPicsRoutes(ctx) {
title: status === 410 ? 'Taken down' : 'Not found', cache: cache.htmlHeaders({ private: true }),
title: status === 410 ? 'Taken down' : 'Not found', cache: cache.htmlHeaders({ private: true }),
body: status === 410
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>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);
}, status);
const mustSignIn = (why) => (req, res, next) => (signedIn(req) ? next() : page(req, res, {
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>`,
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) {
@@ -221,7 +222,7 @@ function createPicsRoutes(ctx) {
const labels = labelsUi.inForce(row);
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);
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']]
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>`);
.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
@@ -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 use = await store.usageOf(s, owner);
const pictureDefault = await sharing.defaultAudience(owner, 'picture');
const pictureDefault = await sharing.defaultAudience(owner, 'picture');
return page(req, res, {
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>
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 })}
${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>
<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>` : ''}`,
${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
@@ -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 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>
<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>`
${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
@@ -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 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);
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, {
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,
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>
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>` : ''}
${album.description ? html`<p class="ovp-desc">${album.description}</p>` : ''}
${copyRow('Link to this album', `${base}/a/${album.slug}`, 'c-album')}
${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>
<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>
${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>
<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>
<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>
@@ -31,6 +31,7 @@ const ovServe = require('openvibe-shared/serve');
@@ -31,6 +31,7 @@ const ovServe = require('openvibe-shared/serve');
const { asyncRouter } = require('../http/router');
const { asyncRouter } = require('../http/router');
const { sameOrigin } = require('../http/principal');
const { sameOrigin } = require('../http/principal');
const { html, raw, notice, askAgent } = require('../render/html');
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.
// 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 frame = require('openvibe-shared/frame');
const { send, asset, assetVersion, SITE_NAME } = require('./layout');
const { send, asset, assetVersion, SITE_NAME } = require('./layout');
@@ -641,7 +642,11 @@ function clipCard(c) {
@@ -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) {
function createVideoRoutes(ctx) {
const { config, log, playlists } = ctx;
const { config, log, playlists } = ctx;
@@ -698,7 +703,8 @@ function createVideoRoutes(ctx) {
@@ -698,7 +703,8 @@ function createVideoRoutes(ctx) {
const notFound = (req, res) => page(req, res, {
const notFound = (req, res) => page(req, res, {
title: 'Not found',
title: 'Not found',
cache: cache.htmlHeaders({ private: true }),
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);
}, 404);
const notFoundVtt = (_req, res) => res.status(404).set('Content-Type', 'text/plain; charset=utf-8').send('Not found');
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);
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}` })}
@@ -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 || '')));
.sort((a, b) => String(b.item.created_at || '').localeCompare(String(a.item.created_at || '')));
return page(req, res, {
return page(req, res, {
index: true,
index: true,
+ styles: ['kit.css'],
scripts: ['js/video-home.js'],
scripts: ['js/video-home.js'],
title: 'OpenVibe.Video: watch streams again and share your videos',
title: 'OpenVibe.Video: watch streams again and share your videos',
description,
description,
@@ -1290,7 +1297,7 @@ ${[['all', 'All'], ['streams', 'Streams'], ['clips', 'Clips'], ['uploads', 'Uplo
@@ -1290,7 +1297,7 @@ ${[['all', 'All'], ['streams', 'Streams'], ['clips', 'Clips'], ['uploads', 'Uplo
</nav>
</nav>
<section class="ovv-continue" id="continue-watching" aria-label="Continue watching" hidden></section>
<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>
<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>`,
</section></div>`,
});
});
});
});
@@ -1328,13 +1335,14 @@ ${feedKind === 'following' ? html`<p class="muted">See updates from followed cha
@@ -1328,13 +1335,14 @@ ${feedKind === 'following' ? html`<p class="muted">See updates from followed cha
return page(req, res, {
return page(req, res, {
index: !cursor && items.length >= 4, noindex: Boolean(cursor) || items.length < 4,
index: !cursor && items.length >= 4, noindex: Boolean(cursor) || items.length < 4,
title, description,
title, description,
+ styles: ['kit.css'],
crumbs: [{ label: 'Home', href: '/' }, { label: 'Categories', href: '/#categories' }, { label: cat.label }],
crumbs: [{ label: 'Home', href: '/' }, { label: 'Categories', href: '/#categories' }, { label: cat.label }],
jsonLd: [
jsonLd: [
seo.jsonLd.breadcrumbs([{ name: 'OpenVibe.Video', url: `${base}/` }, { name: cat.label, url: `${base}/category/${slug}` }]),
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}` }),
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>
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>` : ''}
${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>` : ''}`,
${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"
@@ -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 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>`;
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, {
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}
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>`}`,
: 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 || ''}
@@ -1886,8 +1894,8 @@ ${more || ''}
description: `Videos, recordings and clips from @${username} on OpenVibe.Video.`,
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>
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>` : ''}
${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>` : ''}`,
${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
@@ -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);
if (req.viewer && req.viewer.username) await playlists.refreshOwnerUsername(ownerOf(req), req.viewer.username);
return page(req, res, {
return page(req, res, {
title: 'Your playlists',
title: 'Your playlists',
+ styles: ['kit.css'],
crumbs: [{ label: 'Home', href: '/' }, { label: 'Playlists' }],
crumbs: [{ label: 'Home', href: '/' }, { label: 'Playlists' }],
body: html`<h1>Your playlists</h1>
body: html`<h1>Your playlists</h1>
<section class="ovv-section" aria-labelledby="h-create"><h2 id="h-create">New playlist</h2>
<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>
<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>
<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>`,
});
});
});
});
new file mode 100644
new file mode 100644
@@ -0,0 +1,61 @@
@@ -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();
+})();
@@ -29,7 +29,7 @@ const MB = 1024 * 1024;
@@ -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 () => {
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');
assert.strictEqual((await t.get('/sync')).status, 401, 'a guest is asked to sign in');
const pageBefore = await t.get('/sync', { as: kim });
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');
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 });
const made = await t.get('/sync/keys', { as: kim, form: { name: 'laptop' }, headers: SAME });
assert.strictEqual(made.status, 200, made.text.slice(0, 300));
assert.strictEqual(made.status, 200, made.text.slice(0, 300));