openvibers/openvibe.mediahub Commits
The one Share sheet on every MediaHub page (#290)
Changes
@@@ -348,10 +348,6 @@ a.ovv-channel-name:hover { text-decoration: underline; }/* A clip's credit: the recording it was cut from, and a link back to it. */.ovv-source { margin: 0 0 10px; font-size: .88rem; color: var(--ovv-dim); overflow-wrap: anywhere; }.ovv-source i { color: var(--ovv-faint); }--/* The share box: the page's own link, and a link to the moment it is playing (a moment, not a clip). */--.ovv-share-url { width: 100%; box-sizing: border-box; padding: 8px 10px; border: 1px solid var(--ovv-line-strong); border-radius: 8px; background: var(--bg-tertiary, #16223a); color: inherit; font-size: .82rem; }--.ovv-share-buttons { display: flex; flex-wrap: wrap; gap: 8px; margin: 10px 0 0; }--/* A button that opens a small menu or form (Save, Clip, More): a <details>; video.js closes it on a click elsewhere. */.ovv-pop { position: relative; }.ovv-pop > summary { list-style: none; cursor: pointer; }@@@ -242,60 +242,6 @@if (window.location.hash.length > 1) open(window.location.hash.slice(1));}-- // Share: the page's own link, and the link to the moment it is playing. The link is written in a field on the page,-- // so it can be copied without JavaScript; these buttons (copy, copy at this moment) appear when JavaScript is there.-- function share(video) {-- const title = (document.querySelector('.ovv-title') || {}).textContent || document.title;-- const fieldOf = function (b) {-- const id = b.getAttribute('data-ovv-share-field');-- return id ? document.getElementById(id) : null;-- };-- const momentUrl = function (url) {-- try {-- const u = new URL(url);-- if (video && video.currentTime > 1 && !video.ended) u.searchParams.set('t', String(Math.floor(video.currentTime)));-- return u.toString();-- } catch (e) { return url; }-- };-- const copied = function (b, url) {-- const label = b.innerHTML;-- const field = fieldOf(b);-- if (field) field.value = url;-- const said = function (text) {-- b.innerHTML = text;-- setTimeout(function () { b.innerHTML = label; }, 2000);-- };-- if (!navigator.clipboard || !navigator.clipboard.writeText) {-- // No clipboard to write to: the field is selected, so the reader can copy it by hand.-- if (field) { field.focus(); field.select(); }-- said('<i class="fa-solid fa-check" aria-hidden="true"></i> Link ready to copy');-- return;-- }-- navigator.clipboard.writeText(url).then(function () {-- said('<i class="fa-solid fa-check" aria-hidden="true"></i> Link copied');-- }, function () { window.prompt('Copy this link', url); });-- };-- document.querySelectorAll('[data-ovv-share]').forEach(function (b) {-- b.hidden = false;-- b.addEventListener('click', function () {-- const url = b.getAttribute('data-ovv-share');-- const field = fieldOf(b);-- if (field) field.value = url;-- if (navigator.share && window.matchMedia('(hover: none)').matches) {-- navigator.share({ title: title, url: url }).catch(function () { /* closed */ });-- return;-- }-- copied(b, url);-- });-- });-- document.querySelectorAll('[data-ovv-share-at]').forEach(function (b) {-- b.hidden = false;-- b.addEventListener('click', function () {-- copied(b, momentUrl(b.getAttribute('data-ovv-share-at') || ''));-- });-- });-- }--// The transcript follows the video (the line being said is marked and kept in view inside its panel, unless the// reader scrolled it lately or is searching), and its search box keeps only the lines that have the words.function transcript(video) {@@@ -505,7 +451,6 @@copyButtons();tabs();openers();-- share(document.querySelector('video[data-ovv-video]'));const video = document.querySelector('video[data-ovv-video]');transcript(video);@@@ -39,7 +39,7 @@ const { asyncRouter } = require('./router');const { createDiscoveryRoutes, homeJsonLd, homeDescription } = require('./discovery');const { TWIN_PATHS } = require('../drive/agent-pages');const { sameOrigin } = require('./principal');--const { html, raw, table, notice, time, badge } = require('../render/html');++const { html, raw, table, notice, time, badge, shareButton } = require('../render/html');const { send } = require('../render/layout');const rules = require('../drive/rules');const { isStaff } = require('../drive/staff');@@@ -325,11 +325,12 @@ ${shares.length ? html`<ul class="file-list">${shares.map((sh) => html`<li classconst shares = (await drive.listShares(owner)).filter((sh) => sh.file_id === row.id);return page(req, res, {title: row.name,++ styles: ['kit.css'],crumbs: [{ label: 'Home', href: '/' }, { label: 'Your files', href: '/files' }, { label: row.name }],body: html`<h1>${row.name}</h1>${rules.isPreviewable(row.content_type) ? html`<figure class="file-preview"><img src="/files/${row.id}/preview?w=640" alt="${row.name}" decoding="async"></figure>` : ''}${table(['Size', 'Type', 'SHA-256', 'Added'], [[fileSize(row), row.content_type, row.sha256 || '—', time(row.created_at)]])}--<p><a class="sc-btn sc-primary" href="/files/${row.id}/download">Download</a></p>++<p><a class="sc-btn sc-primary" href="/files/${row.id}/download">Download</a> ${raw(shareButton({ url: `${config.baseUrl}/files/${row.id}`, title: row.name, text: `${row.name} — a file on ${SITE_NAME}.`, image: rules.isPreviewable(row.content_type) ? `${config.baseUrl}/files/${row.id}/preview?w=640` : '' }))}</p><section aria-labelledby="h-share"><h2 id="h-share">Make a share link</h2><form method="post" action="/files/${row.id}/share"><div class="field"><label for="hours">Expires in</label><select id="hours" name="hours">${rules.EXPIRY_CHOICES.map((c) => html`<option value="${c.hours}"${c.hours === rules.EXPIRY_DEFAULT_HOURS ? raw(' selected') : ''}>${c.label}</option>`)}</select></div>@@@ -604,12 +605,13 @@ ${shares.length ? html`<h3>Its links</h3><ul class="file-list">${shares.map((sh)if (!file) return shareGone(req, res, share);return page(req, res, {title: `${file.name} · shared by ${share.owner_username || 'an OpenVibe member'}`,++ styles: ['kit.css'],crumbs: [{ label: 'Home', href: '/' }, { label: 'Shared file' }],body: html`<h1>${file.name}</h1>${notice(`Shared ${share.public ? 'publicly' : 'with you'} by ${share.owner_username ? `@${share.owner_username}` : 'an OpenVibe member'}.`, 'info')}${rules.isPreviewable(file.content_type) ? html`<figure class="file-preview"><img src="/s/${share.slug}/preview?w=640" alt="${file.name}" decoding="async"></figure>` : ''}${table(['Size', 'Type', 'Expires', 'Downloads'], [[fileSize(file), file.content_type, time(share.expires_at), String(Number(share.downloads) || 0)]])}--<p><a class="sc-btn sc-primary" href="/s/${share.slug}/download">Download</a></p>++<p><a class="sc-btn sc-primary" href="/s/${share.slug}/download">Download</a> ${raw(shareButton({ url: `${config.baseUrl}/s/${share.slug}`, title: file.name, text: `${file.name} — shared by ${share.owner_username ? `@${share.owner_username}` : 'an OpenVibe member'} on ${SITE_NAME}.`, image: rules.isPreviewable(file.content_type) ? `${config.baseUrl}/s/${share.slug}/preview?w=640` : '' }))}</p>${mine ? '' : reportForm(share)}<p class="small muted">${REPORT_NOTE}</p>`,});@@@ -618,10 +620,11 @@ ${mine ? '' : reportForm(share)}const folder = await drive.ownFolder(share.owner, share.folder_id);return page(req, res, {title: `${folder ? folder.name : 'A folder'} · shared by ${share.owner_username || 'an OpenVibe member'}`,++ styles: ['kit.css'],crumbs: [{ label: 'Home', href: '/' }, { label: 'Shared folder' }],body: html`<h1>${folder ? folder.name : 'A folder'}</h1>${notice(`Shared ${share.public ? 'publicly' : 'with you'} by ${share.owner_username ? `@${share.owner_username}` : 'an OpenVibe member'}.`, 'info')}--<p class="small muted">${files.length} file${files.length === 1 ? '' : 's'} · expires ${time(share.expires_at)}</p>++<p class="small muted">${files.length} file${files.length === 1 ? '' : 's'} · expires ${time(share.expires_at)} ${raw(shareButton({ url: `${config.baseUrl}/s/${share.slug}`, title: folder ? folder.name : 'A shared folder', text: `A folder shared by ${share.owner_username ? `@${share.owner_username}` : 'an OpenVibe member'} on ${SITE_NAME}.` }))}</p>${files.length ? html`<ul class="file-list">${files.map((f) => html`<li class="file"><span class="file-main"><span class="file-name">${f.name}</span><span class="file-meta small muted">${fileSize(f)} · ${f.content_type}</span></span><a class="sc-btn" href="/s/${share.slug}/f/${f.id}/download">Download</a></li>`)}</ul>` : html`<p class="muted">The folder is empty.</p>`}${mine ? '' : reportForm(share)}`,});@@@ -32,7 +32,7 @@ const { asyncRouter } = require('../http/router');const { sameOrigin } = require('../http/principal');const { boundaryOf, readFormData } = require('../http/form-upload');const { formatBytes } = require('../drive/rules');--const { html, raw, notice, time } = require('../render/html');++const { html, raw, notice, time, shareButton } = require('../render/html');const labelsUi = require('../render/labels-ui');const { ACCESS_COOKIE, REFRESH_COOKIE } = require('../auth/sso');const { send, assetVersion, SITE_NAME } = require('./layout');@@@ -356,6 +356,7 @@ ${hidden ? notice(row.state === 'hidden' ? 'This picture is hidden while staff l${labelsUi.chips(labelsUi.inForce(row))}<figure class="ovp-figure"${labelsUi.attrs(labelsUi.inForce(row), { owner: row.owner, opened: true })}><a href="/i/${row.slug}.${row.ext}"><img src="/i/${row.slug}.${row.ext}" alt="${pics.altOf(row)}" width="${row.width}" height="${row.height}" decoding="async"></a></figure><p class="ovp-meta">${Number(row.width)} × ${Number(row.height)} · ${KIND[row.ext] || row.ext}${row.animated ? ' (animated)' : ''} · ${formatBytes(row.bytes)} · uploaded ${time(row.created_at)}</p>++${raw(shareButton({ url: L.page, title, text: row.description || `${title}: a picture on ${SITE_NAME}.`, image: L.direct }))}${row.description ? html`<p class="ovp-desc">${row.description}</p>` : ''}${row.ai_caption && row.alt_source === 'ai' ? html`<p class="ovp-ai"><span class="ovp-ai-label">Described by AI</span> ${row.ai_caption}</p>` : ''}</article>@@@ -21,7 +21,44 @@ function render(v) {return esc(v);}++const kit = require('openvibe-shared/kit');++const code = (s) => html`<code>${s}</code>`;++++/**++ * The Share button, built by the shared kit (openvibe-shared 4.114.0): the same quiet button with the same share++ * icon on every OpenVibe site. The navbar fetches the sheet the first time the button is pressed.++ *++ * url: the page's own public address — the link that is shared++ * title: what the thing is called++ * text: a line sent with it (the thing's own summary)++ * image: a picture of it (a thumbnail), for the places that take one++ * embed: an embeddable player address, for the places that take one++ * timeFrom: the player whose moment can be shared (a selector), so the sheet can offer "Start at …"++ */++function shareButton({ title = '', url = '', text = '', image = '', embed = '', timeFrom = null, small = false }) {++ const line = String(text || '').trim().replace(/\s+/g, ' ').slice(0, 240);++ const button = kit.share({ title, url, text: line, image, embed, small });++ return timeFrom ? button.replace('data-ov-share-sheet', `data-ov-share-sheet data-time-from="${esc(timeFrom)}"`) : button;++}++++/**++ * The Share button: the one share sheet every OpenVibe site opens (openvibe-shared 4.114.0). The navbar fetches++ * the sheet the first time the button is pressed, so the button itself only says what is being shared:++ *++ * url: the page's own public address — the link that is shared++ * title: what the thing is called++ * text: a line sent with it (the thing's own summary)++ * image: a picture of it (a thumbnail), for the places that take one++ * timeFrom: the player whose moment can be shared (a selector), so the sheet can offer "Start at …"++ *++ * Nothing is stored and no clip is made: a moment is the page's own address with a time on it.++ */++function shareButton({ url = '', title = '', text = '', image = '', timeFrom = null, cls = '', label = 'Share', icon = 'fa-solid fa-share-nodes' }) {++ const line = String(text || '').trim().replace(/\s+/g, ' ');++ const summary = line.length > 240 ? `${line.slice(0, 237).trimEnd()}…` : line;++ return html`<button type="button" class="${cls}" data-ov-share-sheet${url ? html` data-url="${url}"` : ''}${title ? html` data-title="${title}"` : ''}${summary ? html` data-text="${summary}"` : ''}${image ? html` data-image="${image}"` : ''}${timeFrom ? html` data-time-from="${timeFrom}"` : ''}><i class="${icon}" aria-hidden="true"></i> ${label}</button>`;++}const time = (iso) => (iso ? html`<time datetime="${iso}">${String(iso).replace('T', ' ').replace(/\.\d+Z$/, ' UTC').replace(/Z$/, ' UTC')}</time>` : '—');/** A <table> from headers and rows of cells (cells are already html or plain values). Each cell names its column@@@ -43,4 +80,4 @@ const notice = (text, kind = 'info') => html`<div class="notice ${kind}" role="sconst csrfField = (token) => html`<input type="hidden" name="csrf" value="${token}">`;const badge = (text, kind = '') => html`<span class="badge ${kind}">${text}</span>`;--module.exports = { esc, html, raw, Raw, render, code, time, table, problemBox, notice, csrfField, badge };++module.exports = { esc, html, raw, Raw, render, code, time, table, problemBox, notice, csrfField, badge, shareButton };@@@ -64,6 +64,7 @@ function renderPage(o) {${appIcon.headTags({ site: 'media-hub' })}<link rel="stylesheet" href="${asset('css/app.css')}"><link rel="stylesheet" href="${asset('css/video.css')}">++${(o.styles || []).map((name) => `<link rel="stylesheet" href="${ovServe.url(name)}">`).join('\n')}${(o.localStyles || []).map((rel) => `<link rel="stylesheet" href="${asset(rel)}">\n`).join('')}${o.player ? `<link rel="stylesheet" href="${ovServe.url('player.css')}">\n<script src="${ovServe.url('player.js')}" defer></script>\n` : ''}${(o.scripts || []).map((rel) => `<script src="${asset(rel)}" defer></script>`).join('\n')}`;const account = signedIn? html`Signed in as <strong>${viewer.displayName || viewer.username || 'you'}</strong> · <a href="/auth/logout?next=${encodeURIComponent(pathNow)}">Sign out</a>`@@@ -43,7 +43,7 @@ const { createVideoApiRoutes } = require('./api');const { createUploadApi, mountUploadPages } = require('./upload-routes');const { ACCESS_COOKIE, REFRESH_COOKIE } = require('../auth/sso');const { mountDownloads, downloadMenu } = require('./downloads');--const { actionMenu, menuLinks, companion, whatItIs, sharePanel, lengthWords, dayLabel, streamDate } = require('./watch-ui');++const { actionMenu, menuLinks, companion, whatItIs, shareButton, lengthWords, dayLabel, streamDate } = require('./watch-ui');const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');const HOME_LIMIT = 10;@@@ -792,7 +792,7 @@ ${labelsUi.picker(item, { what: `the ${what}`, id: `labels-${kind}` })}r.use(express.urlencoded({ extended: false, limit: '64kb' }));// ── Uploads: /upload, /u/:id, /me/videos (server/video/upload-routes.js) ──-- const uploadCard = mountUploadPages(r, { ctx, page, notFound, signedIn, ownerOf, mustSignIn, sameSite, isStaff, visibilityOptions, visibilityLabel, durationLabel, dateLabel, httpsUrl, fileOf, hlsAttrs, grid, card, renderCaptionsVtt, renderChaptersVtt, vttTime, sendVtt, notFoundVtt, playlistForm, upNext, whatItIs, sharePanel });++ const uploadCard = mountUploadPages(r, { ctx, page, notFound, signedIn, ownerOf, mustSignIn, sameSite, isStaff, visibilityOptions, visibilityLabel, durationLabel, dateLabel, httpsUrl, fileOf, hlsAttrs, grid, card, renderCaptionsVtt, renderChaptersVtt, vttTime, sendVtt, notFoundVtt, playlistForm, upNext, whatItIs, shareButton });// ── Downloads: /v/:id/download/:file and /c/:id/download/:file (server/video/downloads.js) ──mountDownloads(r, { live, chat, ownView, asPerson, notFound, base, httpsUrl, log, fetchImpl: ctx.fetchImpl || globalThis.fetch,@@@ -1289,6 +1289,7 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovvnoindex: unlisted || ownOnly || thin,...(ownOnly ? { cache: cache.htmlHeaders({ private: true }) } : {}),title: watchTitle(vod.title || 'Stream recording', vod.username, vod.created_at),++ styles: ['kit.css'],description: about,...(ownOnly || vod.is_recording ? {} : {jsonLd: videoLd({ base, path: `/v/${id}`, item: vod, title: vod.title || 'Stream recording', description: about, chapterList: mems }),@@@ -1312,7 +1313,7 @@ ${channelName ? html`<a class="ovv-avatar" href="${localChannel || liveChannel}"<div class="ovv-byline-text">${channelName ? (localChannel ? html`<a class="ovv-channel-name" href="${localChannel}">@${channelName}</a>` : html`<span class="ovv-channel-name">@${channelName}</span>`) : ''}${metaParts.length ? html`<span class="ovv-meta">${metaParts.map((p, i) => (i === 0 ? p : [html` · `, p]))}</span>` : ''}</div></div>--<div class="ovv-actions">${manageable ? editButton('recording') : ''}${saveForm}${ownOnly ? '' : clipAction(req, id, vod)}${downloadMenu({ kind: 'vod', id, item: vod, has: { transcript: segs.length, chat: chatRows ? chatRows.rows.length + chatRows.leftOut : 0, chapters: mems.length } })}${sharePanel({ url: `${base}/v/${id}`, id, at: Number(req.query.t) || null })}${moreItems.length ? actionMenu({ icon: 'fa-solid fa-ellipsis', label: 'More', iconOnly: true, cls: 'ovv-more-menu', body: menuLinks(moreItems) }) : ''}</div>++<div class="ovv-actions">${manageable ? editButton('recording') : ''}${saveForm}${ownOnly ? '' : clipAction(req, id, vod)}${downloadMenu({ kind: 'vod', id, item: vod, has: { transcript: segs.length, chat: chatRows ? chatRows.rows.length + chatRows.leftOut : 0, chapters: mems.length } })}${raw(shareButton({ url: `${base}/v/${id}`, title: vod.title || 'Stream recording', text: about, image: httpsUrl(vod.thumbnail_url) || '', timeFrom: '.ovv-player' }))}${moreItems.length ? actionMenu({ icon: 'fa-solid fa-ellipsis', label: 'More', iconOnly: true, cls: 'ovv-more-menu', body: menuLinks(moreItems) }) : ''}</div></div>${vod.is_recording ? html`<p class="ovv-recording" role="status"><i class="fa-solid fa-circle" aria-hidden="true"></i> This recording is still in progress. <a href="${liveChannel}">Watch the live channel</a>.</p>` : ''}</div>@@@ -1413,6 +1414,7 @@ ${clips.length ? html`<section class="ovv-panel ovv-clips" aria-labelledby="h-clnoindex: unlisted || ownOnly || aiClip,...(ownOnly ? { cache: cache.htmlHeaders({ private: true }) } : {}),title: watchTitle(clip.title || 'Clip', clip.source_streamer_username, clip.created_at),++ styles: ['kit.css'],description: clip.ai_overview || factualDescription('clip', clip),...(ownOnly || aiClip ? {} : {jsonLd: videoLd({ base, path: `/c/${id}`, item: clip, title: clip.title || 'Clip', description: clip.ai_overview || factualDescription('clip', clip) || attribution, partOf: clip.vod_id ? `/v/${clip.vod_id}` : null }),@@@ -1435,7 +1437,7 @@ ${clipCredit}<div class="ovv-head-row"><div class="ovv-byline"><div class="ovv-byline-text"><span class="ovv-channel-name">${clip.auto_generated ? html`<span class="ovv-tag ovv-tag--inline">AI clip</span> ` : ''}${attribution}</span>${clip.created_at ? html`<span class="ovv-meta"><time datetime="${clip.created_at}">${dateLabel(clip.created_at)}</time>${manageable ? html` · ${visibilityBadge(clip.visibility)}` : ''}</span>` : ''}</div></div>--<div class="ovv-actions">${manageable ? editButton('clip') : ''}${fullRecording ? html`<a class="ovv-btn ovv-btn--primary" href="${fullRecording}" title="Watch the recording it was cut from, from this moment"><i class="fa-solid fa-film" aria-hidden="true"></i> Full recording</a>` : ''}${saveForm}${cutting ? '' : downloadMenu({ kind: 'clip', id, item: clip, has: { transcript: segs.length } })}${sharePanel({ url: `${base}/c/${id}`, id, at: Number(req.query.t) || null, what: 'clip' })}${moreItems.length ? actionMenu({ icon: 'fa-solid fa-ellipsis', label: 'More', iconOnly: true, cls: 'ovv-more-menu', body: menuLinks(moreItems) }) : ''}</div>++<div class="ovv-actions">${manageable ? editButton('clip') : ''}${fullRecording ? html`<a class="ovv-btn ovv-btn--primary" href="${fullRecording}" title="Watch the recording it was cut from, from this moment"><i class="fa-solid fa-film" aria-hidden="true"></i> Full recording</a>` : ''}${saveForm}${cutting ? '' : downloadMenu({ kind: 'clip', id, item: clip, has: { transcript: segs.length } })}${raw(shareButton({ url: `${base}/c/${id}`, title: clip.title || 'Clip', text: clip.ai_overview || attribution, image: httpsUrl(clip.thumbnail_url) || '', timeFrom: '.ovv-player' }))}${moreItems.length ? actionMenu({ icon: 'fa-solid fa-ellipsis', label: 'More', iconOnly: true, cls: 'ovv-more-menu', body: menuLinks(moreItems) }) : ''}</div></div></div></div>@@@ -1599,8 +1601,9 @@ ${up.html}return page(req, res, {index: true,title: `@${username}`,++ styles: ['kit.css'],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></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>` : ''}<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>` : ''}@@@ -22,7 +22,7 @@const express = require('express');const cache = require('openvibe-shared/cache-policy');const { sameOrigin } = require('../http/principal');--const { html, notice } = require('../render/html');++const { html, raw, notice } = require('../render/html');const { UploadError } = require('./uploads');const REASON_TEXT = {@@@ -131,7 +131,7 @@ function createUploadApi({ ctx, config }) {/** The pages; `h` carries the video site's helpers (page, notFound, signedIn, ownerOf, mustSignIn, sameSite, isStaff, cards). */function mountUploadPages(r, h) {-- const { ctx, page, notFound, signedIn, ownerOf, mustSignIn, sameSite, isStaff, visibilityOptions, visibilityLabel, durationLabel, dateLabel, httpsUrl, fileOf, hlsAttrs, grid, card, renderCaptionsVtt, renderChaptersVtt, vttTime, sendVtt, notFoundVtt, playlistForm, upNext, whatItIs, sharePanel } = h;++ const { ctx, page, notFound, signedIn, ownerOf, mustSignIn, sameSite, isStaff, visibilityOptions, visibilityLabel, durationLabel, dateLabel, httpsUrl, fileOf, hlsAttrs, grid, card, renderCaptionsVtt, renderChaptersVtt, vttTime, sendVtt, notFoundVtt, playlistForm, upNext, whatItIs, shareButton } = h;const up = ctx.videoUploads;const cfg = ctx.config.videoUploads;const whoOf = (req) => ({ requester: signedIn(req) ? ownerOf(req) : null, staff: isStaff(req) });@@@ -279,6 +279,7 @@ ${fresh.map((x) => html`<tr><td><a href="/u/${x.id}">${x.title}</a></td><td>${stindex: listed, noindex: !listed,...(listed ? {} : { cache: cache.htmlHeaders({ private: true }) }),title: row.title,++ styles: ['kit.css'],description: about,...(listed && thumb ? { image: thumb, ogType: 'video.other' } : {}),scripts: ready ? ['js/video.js'] : [],@@@ -299,7 +300,7 @@ ${what}${channel ? html`<a class="ovv-avatar" href="/@${channel}" aria-hidden="true" tabindex="-1">${channel.slice(0, 1).toUpperCase()}${/^[A-Za-z0-9_]{1,32}$/.test(channel) ? html`<img src="${ctx.config.networkUrl}/avatar/${channel}" alt="" loading="lazy">` : ''}</a>` : ''}<div class="ovv-byline-text">${channel ? html`<a class="ovv-channel-name" href="/@${channel}">@${channel}</a>` : ''}</div>--${saveForm || sharePanel ? html`<div class="ovv-byline-actions">${saveForm}${sharePanel ? sharePanel({ url: `${ctx.config.video.url}/u/${row.id}`, id: row.id, at: Number(req.query.t) || null }) : ''}</div>` : ''}++<div class="ovv-byline-actions">${saveForm}${raw(shareButton({ url: `${ctx.config.video.url}/u/${row.id}`, title: row.title, text: about, image: thumb || '', timeFrom: ready && vod ? '.ovv-player' : null }))}</div></div>${row.description ? html`<section class="ovv-panel ovv-description"><p>${row.description}</p></section>` : ''}${aiDone && ai.overview ? html`<section class="ovv-panel" aria-labelledby="h-ai"><h2 id="h-ai">About this video</h2><p>${ai.overview}</p><p class="ovv-fine">Written by AI from the video's sound and pictures (OpenVibe.AI, run on OpenVibe's own servers).</p>@@@ -13,12 +13,13 @@** whatItIs({ … }) the one line under a watch page's title that says what the video is: a stream recording, a clip* or an upload, when it was streamed or published, how long it is and how many people watched-- * sharePanel({ … }) the share box: the page's own link, and a link to the moment it is playing (no clip, nothing-- * stored)++ * shareButton({ … }) the Share button: the one share sheet every OpenVibe site opens (openvibe-shared++ * 4.114.0). The navbar fetches the sheet the first time the button is pressed; the button++ * itself only says what is being shared.** Times read the way a player shows them (1:57, or 1:02:05 in a video an hour or longer), not 00:01:57.*/--const { html } = require('../render/html');++const { html, shareButton } = require('../render/html');/** Seconds → "1:57", or "0:01:57" when the video runs an hour or more (every time in it then lines up). */function clockOf(seconds, long = false) {@@@ -102,29 +103,7 @@ function isoOf(v) {return String(v == null ? '' : v);}--/**-- * The share box: the page's own link, and a link to the moment the video is playing. The link is written out in a-- * field, so it can be copied without JavaScript; the buttons (copy, copy at this moment) appear when JavaScript is-- * there. Nothing is stored and no clip is made: it is the page's address with a time on it.-- *-- * url: the page's own address (with ?t= when the page was opened at a moment)-- * at: the moment in the video the page was opened at (seconds), or null-- */--function sharePanel({ url, id, at = null, what = 'video' }) {-- const field = `ovv-share-${id}`;-- const withTime = at != null && Number(at) > 0 ? `${url}?t=${Math.floor(Number(at))}` : url;-- return html`<details class="ovv-pop ovv-share" data-ovv-menu><summary class="ovv-btn" title="Share a link to this ${what}"><i class="fa-solid fa-share-nodes" aria-hidden="true"></i> Share</summary>--<div class="ovv-pop-panel" role="group" aria-label="Share this ${what}">--<p class="ovv-pop-title">Share this ${what}</p>--<label class="sr-only" for="${field}">The link to this video</label>--<input class="ovv-share-url" id="${field}" type="text" readonly value="${withTime}">--<p class="ovv-fine">A plain link: it opens this page. Put <code>?t=125</code> on the end and the video opens at 2:05 — a moment, not a clip: nothing is stored and nothing is cut.</p>--<p class="ovv-share-buttons">--<button type="button" class="ovv-btn ovv-btn--small" hidden data-ovv-share="${url}" data-ovv-share-field="${field}">Copy the link</button>--<button type="button" class="ovv-btn ovv-btn--small" hidden data-ovv-share-at="${url}" data-ovv-share-field="${field}">Copy the link at this moment</button>--</p>--</div></details>`;--}++/** A button of the action row that opens a menu or a small form. `body` is the panel's content (html). */function actionMenu({ icon, label, title = null, body, cls = '', iconOnly = false }) {@@@ -217,4 +196,4 @@ ${t.body}</section>`;}--module.exports = { clockOf, lengthWords, dayLabel, streamDate, whatItIs, sharePanel, actionMenu, menuLinks, cornerMenu, downloadsFor, companion };++module.exports = { clockOf, lengthWords, dayLabel, streamDate, whatItIs, shareButton, actionMenu, menuLinks, cornerMenu, downloadsFor, companion };@@@ -76,6 +76,15 @@ const { SAME, bytes, upload, share } = require('./helpers/drive');assert.strictEqual((await t.get(`/files/${file.id}`, { as: kim })).status, 200);});++ await check('the file page carries one Share button, with the file page\'s own address', async () => {++ const file = (await upload(t, kim, bytes(80, 'shared'), { name: 'shared.txt', contentType: 'text/plain' })).json().file;++ const r = await t.get(`/files/${file.id}`, { as: kim });++ assert.strictEqual(r.status, 200);++ const buttons = r.text.match(/<button[^>]*data-ov-share-sheet[^>]*>/g) || [];++ assert.strictEqual(buttons.length, 1, 'one Share button: ' + buttons.join(' | '));++ assert.ok(buttons[0].includes(`data-url="https://openvibe.download/files/${file.id}"`), buttons[0]);++ });++await check('everything a person typed is escaped, everywhere it is shown', async () => {const nastyName = '<script>alert("x")</script>.txt';const nastyFolder = '<img src=x onerror="alert(1)">';@@@ -24,6 +24,10 @@ const { SAME, bytes, upload, share } = require('./helpers/drive');const page = await t.get(`/s/${slug}`);assert.strictEqual(page.status, 200, page.text.slice(0, 200));assert.ok(page.text.includes('notes.txt') && page.text.includes('Shared publicly'));++ // One Share button, with the share page's own address.++ const buttons = page.text.match(/<button[^>]*data-ov-share-sheet[^>]*>/g) || [];++ assert.strictEqual(buttons.length, 1, 'one Share button on the share page: ' + buttons.join(' | '));++ assert.ok(buttons[0].includes(`data-url="https://openvibe.download/s/${slug}"`), buttons[0]);const dl = await t.get(`/s/${slug}/download`);assert.strictEqual(dl.status, 200);assert.ok(dl.buffer.equals(bytes(500, 'public')), 'the file, to anyone with the link');@@@ -150,6 +150,11 @@ function multipart(fields, file) {assert.ok(page.text.includes(`content="https://openvibe.pics${direct}"`), 'og:image is the picture');assert.ok(page.text.includes('Report this picture'));assert.ok(!page.text.includes('Delete this picture'), 'no owner tools for a visitor');++ // One Share button: the picture's own page, with the picture as its image.++ const buttons = page.text.match(/<button[^>]*data-ov-share-sheet[^>]*>/g) || [];++ assert.strictEqual(buttons.length, 1, 'one Share button: ' + buttons.join(' | '));++ assert.ok(buttons[0].includes(`data-url="https://openvibe.pics/i/${slug}"`), buttons[0]);++ assert.ok(buttons[0].includes(`data-image="https://openvibe.pics${direct}"`), buttons[0]);assert.ok((await t.get(`/i/${slug}`, { headers: PICS, as: kim })).text.includes('Delete this picture'));});@@@ -158,7 +158,7 @@ function unzip(buf) {assert.ok(!r.text.includes('ovv-panel-dl'), 'no line of links under a panel any more');// The layout: tabs beside the player, one action row, the stream's parts in About.assert.strictEqual((r.text.match(/role="tab"/g) || []).length, 3, 'Chat, Chapters, Transcript');-- assert.ok(r.text.includes('data-ovv-share="https://openvibe.video/v/6026"') && r.text.includes('aria-label="More"'), 'Share and More');++ assert.ok(r.text.includes('data-ov-share-sheet data-url="https://openvibe.video/v/6026"') && r.text.includes('aria-label="More"'), 'Share and More');assert.ok(r.text.includes('<a href="/v/6025">Part 1</a>') && r.text.includes('<b>Part 2 (this one)</b>'), 'the other part of the stream');// Chat replay sits in the part's own time: 12 s in, not 721 s.assert.ok(r.text.includes('data-ovv-at="12"') && !r.text.includes('in part one'), 'chat in part 2\'s time');@@@ -175,22 +175,24 @@ function startLive() {assert.ok(!line.includes('A long recording'), 'no title from a recording we cannot see');});-- await check('outcome 3: the page carries a plain link, and says how to make a link to a moment', async () => {++ await check('outcome 3: the page carries one Share button, with the page\'s own link', async () => {const r = await get('/v/501');-- const field = /<input class="ovv-share-url"[^>]*value="([^"]*)"/.exec(r.text);-- assert.ok(field, 'the link is written out on the page');-- assert.strictEqual(field[1], 'https://openvibe.video/v/501');-- const panel = r.text.slice(r.text.indexOf('ovv-share'), r.text.indexOf('</details>', r.text.indexOf('ovv-share')));-- assert.ok(panel.includes('?t=125'), 'the panel says how to link to a moment: ' + panel);-- assert.ok(panel.includes('nothing is stored'), 'a moment is not a clip: ' + panel);-- assert.ok(panel.includes('data-ovv-share-at="https://openvibe.video/v/501"'), 'the button that copies the link at the moment it plays');-- // The page opened at a moment: the field shows that link.-- const at = await get('/v/501?t=125');-- assert.strictEqual(/<input class="ovv-share-url"[^>]*value="([^"]*)"/.exec(at.text)[1], 'https://openvibe.video/v/501?t=125');-- // An upload's watch page carries the same plain link.++ const buttons = r.text.match(/<button[^>]*data-ov-share-sheet[^>]*>/g) || [];++ assert.strictEqual(buttons.length, 1, 'one Share button: ' + buttons.join(' | '));++ assert.ok(buttons[0].includes('data-url="https://openvibe.video/v/501"'), buttons[0]);++ assert.ok(buttons[0].includes('data-time-from=".ovv-player"'), 'the sheet can share the moment it plays: ' + buttons[0]);++ assert.ok(!r.text.includes('data-ovv-share'), 'the old share box is gone');++ // A clip's page carries the same button, with the clip's own link.++ const clip = await get('/c/602');++ const clipButtons = clip.text.match(/<button[^>]*data-ov-share-sheet[^>]*>/g) || [];++ assert.strictEqual(clipButtons.length, 1, 'one Share button on the clip page: ' + clipButtons.join(' | '));++ assert.ok(clipButtons[0].includes('data-url="https://openvibe.video/c/602"'), clipButtons[0]);++ // An upload's watch page carries the same button.const up = await get('/u/vup_01J0000000000000000000000B');assert.strictEqual(up.status, 200);-- assert.ok(/<input class="ovv-share-url"[^>]*value="https:\/\/openvibe\.video\/u\/vup_01J0000000000000000000000B"/.test(up.text), 'the upload page has the link too');++ const upButtons = up.text.match(/<button[^>]*data-ov-share-sheet[^>]*>/g) || [];++ assert.strictEqual(upButtons.length, 1, 'one Share button on the upload page: ' + upButtons.join(' | '));++ assert.ok(upButtons[0].includes('data-url="https://openvibe.video/u/vup_01J0000000000000000000000B"'), upButtons[0]);});await check('outcome 7: every export says what it is for, and the transcript says it was written by AI', async () => {@@@ -430,6 +430,10 @@ function countOccurrences(haystack, needle) {assert.ok(r.text.includes('Clips'));assert.ok(r.text.includes('/c/201'));assert.match(r.text, /<img src="[^"]*\/avatar\/streamer" alt="" loading="lazy">/, 'the person\'s picture over the letter');++ // One Share button, with the channel page's own public address.++ const buttons = r.text.match(/<button[^>]*data-ov-share-sheet[^>]*>/g) || [];++ assert.strictEqual(buttons.length, 1, 'one Share button: ' + buttons.join(' | '));++ assert.ok(buttons[0].includes('data-url="https://openvibe.video/@streamer"'), buttons[0]);});await check('an unknown /api path on the video host is a JSON 404, not a page', async () => {
@@ -348,10 +348,6 @@ a.ovv-channel-name:hover { text-decoration: underline; }
@@ -348,10 +348,6 @@ a.ovv-channel-name:hover { text-decoration: underline; }
/* A clip's credit: the recording it was cut from, and a link back to it. */
/* A clip's credit: the recording it was cut from, and a link back to it. */
.ovv-source { margin: 0 0 10px; font-size: .88rem; color: var(--ovv-dim); overflow-wrap: anywhere; }
.ovv-source { margin: 0 0 10px; font-size: .88rem; color: var(--ovv-dim); overflow-wrap: anywhere; }
.ovv-source i { color: var(--ovv-faint); }
.ovv-source i { color: var(--ovv-faint); }
-/* The share box: the page's own link, and a link to the moment it is playing (a moment, not a clip). */
-.ovv-share-url { width: 100%; box-sizing: border-box; padding: 8px 10px; border: 1px solid var(--ovv-line-strong); border-radius: 8px; background: var(--bg-tertiary, #16223a); color: inherit; font-size: .82rem; }
-.ovv-share-buttons { display: flex; flex-wrap: wrap; gap: 8px; margin: 10px 0 0; }
-
/* A button that opens a small menu or form (Save, Clip, More): a <details>; video.js closes it on a click elsewhere. */
/* A button that opens a small menu or form (Save, Clip, More): a <details>; video.js closes it on a click elsewhere. */
.ovv-pop { position: relative; }
.ovv-pop { position: relative; }
.ovv-pop > summary { list-style: none; cursor: pointer; }
.ovv-pop > summary { list-style: none; cursor: pointer; }
@@ -242,60 +242,6 @@
@@ -242,60 +242,6 @@
if (window.location.hash.length > 1) open(window.location.hash.slice(1));
if (window.location.hash.length > 1) open(window.location.hash.slice(1));
}
}
- // Share: the page's own link, and the link to the moment it is playing. The link is written in a field on the page,
- // so it can be copied without JavaScript; these buttons (copy, copy at this moment) appear when JavaScript is there.
- function share(video) {
- const title = (document.querySelector('.ovv-title') || {}).textContent || document.title;
- const fieldOf = function (b) {
- const id = b.getAttribute('data-ovv-share-field');
- return id ? document.getElementById(id) : null;
- };
- const momentUrl = function (url) {
- try {
- const u = new URL(url);
- if (video && video.currentTime > 1 && !video.ended) u.searchParams.set('t', String(Math.floor(video.currentTime)));
- return u.toString();
- } catch (e) { return url; }
- };
- const copied = function (b, url) {
- const label = b.innerHTML;
- const field = fieldOf(b);
- if (field) field.value = url;
- const said = function (text) {
- b.innerHTML = text;
- setTimeout(function () { b.innerHTML = label; }, 2000);
- };
- if (!navigator.clipboard || !navigator.clipboard.writeText) {
- // No clipboard to write to: the field is selected, so the reader can copy it by hand.
- if (field) { field.focus(); field.select(); }
- said('<i class="fa-solid fa-check" aria-hidden="true"></i> Link ready to copy');
- return;
- }
- navigator.clipboard.writeText(url).then(function () {
- said('<i class="fa-solid fa-check" aria-hidden="true"></i> Link copied');
- }, function () { window.prompt('Copy this link', url); });
- };
- document.querySelectorAll('[data-ovv-share]').forEach(function (b) {
- b.hidden = false;
- b.addEventListener('click', function () {
- const url = b.getAttribute('data-ovv-share');
- const field = fieldOf(b);
- if (field) field.value = url;
- if (navigator.share && window.matchMedia('(hover: none)').matches) {
- navigator.share({ title: title, url: url }).catch(function () { /* closed */ });
- return;
- }
- copied(b, url);
- });
- });
- document.querySelectorAll('[data-ovv-share-at]').forEach(function (b) {
- b.hidden = false;
- b.addEventListener('click', function () {
- copied(b, momentUrl(b.getAttribute('data-ovv-share-at') || ''));
- });
- });
- }
-
// The transcript follows the video (the line being said is marked and kept in view inside its panel, unless the
// The transcript follows the video (the line being said is marked and kept in view inside its panel, unless the
// reader scrolled it lately or is searching), and its search box keeps only the lines that have the words.
// reader scrolled it lately or is searching), and its search box keeps only the lines that have the words.
function transcript(video) {
function transcript(video) {
@@ -505,7 +451,6 @@
@@ -505,7 +451,6 @@
copyButtons();
copyButtons();
tabs();
tabs();
openers();
openers();
- share(document.querySelector('video[data-ovv-video]'));
const video = document.querySelector('video[data-ovv-video]');
const video = document.querySelector('video[data-ovv-video]');
transcript(video);
transcript(video);
@@ -39,7 +39,7 @@ const { asyncRouter } = require('./router');
@@ -39,7 +39,7 @@ const { asyncRouter } = require('./router');
const { createDiscoveryRoutes, homeJsonLd, homeDescription } = require('./discovery');
const { createDiscoveryRoutes, homeJsonLd, homeDescription } = require('./discovery');
const { TWIN_PATHS } = require('../drive/agent-pages');
const { TWIN_PATHS } = require('../drive/agent-pages');
const { sameOrigin } = require('./principal');
const { sameOrigin } = require('./principal');
-const { html, raw, table, notice, time, badge } = require('../render/html');
+const { html, raw, table, notice, time, badge, shareButton } = require('../render/html');
const { send } = require('../render/layout');
const { send } = require('../render/layout');
const rules = require('../drive/rules');
const rules = require('../drive/rules');
const { isStaff } = require('../drive/staff');
const { isStaff } = require('../drive/staff');
@@ -325,11 +325,12 @@ ${shares.length ? html`<ul class="file-list">${shares.map((sh) => html`<li class
@@ -325,11 +325,12 @@ ${shares.length ? html`<ul class="file-list">${shares.map((sh) => html`<li class
const shares = (await drive.listShares(owner)).filter((sh) => sh.file_id === row.id);
const shares = (await drive.listShares(owner)).filter((sh) => sh.file_id === row.id);
return page(req, res, {
return page(req, res, {
title: row.name,
title: row.name,
+ styles: ['kit.css'],
crumbs: [{ label: 'Home', href: '/' }, { label: 'Your files', href: '/files' }, { label: row.name }],
crumbs: [{ label: 'Home', href: '/' }, { label: 'Your files', href: '/files' }, { label: row.name }],
body: html`<h1>${row.name}</h1>
body: html`<h1>${row.name}</h1>
${rules.isPreviewable(row.content_type) ? html`<figure class="file-preview"><img src="/files/${row.id}/preview?w=640" alt="${row.name}" decoding="async"></figure>` : ''}
${rules.isPreviewable(row.content_type) ? html`<figure class="file-preview"><img src="/files/${row.id}/preview?w=640" alt="${row.name}" decoding="async"></figure>` : ''}
${table(['Size', 'Type', 'SHA-256', 'Added'], [[fileSize(row), row.content_type, row.sha256 || '—', time(row.created_at)]])}
${table(['Size', 'Type', 'SHA-256', 'Added'], [[fileSize(row), row.content_type, row.sha256 || '—', time(row.created_at)]])}
-<p><a class="sc-btn sc-primary" href="/files/${row.id}/download">Download</a></p>
+<p><a class="sc-btn sc-primary" href="/files/${row.id}/download">Download</a> ${raw(shareButton({ url: `${config.baseUrl}/files/${row.id}`, title: row.name, text: `${row.name} — a file on ${SITE_NAME}.`, image: rules.isPreviewable(row.content_type) ? `${config.baseUrl}/files/${row.id}/preview?w=640` : '' }))}</p>
<section aria-labelledby="h-share"><h2 id="h-share">Make a share link</h2>
<section aria-labelledby="h-share"><h2 id="h-share">Make a share link</h2>
<form method="post" action="/files/${row.id}/share">
<form method="post" action="/files/${row.id}/share">
<div class="field"><label for="hours">Expires in</label><select id="hours" name="hours">${rules.EXPIRY_CHOICES.map((c) => html`<option value="${c.hours}"${c.hours === rules.EXPIRY_DEFAULT_HOURS ? raw(' selected') : ''}>${c.label}</option>`)}</select></div>
<div class="field"><label for="hours">Expires in</label><select id="hours" name="hours">${rules.EXPIRY_CHOICES.map((c) => html`<option value="${c.hours}"${c.hours === rules.EXPIRY_DEFAULT_HOURS ? raw(' selected') : ''}>${c.label}</option>`)}</select></div>
@@ -604,12 +605,13 @@ ${shares.length ? html`<h3>Its links</h3><ul class="file-list">${shares.map((sh)
@@ -604,12 +605,13 @@ ${shares.length ? html`<h3>Its links</h3><ul class="file-list">${shares.map((sh)
if (!file) return shareGone(req, res, share);
if (!file) return shareGone(req, res, share);
return page(req, res, {
return page(req, res, {
title: `${file.name} · shared by ${share.owner_username || 'an OpenVibe member'}`,
title: `${file.name} · shared by ${share.owner_username || 'an OpenVibe member'}`,
+ styles: ['kit.css'],
crumbs: [{ label: 'Home', href: '/' }, { label: 'Shared file' }],
crumbs: [{ label: 'Home', href: '/' }, { label: 'Shared file' }],
body: html`<h1>${file.name}</h1>
body: html`<h1>${file.name}</h1>
${notice(`Shared ${share.public ? 'publicly' : 'with you'} by ${share.owner_username ? `@${share.owner_username}` : 'an OpenVibe member'}.`, 'info')}
${notice(`Shared ${share.public ? 'publicly' : 'with you'} by ${share.owner_username ? `@${share.owner_username}` : 'an OpenVibe member'}.`, 'info')}
${rules.isPreviewable(file.content_type) ? html`<figure class="file-preview"><img src="/s/${share.slug}/preview?w=640" alt="${file.name}" decoding="async"></figure>` : ''}
${rules.isPreviewable(file.content_type) ? html`<figure class="file-preview"><img src="/s/${share.slug}/preview?w=640" alt="${file.name}" decoding="async"></figure>` : ''}
${table(['Size', 'Type', 'Expires', 'Downloads'], [[fileSize(file), file.content_type, time(share.expires_at), String(Number(share.downloads) || 0)]])}
${table(['Size', 'Type', 'Expires', 'Downloads'], [[fileSize(file), file.content_type, time(share.expires_at), String(Number(share.downloads) || 0)]])}
-<p><a class="sc-btn sc-primary" href="/s/${share.slug}/download">Download</a></p>
+<p><a class="sc-btn sc-primary" href="/s/${share.slug}/download">Download</a> ${raw(shareButton({ url: `${config.baseUrl}/s/${share.slug}`, title: file.name, text: `${file.name} — shared by ${share.owner_username ? `@${share.owner_username}` : 'an OpenVibe member'} on ${SITE_NAME}.`, image: rules.isPreviewable(file.content_type) ? `${config.baseUrl}/s/${share.slug}/preview?w=640` : '' }))}</p>
${mine ? '' : reportForm(share)}
${mine ? '' : reportForm(share)}
<p class="small muted">${REPORT_NOTE}</p>`,
<p class="small muted">${REPORT_NOTE}</p>`,
});
});
@@ -618,10 +620,11 @@ ${mine ? '' : reportForm(share)}
@@ -618,10 +620,11 @@ ${mine ? '' : reportForm(share)}
const folder = await drive.ownFolder(share.owner, share.folder_id);
const folder = await drive.ownFolder(share.owner, share.folder_id);
return page(req, res, {
return page(req, res, {
title: `${folder ? folder.name : 'A folder'} · shared by ${share.owner_username || 'an OpenVibe member'}`,
title: `${folder ? folder.name : 'A folder'} · shared by ${share.owner_username || 'an OpenVibe member'}`,
+ styles: ['kit.css'],
crumbs: [{ label: 'Home', href: '/' }, { label: 'Shared folder' }],
crumbs: [{ label: 'Home', href: '/' }, { label: 'Shared folder' }],
body: html`<h1>${folder ? folder.name : 'A folder'}</h1>
body: html`<h1>${folder ? folder.name : 'A folder'}</h1>
${notice(`Shared ${share.public ? 'publicly' : 'with you'} by ${share.owner_username ? `@${share.owner_username}` : 'an OpenVibe member'}.`, 'info')}
${notice(`Shared ${share.public ? 'publicly' : 'with you'} by ${share.owner_username ? `@${share.owner_username}` : 'an OpenVibe member'}.`, 'info')}
-<p class="small muted">${files.length} file${files.length === 1 ? '' : 's'} · expires ${time(share.expires_at)}</p>
+<p class="small muted">${files.length} file${files.length === 1 ? '' : 's'} · expires ${time(share.expires_at)} ${raw(shareButton({ url: `${config.baseUrl}/s/${share.slug}`, title: folder ? folder.name : 'A shared folder', text: `A folder shared by ${share.owner_username ? `@${share.owner_username}` : 'an OpenVibe member'} on ${SITE_NAME}.` }))}</p>
${files.length ? html`<ul class="file-list">${files.map((f) => html`<li class="file"><span class="file-main"><span class="file-name">${f.name}</span><span class="file-meta small muted">${fileSize(f)} · ${f.content_type}</span></span><a class="sc-btn" href="/s/${share.slug}/f/${f.id}/download">Download</a></li>`)}</ul>` : html`<p class="muted">The folder is empty.</p>`}
${files.length ? html`<ul class="file-list">${files.map((f) => html`<li class="file"><span class="file-main"><span class="file-name">${f.name}</span><span class="file-meta small muted">${fileSize(f)} · ${f.content_type}</span></span><a class="sc-btn" href="/s/${share.slug}/f/${f.id}/download">Download</a></li>`)}</ul>` : html`<p class="muted">The folder is empty.</p>`}
${mine ? '' : reportForm(share)}`,
${mine ? '' : reportForm(share)}`,
});
});
@@ -32,7 +32,7 @@ const { asyncRouter } = require('../http/router');
@@ -32,7 +32,7 @@ const { asyncRouter } = require('../http/router');
const { sameOrigin } = require('../http/principal');
const { sameOrigin } = require('../http/principal');
const { boundaryOf, readFormData } = require('../http/form-upload');
const { boundaryOf, readFormData } = require('../http/form-upload');
const { formatBytes } = require('../drive/rules');
const { formatBytes } = require('../drive/rules');
-const { html, raw, notice, time } = require('../render/html');
+const { html, raw, notice, time, shareButton } = require('../render/html');
const labelsUi = require('../render/labels-ui');
const labelsUi = require('../render/labels-ui');
const { ACCESS_COOKIE, REFRESH_COOKIE } = require('../auth/sso');
const { ACCESS_COOKIE, REFRESH_COOKIE } = require('../auth/sso');
const { send, assetVersion, SITE_NAME } = require('./layout');
const { send, assetVersion, SITE_NAME } = require('./layout');
@@ -356,6 +356,7 @@ ${hidden ? notice(row.state === 'hidden' ? 'This picture is hidden while staff l
@@ -356,6 +356,7 @@ ${hidden ? notice(row.state === 'hidden' ? 'This picture is hidden while staff l
${labelsUi.chips(labelsUi.inForce(row))}
${labelsUi.chips(labelsUi.inForce(row))}
<figure class="ovp-figure"${labelsUi.attrs(labelsUi.inForce(row), { owner: row.owner, opened: true })}><a href="/i/${row.slug}.${row.ext}"><img src="/i/${row.slug}.${row.ext}" alt="${pics.altOf(row)}" width="${row.width}" height="${row.height}" decoding="async"></a></figure>
<figure class="ovp-figure"${labelsUi.attrs(labelsUi.inForce(row), { owner: row.owner, opened: true })}><a href="/i/${row.slug}.${row.ext}"><img src="/i/${row.slug}.${row.ext}" alt="${pics.altOf(row)}" width="${row.width}" height="${row.height}" decoding="async"></a></figure>
<p class="ovp-meta">${Number(row.width)} × ${Number(row.height)} · ${KIND[row.ext] || row.ext}${row.animated ? ' (animated)' : ''} · ${formatBytes(row.bytes)} · uploaded ${time(row.created_at)}</p>
<p class="ovp-meta">${Number(row.width)} × ${Number(row.height)} · ${KIND[row.ext] || row.ext}${row.animated ? ' (animated)' : ''} · ${formatBytes(row.bytes)} · uploaded ${time(row.created_at)}</p>
+${raw(shareButton({ url: L.page, title, text: row.description || `${title}: a picture on ${SITE_NAME}.`, image: L.direct }))}
${row.description ? html`<p class="ovp-desc">${row.description}</p>` : ''}
${row.description ? html`<p class="ovp-desc">${row.description}</p>` : ''}
${row.ai_caption && row.alt_source === 'ai' ? html`<p class="ovp-ai"><span class="ovp-ai-label">Described by AI</span> ${row.ai_caption}</p>` : ''}
${row.ai_caption && row.alt_source === 'ai' ? html`<p class="ovp-ai"><span class="ovp-ai-label">Described by AI</span> ${row.ai_caption}</p>` : ''}
</article>
</article>
@@ -21,7 +21,44 @@ function render(v) {
@@ -21,7 +21,44 @@ function render(v) {
return esc(v);
return esc(v);
}
}
+const kit = require('openvibe-shared/kit');
+
const code = (s) => html`<code>${s}</code>`;
const code = (s) => html`<code>${s}</code>`;
+
+/**
+ * The Share button, built by the shared kit (openvibe-shared 4.114.0): the same quiet button with the same share
+ * icon on every OpenVibe site. The navbar fetches the sheet the first time the button is pressed.
+ *
+ * url: the page's own public address — the link that is shared
+ * title: what the thing is called
+ * text: a line sent with it (the thing's own summary)
+ * image: a picture of it (a thumbnail), for the places that take one
+ * embed: an embeddable player address, for the places that take one
+ * timeFrom: the player whose moment can be shared (a selector), so the sheet can offer "Start at …"
+ */
+function shareButton({ title = '', url = '', text = '', image = '', embed = '', timeFrom = null, small = false }) {
+ const line = String(text || '').trim().replace(/\s+/g, ' ').slice(0, 240);
+ const button = kit.share({ title, url, text: line, image, embed, small });
+ return timeFrom ? button.replace('data-ov-share-sheet', `data-ov-share-sheet data-time-from="${esc(timeFrom)}"`) : button;
+}
+
+/**
+ * The Share button: the one share sheet every OpenVibe site opens (openvibe-shared 4.114.0). The navbar fetches
+ * the sheet the first time the button is pressed, so the button itself only says what is being shared:
+ *
+ * url: the page's own public address — the link that is shared
+ * title: what the thing is called
+ * text: a line sent with it (the thing's own summary)
+ * image: a picture of it (a thumbnail), for the places that take one
+ * timeFrom: the player whose moment can be shared (a selector), so the sheet can offer "Start at …"
+ *
+ * Nothing is stored and no clip is made: a moment is the page's own address with a time on it.
+ */
+function shareButton({ url = '', title = '', text = '', image = '', timeFrom = null, cls = '', label = 'Share', icon = 'fa-solid fa-share-nodes' }) {
+ const line = String(text || '').trim().replace(/\s+/g, ' ');
+ const summary = line.length > 240 ? `${line.slice(0, 237).trimEnd()}…` : line;
+ return html`<button type="button" class="${cls}" data-ov-share-sheet${url ? html` data-url="${url}"` : ''}${title ? html` data-title="${title}"` : ''}${summary ? html` data-text="${summary}"` : ''}${image ? html` data-image="${image}"` : ''}${timeFrom ? html` data-time-from="${timeFrom}"` : ''}><i class="${icon}" aria-hidden="true"></i> ${label}</button>`;
+}
const time = (iso) => (iso ? html`<time datetime="${iso}">${String(iso).replace('T', ' ').replace(/\.\d+Z$/, ' UTC').replace(/Z$/, ' UTC')}</time>` : '—');
const time = (iso) => (iso ? html`<time datetime="${iso}">${String(iso).replace('T', ' ').replace(/\.\d+Z$/, ' UTC').replace(/Z$/, ' UTC')}</time>` : '—');
/** A <table> from headers and rows of cells (cells are already html or plain values). Each cell names its column
/** A <table> from headers and rows of cells (cells are already html or plain values). Each cell names its column
@@ -43,4 +80,4 @@ const notice = (text, kind = 'info') => html`<div class="notice ${kind}" role="s
@@ -43,4 +80,4 @@ const notice = (text, kind = 'info') => html`<div class="notice ${kind}" role="s
const csrfField = (token) => html`<input type="hidden" name="csrf" value="${token}">`;
const csrfField = (token) => html`<input type="hidden" name="csrf" value="${token}">`;
const badge = (text, kind = '') => html`<span class="badge ${kind}">${text}</span>`;
const badge = (text, kind = '') => html`<span class="badge ${kind}">${text}</span>`;
-module.exports = { esc, html, raw, Raw, render, code, time, table, problemBox, notice, csrfField, badge };
+module.exports = { esc, html, raw, Raw, render, code, time, table, problemBox, notice, csrfField, badge, shareButton };
@@ -64,6 +64,7 @@ function renderPage(o) {
@@ -64,6 +64,7 @@ function renderPage(o) {
${appIcon.headTags({ site: 'media-hub' })}
${appIcon.headTags({ site: 'media-hub' })}
<link rel="stylesheet" href="${asset('css/app.css')}">
<link rel="stylesheet" href="${asset('css/app.css')}">
<link rel="stylesheet" href="${asset('css/video.css')}">
<link rel="stylesheet" href="${asset('css/video.css')}">
+${(o.styles || []).map((name) => `<link rel="stylesheet" href="${ovServe.url(name)}">`).join('\n')}
${(o.localStyles || []).map((rel) => `<link rel="stylesheet" href="${asset(rel)}">\n`).join('')}${o.player ? `<link rel="stylesheet" href="${ovServe.url('player.css')}">\n<script src="${ovServe.url('player.js')}" defer></script>\n` : ''}${(o.scripts || []).map((rel) => `<script src="${asset(rel)}" defer></script>`).join('\n')}`;
${(o.localStyles || []).map((rel) => `<link rel="stylesheet" href="${asset(rel)}">\n`).join('')}${o.player ? `<link rel="stylesheet" href="${ovServe.url('player.css')}">\n<script src="${ovServe.url('player.js')}" defer></script>\n` : ''}${(o.scripts || []).map((rel) => `<script src="${asset(rel)}" defer></script>`).join('\n')}`;
const account = signedIn
const account = signedIn
? html`Signed in as <strong>${viewer.displayName || viewer.username || 'you'}</strong> · <a href="/auth/logout?next=${encodeURIComponent(pathNow)}">Sign out</a>`
? html`Signed in as <strong>${viewer.displayName || viewer.username || 'you'}</strong> · <a href="/auth/logout?next=${encodeURIComponent(pathNow)}">Sign out</a>`
@@ -43,7 +43,7 @@ const { createVideoApiRoutes } = require('./api');
@@ -43,7 +43,7 @@ const { createVideoApiRoutes } = require('./api');
const { createUploadApi, mountUploadPages } = require('./upload-routes');
const { createUploadApi, mountUploadPages } = require('./upload-routes');
const { ACCESS_COOKIE, REFRESH_COOKIE } = require('../auth/sso');
const { ACCESS_COOKIE, REFRESH_COOKIE } = require('../auth/sso');
const { mountDownloads, downloadMenu } = require('./downloads');
const { mountDownloads, downloadMenu } = require('./downloads');
-const { actionMenu, menuLinks, companion, whatItIs, sharePanel, lengthWords, dayLabel, streamDate } = require('./watch-ui');
+const { actionMenu, menuLinks, companion, whatItIs, shareButton, lengthWords, dayLabel, streamDate } = require('./watch-ui');
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
const HOME_LIMIT = 10;
const HOME_LIMIT = 10;
@@ -792,7 +792,7 @@ ${labelsUi.picker(item, { what: `the ${what}`, id: `labels-${kind}` })}
@@ -792,7 +792,7 @@ ${labelsUi.picker(item, { what: `the ${what}`, id: `labels-${kind}` })}
r.use(express.urlencoded({ extended: false, limit: '64kb' }));
r.use(express.urlencoded({ extended: false, limit: '64kb' }));
// ── Uploads: /upload, /u/:id, /me/videos (server/video/upload-routes.js) ──
// ── Uploads: /upload, /u/:id, /me/videos (server/video/upload-routes.js) ──
- const uploadCard = mountUploadPages(r, { ctx, page, notFound, signedIn, ownerOf, mustSignIn, sameSite, isStaff, visibilityOptions, visibilityLabel, durationLabel, dateLabel, httpsUrl, fileOf, hlsAttrs, grid, card, renderCaptionsVtt, renderChaptersVtt, vttTime, sendVtt, notFoundVtt, playlistForm, upNext, whatItIs, sharePanel });
+ const uploadCard = mountUploadPages(r, { ctx, page, notFound, signedIn, ownerOf, mustSignIn, sameSite, isStaff, visibilityOptions, visibilityLabel, durationLabel, dateLabel, httpsUrl, fileOf, hlsAttrs, grid, card, renderCaptionsVtt, renderChaptersVtt, vttTime, sendVtt, notFoundVtt, playlistForm, upNext, whatItIs, shareButton });
// ── Downloads: /v/:id/download/:file and /c/:id/download/:file (server/video/downloads.js) ──
// ── Downloads: /v/:id/download/:file and /c/:id/download/:file (server/video/downloads.js) ──
mountDownloads(r, { live, chat, ownView, asPerson, notFound, base, httpsUrl, log, fetchImpl: ctx.fetchImpl || globalThis.fetch,
mountDownloads(r, { live, chat, ownView, asPerson, notFound, base, httpsUrl, log, fetchImpl: ctx.fetchImpl || globalThis.fetch,
@@ -1289,6 +1289,7 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovv
@@ -1289,6 +1289,7 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovv
noindex: unlisted || ownOnly || thin,
noindex: unlisted || ownOnly || thin,
...(ownOnly ? { cache: cache.htmlHeaders({ private: true }) } : {}),
...(ownOnly ? { cache: cache.htmlHeaders({ private: true }) } : {}),
title: watchTitle(vod.title || 'Stream recording', vod.username, vod.created_at),
title: watchTitle(vod.title || 'Stream recording', vod.username, vod.created_at),
+ styles: ['kit.css'],
description: about,
description: about,
...(ownOnly || vod.is_recording ? {} : {
...(ownOnly || vod.is_recording ? {} : {
jsonLd: videoLd({ base, path: `/v/${id}`, item: vod, title: vod.title || 'Stream recording', description: about, chapterList: mems }),
jsonLd: videoLd({ base, path: `/v/${id}`, item: vod, title: vod.title || 'Stream recording', description: about, chapterList: mems }),
@@ -1312,7 +1313,7 @@ ${channelName ? html`<a class="ovv-avatar" href="${localChannel || liveChannel}"
@@ -1312,7 +1313,7 @@ ${channelName ? html`<a class="ovv-avatar" href="${localChannel || liveChannel}"
<div class="ovv-byline-text">${channelName ? (localChannel ? html`<a class="ovv-channel-name" href="${localChannel}">@${channelName}</a>` : html`<span class="ovv-channel-name">@${channelName}</span>`) : ''}
<div class="ovv-byline-text">${channelName ? (localChannel ? html`<a class="ovv-channel-name" href="${localChannel}">@${channelName}</a>` : html`<span class="ovv-channel-name">@${channelName}</span>`) : ''}
${metaParts.length ? html`<span class="ovv-meta">${metaParts.map((p, i) => (i === 0 ? p : [html` · `, p]))}</span>` : ''}</div>
${metaParts.length ? html`<span class="ovv-meta">${metaParts.map((p, i) => (i === 0 ? p : [html` · `, p]))}</span>` : ''}</div>
</div>
</div>
-<div class="ovv-actions">${manageable ? editButton('recording') : ''}${saveForm}${ownOnly ? '' : clipAction(req, id, vod)}${downloadMenu({ kind: 'vod', id, item: vod, has: { transcript: segs.length, chat: chatRows ? chatRows.rows.length + chatRows.leftOut : 0, chapters: mems.length } })}${sharePanel({ url: `${base}/v/${id}`, id, at: Number(req.query.t) || null })}${moreItems.length ? actionMenu({ icon: 'fa-solid fa-ellipsis', label: 'More', iconOnly: true, cls: 'ovv-more-menu', body: menuLinks(moreItems) }) : ''}</div>
+<div class="ovv-actions">${manageable ? editButton('recording') : ''}${saveForm}${ownOnly ? '' : clipAction(req, id, vod)}${downloadMenu({ kind: 'vod', id, item: vod, has: { transcript: segs.length, chat: chatRows ? chatRows.rows.length + chatRows.leftOut : 0, chapters: mems.length } })}${raw(shareButton({ url: `${base}/v/${id}`, title: vod.title || 'Stream recording', text: about, image: httpsUrl(vod.thumbnail_url) || '', timeFrom: '.ovv-player' }))}${moreItems.length ? actionMenu({ icon: 'fa-solid fa-ellipsis', label: 'More', iconOnly: true, cls: 'ovv-more-menu', body: menuLinks(moreItems) }) : ''}</div>
</div>
</div>
${vod.is_recording ? html`<p class="ovv-recording" role="status"><i class="fa-solid fa-circle" aria-hidden="true"></i> This recording is still in progress. <a href="${liveChannel}">Watch the live channel</a>.</p>` : ''}
${vod.is_recording ? html`<p class="ovv-recording" role="status"><i class="fa-solid fa-circle" aria-hidden="true"></i> This recording is still in progress. <a href="${liveChannel}">Watch the live channel</a>.</p>` : ''}
</div>
</div>
@@ -1413,6 +1414,7 @@ ${clips.length ? html`<section class="ovv-panel ovv-clips" aria-labelledby="h-cl
@@ -1413,6 +1414,7 @@ ${clips.length ? html`<section class="ovv-panel ovv-clips" aria-labelledby="h-cl
noindex: unlisted || ownOnly || aiClip,
noindex: unlisted || ownOnly || aiClip,
...(ownOnly ? { cache: cache.htmlHeaders({ private: true }) } : {}),
...(ownOnly ? { cache: cache.htmlHeaders({ private: true }) } : {}),
title: watchTitle(clip.title || 'Clip', clip.source_streamer_username, clip.created_at),
title: watchTitle(clip.title || 'Clip', clip.source_streamer_username, clip.created_at),
+ styles: ['kit.css'],
description: clip.ai_overview || factualDescription('clip', clip),
description: clip.ai_overview || factualDescription('clip', clip),
...(ownOnly || aiClip ? {} : {
...(ownOnly || aiClip ? {} : {
jsonLd: videoLd({ base, path: `/c/${id}`, item: clip, title: clip.title || 'Clip', description: clip.ai_overview || factualDescription('clip', clip) || attribution, partOf: clip.vod_id ? `/v/${clip.vod_id}` : null }),
jsonLd: videoLd({ base, path: `/c/${id}`, item: clip, title: clip.title || 'Clip', description: clip.ai_overview || factualDescription('clip', clip) || attribution, partOf: clip.vod_id ? `/v/${clip.vod_id}` : null }),
@@ -1435,7 +1437,7 @@ ${clipCredit}
@@ -1435,7 +1437,7 @@ ${clipCredit}
<div class="ovv-head-row">
<div class="ovv-head-row">
<div class="ovv-byline"><div class="ovv-byline-text"><span class="ovv-channel-name">${clip.auto_generated ? html`<span class="ovv-tag ovv-tag--inline">AI clip</span> ` : ''}${attribution}</span>
<div class="ovv-byline"><div class="ovv-byline-text"><span class="ovv-channel-name">${clip.auto_generated ? html`<span class="ovv-tag ovv-tag--inline">AI clip</span> ` : ''}${attribution}</span>
${clip.created_at ? html`<span class="ovv-meta"><time datetime="${clip.created_at}">${dateLabel(clip.created_at)}</time>${manageable ? html` · ${visibilityBadge(clip.visibility)}` : ''}</span>` : ''}</div></div>
${clip.created_at ? html`<span class="ovv-meta"><time datetime="${clip.created_at}">${dateLabel(clip.created_at)}</time>${manageable ? html` · ${visibilityBadge(clip.visibility)}` : ''}</span>` : ''}</div></div>
-<div class="ovv-actions">${manageable ? editButton('clip') : ''}${fullRecording ? html`<a class="ovv-btn ovv-btn--primary" href="${fullRecording}" title="Watch the recording it was cut from, from this moment"><i class="fa-solid fa-film" aria-hidden="true"></i> Full recording</a>` : ''}${saveForm}${cutting ? '' : downloadMenu({ kind: 'clip', id, item: clip, has: { transcript: segs.length } })}${sharePanel({ url: `${base}/c/${id}`, id, at: Number(req.query.t) || null, what: 'clip' })}${moreItems.length ? actionMenu({ icon: 'fa-solid fa-ellipsis', label: 'More', iconOnly: true, cls: 'ovv-more-menu', body: menuLinks(moreItems) }) : ''}</div>
+<div class="ovv-actions">${manageable ? editButton('clip') : ''}${fullRecording ? html`<a class="ovv-btn ovv-btn--primary" href="${fullRecording}" title="Watch the recording it was cut from, from this moment"><i class="fa-solid fa-film" aria-hidden="true"></i> Full recording</a>` : ''}${saveForm}${cutting ? '' : downloadMenu({ kind: 'clip', id, item: clip, has: { transcript: segs.length } })}${raw(shareButton({ url: `${base}/c/${id}`, title: clip.title || 'Clip', text: clip.ai_overview || attribution, image: httpsUrl(clip.thumbnail_url) || '', timeFrom: '.ovv-player' }))}${moreItems.length ? actionMenu({ icon: 'fa-solid fa-ellipsis', label: 'More', iconOnly: true, cls: 'ovv-more-menu', body: menuLinks(moreItems) }) : ''}</div>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -1599,8 +1601,9 @@ ${up.html}
@@ -1599,8 +1601,9 @@ ${up.html}
return page(req, res, {
return page(req, res, {
index: true,
index: true,
title: `@${username}`,
title: `@${username}`,
+ styles: ['kit.css'],
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></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>
<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>` : ''}
${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>` : ''}
@@ -22,7 +22,7 @@
@@ -22,7 +22,7 @@
const express = require('express');
const express = require('express');
const cache = require('openvibe-shared/cache-policy');
const cache = require('openvibe-shared/cache-policy');
const { sameOrigin } = require('../http/principal');
const { sameOrigin } = require('../http/principal');
-const { html, notice } = require('../render/html');
+const { html, raw, notice } = require('../render/html');
const { UploadError } = require('./uploads');
const { UploadError } = require('./uploads');
const REASON_TEXT = {
const REASON_TEXT = {
@@ -131,7 +131,7 @@ function createUploadApi({ ctx, config }) {
@@ -131,7 +131,7 @@ function createUploadApi({ ctx, config }) {
/** The pages; `h` carries the video site's helpers (page, notFound, signedIn, ownerOf, mustSignIn, sameSite, isStaff, cards). */
/** The pages; `h` carries the video site's helpers (page, notFound, signedIn, ownerOf, mustSignIn, sameSite, isStaff, cards). */
function mountUploadPages(r, h) {
function mountUploadPages(r, h) {
- const { ctx, page, notFound, signedIn, ownerOf, mustSignIn, sameSite, isStaff, visibilityOptions, visibilityLabel, durationLabel, dateLabel, httpsUrl, fileOf, hlsAttrs, grid, card, renderCaptionsVtt, renderChaptersVtt, vttTime, sendVtt, notFoundVtt, playlistForm, upNext, whatItIs, sharePanel } = h;
+ const { ctx, page, notFound, signedIn, ownerOf, mustSignIn, sameSite, isStaff, visibilityOptions, visibilityLabel, durationLabel, dateLabel, httpsUrl, fileOf, hlsAttrs, grid, card, renderCaptionsVtt, renderChaptersVtt, vttTime, sendVtt, notFoundVtt, playlistForm, upNext, whatItIs, shareButton } = h;
const up = ctx.videoUploads;
const up = ctx.videoUploads;
const cfg = ctx.config.videoUploads;
const cfg = ctx.config.videoUploads;
const whoOf = (req) => ({ requester: signedIn(req) ? ownerOf(req) : null, staff: isStaff(req) });
const whoOf = (req) => ({ requester: signedIn(req) ? ownerOf(req) : null, staff: isStaff(req) });
@@ -279,6 +279,7 @@ ${fresh.map((x) => html`<tr><td><a href="/u/${x.id}">${x.title}</a></td><td>${st
@@ -279,6 +279,7 @@ ${fresh.map((x) => html`<tr><td><a href="/u/${x.id}">${x.title}</a></td><td>${st
index: listed, noindex: !listed,
index: listed, noindex: !listed,
...(listed ? {} : { cache: cache.htmlHeaders({ private: true }) }),
...(listed ? {} : { cache: cache.htmlHeaders({ private: true }) }),
title: row.title,
title: row.title,
+ styles: ['kit.css'],
description: about,
description: about,
...(listed && thumb ? { image: thumb, ogType: 'video.other' } : {}),
...(listed && thumb ? { image: thumb, ogType: 'video.other' } : {}),
scripts: ready ? ['js/video.js'] : [],
scripts: ready ? ['js/video.js'] : [],
@@ -299,7 +300,7 @@ ${what}
@@ -299,7 +300,7 @@ ${what}
${channel ? html`<a class="ovv-avatar" href="/@${channel}" aria-hidden="true" tabindex="-1">${channel.slice(0, 1).toUpperCase()}${/^[A-Za-z0-9_]{1,32}$/.test(channel) ? html`<img src="${ctx.config.networkUrl}/avatar/${channel}" alt="" loading="lazy">` : ''}</a>` : ''}
${channel ? html`<a class="ovv-avatar" href="/@${channel}" aria-hidden="true" tabindex="-1">${channel.slice(0, 1).toUpperCase()}${/^[A-Za-z0-9_]{1,32}$/.test(channel) ? html`<img src="${ctx.config.networkUrl}/avatar/${channel}" alt="" loading="lazy">` : ''}</a>` : ''}
<div class="ovv-byline-text">${channel ? html`<a class="ovv-channel-name" href="/@${channel}">@${channel}</a>` : ''}
<div class="ovv-byline-text">${channel ? html`<a class="ovv-channel-name" href="/@${channel}">@${channel}</a>` : ''}
</div>
</div>
-${saveForm || sharePanel ? html`<div class="ovv-byline-actions">${saveForm}${sharePanel ? sharePanel({ url: `${ctx.config.video.url}/u/${row.id}`, id: row.id, at: Number(req.query.t) || null }) : ''}</div>` : ''}
+<div class="ovv-byline-actions">${saveForm}${raw(shareButton({ url: `${ctx.config.video.url}/u/${row.id}`, title: row.title, text: about, image: thumb || '', timeFrom: ready && vod ? '.ovv-player' : null }))}</div>
</div>
</div>
${row.description ? html`<section class="ovv-panel ovv-description"><p>${row.description}</p></section>` : ''}
${row.description ? html`<section class="ovv-panel ovv-description"><p>${row.description}</p></section>` : ''}
${aiDone && ai.overview ? html`<section class="ovv-panel" aria-labelledby="h-ai"><h2 id="h-ai">About this video</h2><p>${ai.overview}</p><p class="ovv-fine">Written by AI from the video's sound and pictures (OpenVibe.AI, run on OpenVibe's own servers).</p>
${aiDone && ai.overview ? html`<section class="ovv-panel" aria-labelledby="h-ai"><h2 id="h-ai">About this video</h2><p>${ai.overview}</p><p class="ovv-fine">Written by AI from the video's sound and pictures (OpenVibe.AI, run on OpenVibe's own servers).</p>
@@ -13,12 +13,13 @@
@@ -13,12 +13,13 @@
*
*
* whatItIs({ … }) the one line under a watch page's title that says what the video is: a stream recording, a clip
* whatItIs({ … }) the one line under a watch page's title that says what the video is: a stream recording, a clip
* or an upload, when it was streamed or published, how long it is and how many people watched
* or an upload, when it was streamed or published, how long it is and how many people watched
- * sharePanel({ … }) the share box: the page's own link, and a link to the moment it is playing (no clip, nothing
- * stored)
+ * shareButton({ … }) the Share button: the one share sheet every OpenVibe site opens (openvibe-shared
+ * 4.114.0). The navbar fetches the sheet the first time the button is pressed; the button
+ * itself only says what is being shared.
*
*
* Times read the way a player shows them (1:57, or 1:02:05 in a video an hour or longer), not 00:01:57.
* Times read the way a player shows them (1:57, or 1:02:05 in a video an hour or longer), not 00:01:57.
*/
*/
-const { html } = require('../render/html');
+const { html, shareButton } = require('../render/html');
/** Seconds → "1:57", or "0:01:57" when the video runs an hour or more (every time in it then lines up). */
/** Seconds → "1:57", or "0:01:57" when the video runs an hour or more (every time in it then lines up). */
function clockOf(seconds, long = false) {
function clockOf(seconds, long = false) {
@@ -102,29 +103,7 @@ function isoOf(v) {
@@ -102,29 +103,7 @@ function isoOf(v) {
return String(v == null ? '' : v);
return String(v == null ? '' : v);
}
}
-/**
- * The share box: the page's own link, and a link to the moment the video is playing. The link is written out in a
- * field, so it can be copied without JavaScript; the buttons (copy, copy at this moment) appear when JavaScript is
- * there. Nothing is stored and no clip is made: it is the page's address with a time on it.
- *
- * url: the page's own address (with ?t= when the page was opened at a moment)
- * at: the moment in the video the page was opened at (seconds), or null
- */
-function sharePanel({ url, id, at = null, what = 'video' }) {
- const field = `ovv-share-${id}`;
- const withTime = at != null && Number(at) > 0 ? `${url}?t=${Math.floor(Number(at))}` : url;
- return html`<details class="ovv-pop ovv-share" data-ovv-menu><summary class="ovv-btn" title="Share a link to this ${what}"><i class="fa-solid fa-share-nodes" aria-hidden="true"></i> Share</summary>
-<div class="ovv-pop-panel" role="group" aria-label="Share this ${what}">
-<p class="ovv-pop-title">Share this ${what}</p>
-<label class="sr-only" for="${field}">The link to this video</label>
-<input class="ovv-share-url" id="${field}" type="text" readonly value="${withTime}">
-<p class="ovv-fine">A plain link: it opens this page. Put <code>?t=125</code> on the end and the video opens at 2:05 — a moment, not a clip: nothing is stored and nothing is cut.</p>
-<p class="ovv-share-buttons">
-<button type="button" class="ovv-btn ovv-btn--small" hidden data-ovv-share="${url}" data-ovv-share-field="${field}">Copy the link</button>
-<button type="button" class="ovv-btn ovv-btn--small" hidden data-ovv-share-at="${url}" data-ovv-share-field="${field}">Copy the link at this moment</button>
-</p>
-</div></details>`;
-}
+
/** A button of the action row that opens a menu or a small form. `body` is the panel's content (html). */
/** A button of the action row that opens a menu or a small form. `body` is the panel's content (html). */
function actionMenu({ icon, label, title = null, body, cls = '', iconOnly = false }) {
function actionMenu({ icon, label, title = null, body, cls = '', iconOnly = false }) {
@@ -217,4 +196,4 @@ ${t.body}
@@ -217,4 +196,4 @@ ${t.body}
</section>`;
</section>`;
}
}
-module.exports = { clockOf, lengthWords, dayLabel, streamDate, whatItIs, sharePanel, actionMenu, menuLinks, cornerMenu, downloadsFor, companion };
+module.exports = { clockOf, lengthWords, dayLabel, streamDate, whatItIs, shareButton, actionMenu, menuLinks, cornerMenu, downloadsFor, companion };
@@ -76,6 +76,15 @@ const { SAME, bytes, upload, share } = require('./helpers/drive');
@@ -76,6 +76,15 @@ const { SAME, bytes, upload, share } = require('./helpers/drive');
assert.strictEqual((await t.get(`/files/${file.id}`, { as: kim })).status, 200);
assert.strictEqual((await t.get(`/files/${file.id}`, { as: kim })).status, 200);
});
});
+ await check('the file page carries one Share button, with the file page\'s own address', async () => {
+ const file = (await upload(t, kim, bytes(80, 'shared'), { name: 'shared.txt', contentType: 'text/plain' })).json().file;
+ const r = await t.get(`/files/${file.id}`, { as: kim });
+ assert.strictEqual(r.status, 200);
+ const buttons = r.text.match(/<button[^>]*data-ov-share-sheet[^>]*>/g) || [];
+ assert.strictEqual(buttons.length, 1, 'one Share button: ' + buttons.join(' | '));
+ assert.ok(buttons[0].includes(`data-url="https://openvibe.download/files/${file.id}"`), buttons[0]);
+ });
+
await check('everything a person typed is escaped, everywhere it is shown', async () => {
await check('everything a person typed is escaped, everywhere it is shown', async () => {
const nastyName = '<script>alert("x")</script>.txt';
const nastyName = '<script>alert("x")</script>.txt';
const nastyFolder = '<img src=x onerror="alert(1)">';
const nastyFolder = '<img src=x onerror="alert(1)">';
@@ -24,6 +24,10 @@ const { SAME, bytes, upload, share } = require('./helpers/drive');
@@ -24,6 +24,10 @@ const { SAME, bytes, upload, share } = require('./helpers/drive');
const page = await t.get(`/s/${slug}`);
const page = await t.get(`/s/${slug}`);
assert.strictEqual(page.status, 200, page.text.slice(0, 200));
assert.strictEqual(page.status, 200, page.text.slice(0, 200));
assert.ok(page.text.includes('notes.txt') && page.text.includes('Shared publicly'));
assert.ok(page.text.includes('notes.txt') && page.text.includes('Shared publicly'));
+ // One Share button, with the share page's own address.
+ const buttons = page.text.match(/<button[^>]*data-ov-share-sheet[^>]*>/g) || [];
+ assert.strictEqual(buttons.length, 1, 'one Share button on the share page: ' + buttons.join(' | '));
+ assert.ok(buttons[0].includes(`data-url="https://openvibe.download/s/${slug}"`), buttons[0]);
const dl = await t.get(`/s/${slug}/download`);
const dl = await t.get(`/s/${slug}/download`);
assert.strictEqual(dl.status, 200);
assert.strictEqual(dl.status, 200);
assert.ok(dl.buffer.equals(bytes(500, 'public')), 'the file, to anyone with the link');
assert.ok(dl.buffer.equals(bytes(500, 'public')), 'the file, to anyone with the link');
@@ -150,6 +150,11 @@ function multipart(fields, file) {
@@ -150,6 +150,11 @@ function multipart(fields, file) {
assert.ok(page.text.includes(`content="https://openvibe.pics${direct}"`), 'og:image is the picture');
assert.ok(page.text.includes(`content="https://openvibe.pics${direct}"`), 'og:image is the picture');
assert.ok(page.text.includes('Report this picture'));
assert.ok(page.text.includes('Report this picture'));
assert.ok(!page.text.includes('Delete this picture'), 'no owner tools for a visitor');
assert.ok(!page.text.includes('Delete this picture'), 'no owner tools for a visitor');
+ // One Share button: the picture's own page, with the picture as its image.
+ const buttons = page.text.match(/<button[^>]*data-ov-share-sheet[^>]*>/g) || [];
+ assert.strictEqual(buttons.length, 1, 'one Share button: ' + buttons.join(' | '));
+ assert.ok(buttons[0].includes(`data-url="https://openvibe.pics/i/${slug}"`), buttons[0]);
+ assert.ok(buttons[0].includes(`data-image="https://openvibe.pics${direct}"`), buttons[0]);
assert.ok((await t.get(`/i/${slug}`, { headers: PICS, as: kim })).text.includes('Delete this picture'));
assert.ok((await t.get(`/i/${slug}`, { headers: PICS, as: kim })).text.includes('Delete this picture'));
});
});
@@ -158,7 +158,7 @@ function unzip(buf) {
@@ -158,7 +158,7 @@ function unzip(buf) {
assert.ok(!r.text.includes('ovv-panel-dl'), 'no line of links under a panel any more');
assert.ok(!r.text.includes('ovv-panel-dl'), 'no line of links under a panel any more');
// The layout: tabs beside the player, one action row, the stream's parts in About.
// The layout: tabs beside the player, one action row, the stream's parts in About.
assert.strictEqual((r.text.match(/role="tab"/g) || []).length, 3, 'Chat, Chapters, Transcript');
assert.strictEqual((r.text.match(/role="tab"/g) || []).length, 3, 'Chat, Chapters, Transcript');
- assert.ok(r.text.includes('data-ovv-share="https://openvibe.video/v/6026"') && r.text.includes('aria-label="More"'), 'Share and More');
+ assert.ok(r.text.includes('data-ov-share-sheet data-url="https://openvibe.video/v/6026"') && r.text.includes('aria-label="More"'), 'Share and More');
assert.ok(r.text.includes('<a href="/v/6025">Part 1</a>') && r.text.includes('<b>Part 2 (this one)</b>'), 'the other part of the stream');
assert.ok(r.text.includes('<a href="/v/6025">Part 1</a>') && r.text.includes('<b>Part 2 (this one)</b>'), 'the other part of the stream');
// Chat replay sits in the part's own time: 12 s in, not 721 s.
// Chat replay sits in the part's own time: 12 s in, not 721 s.
assert.ok(r.text.includes('data-ovv-at="12"') && !r.text.includes('in part one'), 'chat in part 2\'s time');
assert.ok(r.text.includes('data-ovv-at="12"') && !r.text.includes('in part one'), 'chat in part 2\'s time');
@@ -175,22 +175,24 @@ function startLive() {
@@ -175,22 +175,24 @@ function startLive() {
assert.ok(!line.includes('A long recording'), 'no title from a recording we cannot see');
assert.ok(!line.includes('A long recording'), 'no title from a recording we cannot see');
});
});
- await check('outcome 3: the page carries a plain link, and says how to make a link to a moment', async () => {
+ await check('outcome 3: the page carries one Share button, with the page\'s own link', async () => {
const r = await get('/v/501');
const r = await get('/v/501');
- const field = /<input class="ovv-share-url"[^>]*value="([^"]*)"/.exec(r.text);
- assert.ok(field, 'the link is written out on the page');
- assert.strictEqual(field[1], 'https://openvibe.video/v/501');
- const panel = r.text.slice(r.text.indexOf('ovv-share'), r.text.indexOf('</details>', r.text.indexOf('ovv-share')));
- assert.ok(panel.includes('?t=125'), 'the panel says how to link to a moment: ' + panel);
- assert.ok(panel.includes('nothing is stored'), 'a moment is not a clip: ' + panel);
- assert.ok(panel.includes('data-ovv-share-at="https://openvibe.video/v/501"'), 'the button that copies the link at the moment it plays');
- // The page opened at a moment: the field shows that link.
- const at = await get('/v/501?t=125');
- assert.strictEqual(/<input class="ovv-share-url"[^>]*value="([^"]*)"/.exec(at.text)[1], 'https://openvibe.video/v/501?t=125');
- // An upload's watch page carries the same plain link.
+ const buttons = r.text.match(/<button[^>]*data-ov-share-sheet[^>]*>/g) || [];
+ assert.strictEqual(buttons.length, 1, 'one Share button: ' + buttons.join(' | '));
+ assert.ok(buttons[0].includes('data-url="https://openvibe.video/v/501"'), buttons[0]);
+ assert.ok(buttons[0].includes('data-time-from=".ovv-player"'), 'the sheet can share the moment it plays: ' + buttons[0]);
+ assert.ok(!r.text.includes('data-ovv-share'), 'the old share box is gone');
+ // A clip's page carries the same button, with the clip's own link.
+ const clip = await get('/c/602');
+ const clipButtons = clip.text.match(/<button[^>]*data-ov-share-sheet[^>]*>/g) || [];
+ assert.strictEqual(clipButtons.length, 1, 'one Share button on the clip page: ' + clipButtons.join(' | '));
+ assert.ok(clipButtons[0].includes('data-url="https://openvibe.video/c/602"'), clipButtons[0]);
+ // An upload's watch page carries the same button.
const up = await get('/u/vup_01J0000000000000000000000B');
const up = await get('/u/vup_01J0000000000000000000000B');
assert.strictEqual(up.status, 200);
assert.strictEqual(up.status, 200);
- assert.ok(/<input class="ovv-share-url"[^>]*value="https:\/\/openvibe\.video\/u\/vup_01J0000000000000000000000B"/.test(up.text), 'the upload page has the link too');
+ const upButtons = up.text.match(/<button[^>]*data-ov-share-sheet[^>]*>/g) || [];
+ assert.strictEqual(upButtons.length, 1, 'one Share button on the upload page: ' + upButtons.join(' | '));
+ assert.ok(upButtons[0].includes('data-url="https://openvibe.video/u/vup_01J0000000000000000000000B"'), upButtons[0]);
});
});
await check('outcome 7: every export says what it is for, and the transcript says it was written by AI', async () => {
await check('outcome 7: every export says what it is for, and the transcript says it was written by AI', async () => {
@@ -430,6 +430,10 @@ function countOccurrences(haystack, needle) {
@@ -430,6 +430,10 @@ function countOccurrences(haystack, needle) {
assert.ok(r.text.includes('Clips'));
assert.ok(r.text.includes('Clips'));
assert.ok(r.text.includes('/c/201'));
assert.ok(r.text.includes('/c/201'));
assert.match(r.text, /<img src="[^"]*\/avatar\/streamer" alt="" loading="lazy">/, 'the person\'s picture over the letter');
assert.match(r.text, /<img src="[^"]*\/avatar\/streamer" alt="" loading="lazy">/, 'the person\'s picture over the letter');
+ // One Share button, with the channel page's own public address.
+ const buttons = r.text.match(/<button[^>]*data-ov-share-sheet[^>]*>/g) || [];
+ assert.strictEqual(buttons.length, 1, 'one Share button: ' + buttons.join(' | '));
+ assert.ok(buttons[0].includes('data-url="https://openvibe.video/@streamer"'), buttons[0]);
});
});
await check('an unknown /api path on the video host is a JSON 404, not a page', async () => {
await check('an unknown /api path on the video host is a JSON 404, not a page', async () => {