openvibers/openvibe.media Commits
The OpenVibe Frame and the shared update system on openvibe.media: page-chrome.js is page-frame.js (frameScripts) on openvibe-shared/frame 1.11.1; the index shows what shipped and /updates is the shared log; the footer links it; the shared navbar signs out through this site (logoutUrl)
Changes
@@@ -18,7 +18,7 @@"multer": "^1.4.5-lts.1","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.33.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.5.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.5.1",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.11.1","sharp": "^0.33.4"}},@@@ -1944,9 +1944,9 @@}},"node_modules/openvibe-shared": {-- "version": "1.5.1",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.5.1",-- "integrity": "sha512-1XWRFwN8br3iiy2Z8fmnXQMgR76VlfMpHjP1xr5uKLCJmtvtNSWO1BgA1kQmVtyBY7g+1n6YFIjahaD4qWbD/Q==",++ "version": "1.11.1",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.11.1",++ "integrity": "sha512-hBaZlZRVZjkGfsPQE16rQIou2lUuusADea3Evx1tVjB84FTHGHS7ZALfcXaNOk7dR53HOVTJrQ0njmScdreUiw==","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0"@@@ -20,7 +20,7 @@"dotenv": "^16.4.5","express": "^4.19.2","multer": "^1.4.5-lts.1",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.5.1",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.11.1","sharp": "^0.33.4"}}@@@ -18,7 +18,8 @@ const PAGE_SIZE = 48;const THUMB_DIR = path.resolve(config.thumbnails.path);// Public base URL per app for "source" links — shared with the watch/paste pages.--const { appUrl } = require('./page-chrome');++const { appUrl } = require('./page-frame');++const frame = require('openvibe-shared/frame');const esc = (s) => String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');const abs = (u) => (!u ? null : (/^https?:\/\//i.test(u) ? u : `${config.publicUrl}${u.startsWith('/') ? '' : '/'}${u}`));@@@ -270,13 +271,14 @@ nav.tabs a .n{opacity:.75;font-size:.78rem;margin-left:.3rem}<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">${require('openvibe-shared/app-icon').headTags({ site: 'media', iconBase: '/assets' })}</head><body>--<div id="navbar-mount"></div>${require('openvibe-shared/chrome-ssr').noscriptNav({ name: 'OpenVibe.Media' })}++<div id="navbar-mount"></div>${frame.noscriptNav({ name: 'OpenVibe.Media' })}<header class="intro"><h1>Every public file on the network</h1><span class="sub">videos, clips, images, pastes & thumbnails from all OpenVibe sites</span></header><nav class="tabs" aria-label="Media types">${TABS.map(([k, label]) => `<a class="${k === tab ? 'on' : ''}" href="/?tab=${k}">${label}<span class="n">${(counts[k] ?? 0).toLocaleString()}</span></a>`).join('')}</nav>${data.cards.length ? `<div class="grid">${data.cards.map(renderCard).join('')}</div>` : '<div class="empty">Nothing here yet.</div>'}<div class="pager">${nav(page - 1, '‹ Prev', page <= 1)}<span>Page ${page} / ${pages} · ${data.total.toLocaleString()} items</span>${nav(page + 1, 'Next ›', page >= pages)}</div>--${require('openvibe-shared/footer').ssr({ service: 'media', variant: 'full' })}--${require('./page-chrome').chromeScripts({ footer: { variant: 'full' } })}++${tab === 'all' && page === 1 ? `<div style="max-width:1200px;margin:0 auto;padding:0 1rem">${frame.shipped({ service: 'media', title: 'Recently shipped on OpenVibe.Media' })}</div>` : ''}++${frame.footer({ service: 'media', variant: 'full', updates: '/updates' })}++${require('./page-frame').frameScripts({ footer: { variant: 'full' } })}</body></html>`;}ddeleted file mode 100644@@@ -1,201 +0,0 @@--/**-- * OpenVibe.Media — the shell every server-rendered page shares.-- *-- * One place for: the network chrome (openvibe.network's theme-loader before-- * paint, navbar + footer after), the SEO head (title, description, canonical,-- * robots, Open Graph, Twitter card, JSON-LD) and the page palette, which-- * reads the shared theme tokens (--bg-primary, --accent, …) that-- * theme-loader.js sets on <html> and falls back to the default Vibe palette.-- */--'use strict';----const config = require('../config');----const NETWORK_URL = (config.network && config.network.url) || 'https://openvibe.network';--const SITE_NAME = 'OpenVibe.Media';--const DEFAULT_OG_IMAGE = `${config.publicUrl}/og-image.png`;----// Public base URL per app for canonical / "source" links (env-overridable JSON map).--const APP_PUBLIC_URLS = (() => {-- const defaults = {-- live: 'https://openvibe.live',-- games: 'https://openvibe.games',-- tools: 'https://openvibe.tools',-- network: NETWORK_URL,-- community: process.env.COMMUNITY_PUBLIC_URL || 'https://openvibe.community',-- };-- try {-- const m = JSON.parse(process.env.APP_PUBLIC_URLS || '');-- if (m && typeof m === 'object') return { ...defaults, ...m };-- } catch { /* unset or malformed: defaults */ }-- return defaults;--})();--const appUrl = (appId) => APP_PUBLIC_URLS[appId] || APP_PUBLIC_URLS.live;----const esc = (s) => String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');--const abs = (u) => (!u ? null : (/^https?:\/\//i.test(u) ? u : `${config.publicUrl}${u.startsWith('/') ? '' : '/'}${u}`));--const snip = (s, n = 200) => { const t = String(s || '').replace(/\s+/g, ' ').trim(); return t.length > n ? t.slice(0, n - 1).trimEnd() + '…' : t; };--/** JSON that is safe inside a <script>: no `</` can end the element early. */--const jsonForScript = (v) => JSON.stringify(v).replace(/</g, '\\u003c');----/** SQLite's "YYYY-MM-DD HH:MM:SS" (UTC) → ISO 8601, or null. */--function isoDate(dt) {-- if (!dt) return null;-- const d = new Date(String(dt).includes('T') ? dt : `${dt}Z`.replace(' ', 'T'));-- return Number.isNaN(d.getTime()) ? null : d.toISOString();--}--/** Seconds → ISO 8601 duration (PT1H2M3S), or null. */--function isoDuration(sec) {-- sec = Math.floor(Number(sec) || 0);-- if (sec <= 0) return null;-- const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60;-- return `PT${h ? h + 'H' : ''}${m ? m + 'M' : ''}${s || (!h && !m) ? s + 'S' : ''}`;--}--function fmtDuration(sec) {-- sec = Math.floor(Number(sec) || 0);-- const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60;-- return h ? `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}` : `${m}:${String(s).padStart(2, '0')}`;--}--function fmtDate(dt) {-- const iso = isoDate(dt);-- if (!iso) return '';-- try { return new Date(iso).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', timeZone: 'UTC' }); } catch { return iso.slice(0, 10); }--}----/**-- * The <head> a page needs: shared theme before paint, then the SEO set.-- * title, description, canonical (absolute), robots, image (absolute),-- * ogType, jsonLd (array of objects), video ({ url, type, width, height }).-- */--function headTags(seo) {-- const title = esc(seo.title);-- const description = esc(snip(seo.description, 300));-- const image = seo.image || DEFAULT_OG_IMAGE;-- const twitterCard = seo.twitterCard || 'summary_large_image';-- const tags = [-- '<meta charset="utf-8">',-- '<meta name="viewport" content="width=device-width, initial-scale=1">',-- `<title>${title}</title>`,-- `<meta name="description" content="${description}">`,-- // Applies the user's theme to <html> synchronously; must run before any CSS paints.-- require('openvibe-shared/app-icon').headTags({ site: 'media', iconBase: '/assets' }),-- `<script src="${NETWORK_URL}/shared/theme-loader.js" defer></script>`,-- seo.canonical ? `<link rel="canonical" href="${esc(seo.canonical)}">` : '',-- `<meta name="robots" content="${esc(seo.robots || 'index, follow')}">`,-- `<meta property="og:site_name" content="${SITE_NAME}">`,-- `<meta property="og:type" content="${esc(seo.ogType || 'website')}">`,-- `<meta property="og:title" content="${title}">`,-- `<meta property="og:description" content="${description}">`,-- seo.canonical ? `<meta property="og:url" content="${esc(seo.canonical)}">` : '',-- `<meta property="og:image" content="${esc(image)}">`,-- image === DEFAULT_OG_IMAGE ? '<meta property="og:image:width" content="1200"><meta property="og:image:height" content="630">' : '',-- seo.video && seo.video.url ? [-- `<meta property="og:video" content="${esc(seo.video.url)}">`,-- `<meta property="og:video:secure_url" content="${esc(seo.video.url)}">`,-- seo.video.type ? `<meta property="og:video:type" content="${esc(seo.video.type)}">` : '',-- ].join('\n') : '',-- `<meta name="twitter:card" content="${esc(twitterCard)}">`,-- `<meta name="twitter:title" content="${title}">`,-- `<meta name="twitter:description" content="${description}">`,-- `<meta name="twitter:image" content="${esc(image)}">`,-- ...(seo.jsonLd || []).map(obj => `<script type="application/ld+json">${jsonForScript(obj)}</script>`),-- ];-- return tags.filter(Boolean).join('\n');--}----/** Page palette on the shared theme tokens (fallback: the default Vibe theme). */--function baseCss() {-- return `-- :root { --bg: var(--bg-primary, #0a0f1c); --panel: var(--bg-card, #131c2e); --line: var(--border, #1f2d47);-- --text: var(--text-primary, #e6edf7); --muted: var(--text-secondary, #96a7c2);-- --link: var(--accent-light, #60a5fa); --acc: var(--accent, #3b82f6); --on-acc: var(--on-accent, #fff); }-- * { box-sizing: border-box; }-- html, body { margin: 0; min-height: 100%; }-- body { background: var(--bg); color: var(--text); font: 15px/1.6 system-ui, -apple-system, 'Segoe UI', sans-serif; display: flex; flex-direction: column; min-height: 100vh; }-- main { width: 100%; max-width: 1040px; margin: 0 auto; padding: 1.2rem 16px 2rem; flex: 1 0 auto; }-- a { color: var(--link); }-- h1 { font-size: 1.35rem; line-height: 1.3; margin: .2rem 0 .4rem; overflow-wrap: anywhere; }-- .meta { color: var(--muted); font-size: .86rem; margin: 0 0 1rem; display: flex; flex-wrap: wrap; gap: .25rem .6rem; align-items: center; }-- .meta a { text-decoration: none; }-- .meta a:hover { text-decoration: underline; }-- .note { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: .7rem 1rem; margin: 0 0 1rem; color: var(--muted); font-size: .9rem; }-- .note a { font-weight: 600; }-- .btn { display: inline-block; padding: .5rem .95rem; border-radius: 10px; background: var(--acc); color: var(--on-acc); text-decoration: none; font-weight: 600; font-size: .9rem; }-- .btn.ghost { background: transparent; color: var(--link); border: 1px solid var(--line); }-- .actions { display: flex; flex-wrap: wrap; gap: .5rem; margin: 0 0 1rem; }-- .desc { color: var(--muted); white-space: pre-wrap; overflow-wrap: anywhere; }-- #ov-footer { flex: none; }--`;--}----/**-- * The scripts that mount the navbar + footer, after the page content so a-- * slow Network never blocks the body.-- * history { type, title } → recorded to the signed-in user's network history-- * footer { variant: 'full' | 'compact', links: [{ heading, items: [{ label, href }] }] }-- */--function chromeScripts({ history, footer } = {}) {-- const navOpts = { service: 'media', apiBase: NETWORK_URL, silentLogin: `${config.publicUrl}/auth/login?silent=1&next={url}`, fedcmLogin: `${config.publicUrl}/auth/fedcm`, loginUrl: `${config.publicUrl}/auth/login?next={url}`, sessionUrl: '/auth/me' };-- if (history) navOpts.history = history;-- const footOpts = {-- service: 'media',-- variant: (footer && footer.variant) || 'compact',-- links: (footer && footer.links) || defaultFooterLinks(),-- mount: '#ov-footer',-- };-- return `<script src="${NETWORK_URL}/shared/navbar.js" defer></script>--<script src="${NETWORK_URL}/shared/footer.js" defer></script>--<script>--(function () {-- var tries = 0;-- function boot() {-- if (!window.OpenVibeNavbar || !window.OpenVibeFooter) { if (++tries < 60) setTimeout(boot, 100); return; }-- try { OpenVibeNavbar.init(${jsonForScript(navOpts)}); } catch (e) { /* navbar optional */ }-- try { OpenVibeFooter.init(${jsonForScript(footOpts)}); } catch (e) { /* footer optional */ }-- }-- if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); else boot();--})();--</script>`;--}----function defaultFooterLinks() {-- return [-- {-- heading: 'Media',-- items: [-- { label: 'Media index', href: `${config.publicUrl}/` },-- { label: 'Videos', href: `${config.publicUrl}/?tab=videos` },-- { label: 'Clips', href: `${config.publicUrl}/?tab=clips` },-- { label: 'Pastes', href: `${appUrl('community')}/pastes` },-- ],-- },-- ];--}----/**-- * A whole document: `seo` → headTags, `css` extra styles, `body` the <main>-- * contents, `history` / `footer` → chromeScripts.-- */--function page({ seo, css = '', body, history, footer }) {-- return `<!DOCTYPE html>--<html lang="en">--<head>--${headTags(seo)}--<style>${baseCss()}${css}</style>--</head>--<body>--<div id="navbar-mount"></div>${require('openvibe-shared/chrome-ssr').noscriptNav({ name: 'OpenVibe.Media', links: [{ label: 'Videos', href: '/?tab=videos' }, { label: 'Clips', href: '/?tab=clips' }] })}--<main>--${body}--</main>--${require('openvibe-shared/footer').ssr({ service: 'media', variant: 'compact' })}--${chromeScripts({ history, footer })}--</body>--</html>`;--}----module.exports = {-- NETWORK_URL, SITE_NAME, DEFAULT_OG_IMAGE, APP_PUBLIC_URLS, appUrl,-- esc, abs, snip, isoDate, isoDuration, fmtDuration, fmtDate, jsonForScript,-- headTags, baseCss, chromeScripts, defaultFooterLinks, page,--};nnew file mode 100644@@@ -0,0 +1,202 @@++/**++ * OpenVibe.Media — the shell every server-rendered page shares.++ *++ * One place for: the OpenVibe Frame (openvibe.network's theme-loader before++ * paint, navbar + footer after), the SEO head (title, description, canonical,++ * robots, Open Graph, Twitter card, JSON-LD) and the page palette, which++ * reads the shared theme tokens (--bg-primary, --accent, …) that++ * theme-loader.js sets on <html> and falls back to the default Vibe palette.++ */++'use strict';++++const config = require('../config');++++const NETWORK_URL = (config.network && config.network.url) || 'https://openvibe.network';++const SITE_NAME = 'OpenVibe.Media';++const DEFAULT_OG_IMAGE = `${config.publicUrl}/og-image.png`;++++// Public base URL per app for canonical / "source" links (env-overridable JSON map).++const APP_PUBLIC_URLS = (() => {++ const defaults = {++ live: 'https://openvibe.live',++ games: 'https://openvibe.games',++ tools: 'https://openvibe.tools',++ network: NETWORK_URL,++ community: process.env.COMMUNITY_PUBLIC_URL || 'https://openvibe.community',++ };++ try {++ const m = JSON.parse(process.env.APP_PUBLIC_URLS || '');++ if (m && typeof m === 'object') return { ...defaults, ...m };++ } catch { /* unset or malformed: defaults */ }++ return defaults;++})();++const appUrl = (appId) => APP_PUBLIC_URLS[appId] || APP_PUBLIC_URLS.live;++++const esc = (s) => String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');++const abs = (u) => (!u ? null : (/^https?:\/\//i.test(u) ? u : `${config.publicUrl}${u.startsWith('/') ? '' : '/'}${u}`));++const snip = (s, n = 200) => { const t = String(s || '').replace(/\s+/g, ' ').trim(); return t.length > n ? t.slice(0, n - 1).trimEnd() + '…' : t; };++/** JSON that is safe inside a <script>: no `</` can end the element early. */++const jsonForScript = (v) => JSON.stringify(v).replace(/</g, '\\u003c');++++/** SQLite's "YYYY-MM-DD HH:MM:SS" (UTC) → ISO 8601, or null. */++function isoDate(dt) {++ if (!dt) return null;++ const d = new Date(String(dt).includes('T') ? dt : `${dt}Z`.replace(' ', 'T'));++ return Number.isNaN(d.getTime()) ? null : d.toISOString();++}++/** Seconds → ISO 8601 duration (PT1H2M3S), or null. */++function isoDuration(sec) {++ sec = Math.floor(Number(sec) || 0);++ if (sec <= 0) return null;++ const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60;++ return `PT${h ? h + 'H' : ''}${m ? m + 'M' : ''}${s || (!h && !m) ? s + 'S' : ''}`;++}++function fmtDuration(sec) {++ sec = Math.floor(Number(sec) || 0);++ const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60;++ return h ? `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}` : `${m}:${String(s).padStart(2, '0')}`;++}++function fmtDate(dt) {++ const iso = isoDate(dt);++ if (!iso) return '';++ try { return new Date(iso).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', timeZone: 'UTC' }); } catch { return iso.slice(0, 10); }++}++++/**++ * The <head> a page needs: shared theme before paint, then the SEO set.++ * title, description, canonical (absolute), robots, image (absolute),++ * ogType, jsonLd (array of objects), video ({ url, type, width, height }).++ */++function headTags(seo) {++ const title = esc(seo.title);++ const description = esc(snip(seo.description, 300));++ const image = seo.image || DEFAULT_OG_IMAGE;++ const twitterCard = seo.twitterCard || 'summary_large_image';++ const tags = [++ '<meta charset="utf-8">',++ '<meta name="viewport" content="width=device-width, initial-scale=1">',++ `<title>${title}</title>`,++ `<meta name="description" content="${description}">`,++ // Applies the user's theme to <html> synchronously; must run before any CSS paints.++ require('openvibe-shared/app-icon').headTags({ site: 'media', iconBase: '/assets' }),++ `<script src="${NETWORK_URL}/shared/theme-loader.js" defer></script>`,++ seo.canonical ? `<link rel="canonical" href="${esc(seo.canonical)}">` : '',++ `<meta name="robots" content="${esc(seo.robots || 'index, follow')}">`,++ `<meta property="og:site_name" content="${SITE_NAME}">`,++ `<meta property="og:type" content="${esc(seo.ogType || 'website')}">`,++ `<meta property="og:title" content="${title}">`,++ `<meta property="og:description" content="${description}">`,++ seo.canonical ? `<meta property="og:url" content="${esc(seo.canonical)}">` : '',++ `<meta property="og:image" content="${esc(image)}">`,++ image === DEFAULT_OG_IMAGE ? '<meta property="og:image:width" content="1200"><meta property="og:image:height" content="630">' : '',++ seo.video && seo.video.url ? [++ `<meta property="og:video" content="${esc(seo.video.url)}">`,++ `<meta property="og:video:secure_url" content="${esc(seo.video.url)}">`,++ seo.video.type ? `<meta property="og:video:type" content="${esc(seo.video.type)}">` : '',++ ].join('\n') : '',++ `<meta name="twitter:card" content="${esc(twitterCard)}">`,++ `<meta name="twitter:title" content="${title}">`,++ `<meta name="twitter:description" content="${description}">`,++ `<meta name="twitter:image" content="${esc(image)}">`,++ ...(seo.jsonLd || []).map(obj => `<script type="application/ld+json">${jsonForScript(obj)}</script>`),++ ];++ return tags.filter(Boolean).join('\n');++}++++/** Page palette on the shared theme tokens (fallback: the default Vibe theme). */++function baseCss() {++ return `++ :root { --bg: var(--bg-primary, #0a0f1c); --panel: var(--bg-card, #131c2e); --line: var(--border, #1f2d47);++ --text: var(--text-primary, #e6edf7); --muted: var(--text-secondary, #96a7c2);++ --link: var(--accent-light, #60a5fa); --acc: var(--accent, #3b82f6); --on-acc: var(--on-accent, #fff); }++ * { box-sizing: border-box; }++ html, body { margin: 0; min-height: 100%; }++ body { background: var(--bg); color: var(--text); font: 15px/1.6 system-ui, -apple-system, 'Segoe UI', sans-serif; display: flex; flex-direction: column; min-height: 100vh; }++ main { width: 100%; max-width: 1040px; margin: 0 auto; padding: 1.2rem 16px 2rem; flex: 1 0 auto; }++ a { color: var(--link); }++ h1 { font-size: 1.35rem; line-height: 1.3; margin: .2rem 0 .4rem; overflow-wrap: anywhere; }++ .meta { color: var(--muted); font-size: .86rem; margin: 0 0 1rem; display: flex; flex-wrap: wrap; gap: .25rem .6rem; align-items: center; }++ .meta a { text-decoration: none; }++ .meta a:hover { text-decoration: underline; }++ .note { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: .7rem 1rem; margin: 0 0 1rem; color: var(--muted); font-size: .9rem; }++ .note a { font-weight: 600; }++ .btn { display: inline-block; padding: .5rem .95rem; border-radius: 10px; background: var(--acc); color: var(--on-acc); text-decoration: none; font-weight: 600; font-size: .9rem; }++ .btn.ghost { background: transparent; color: var(--link); border: 1px solid var(--line); }++ .actions { display: flex; flex-wrap: wrap; gap: .5rem; margin: 0 0 1rem; }++ .desc { color: var(--muted); white-space: pre-wrap; overflow-wrap: anywhere; }++ #ov-footer { flex: none; }++`;++}++++/**++ * The scripts that mount the navbar + footer, after the page content so a++ * slow Network never blocks the body.++ * history { type, title } → recorded to the signed-in user's network history++ * footer { variant: 'full' | 'compact', links: [{ heading, items: [{ label, href }] }] }++ */++function frameScripts({ history, footer } = {}) {++ const navOpts = { service: 'media', apiBase: NETWORK_URL, silentLogin: `${config.publicUrl}/auth/login?silent=1&next={url}`, fedcmLogin: `${config.publicUrl}/auth/fedcm`, loginUrl: `${config.publicUrl}/auth/login?next={url}`, logoutUrl: '/auth/logout?next={path}', sessionUrl: '/auth/me' };++ if (history) navOpts.history = history;++ const footOpts = {++ service: 'media',++ variant: (footer && footer.variant) || 'compact',++ links: (footer && footer.links) || defaultFooterLinks(),++ mount: '#ov-footer',++ updates: '/updates', // the footer's "shipped X ago" line and Updates link open this site's log++ };++ return `<script src="${NETWORK_URL}/shared/navbar.js" defer></script>++<script src="${NETWORK_URL}/shared/footer.js" defer></script>++<script>++(function () {++ var tries = 0;++ function boot() {++ if (!window.OpenVibeNavbar || !window.OpenVibeFooter) { if (++tries < 60) setTimeout(boot, 100); return; }++ try { OpenVibeNavbar.init(${jsonForScript(navOpts)}); } catch (e) { /* navbar optional */ }++ try { OpenVibeFooter.init(${jsonForScript(footOpts)}); } catch (e) { /* footer optional */ }++ }++ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); else boot();++})();++</script>`;++}++++function defaultFooterLinks() {++ return [++ {++ heading: 'Media',++ items: [++ { label: 'Media index', href: `${config.publicUrl}/` },++ { label: 'Videos', href: `${config.publicUrl}/?tab=videos` },++ { label: 'Clips', href: `${config.publicUrl}/?tab=clips` },++ { label: 'Pastes', href: `${appUrl('community')}/pastes` },++ ],++ },++ ];++}++++/**++ * A whole document: `seo` → headTags, `css` extra styles, `body` the <main>++ * contents, `history` / `footer` → frameScripts.++ */++function page({ seo, css = '', body, history, footer }) {++ return `<!DOCTYPE html>++<html lang="en">++<head>++${headTags(seo)}++<style>${baseCss()}${css}</style>++</head>++<body>++<div id="navbar-mount"></div>${require('openvibe-shared/frame').noscriptNav({ name: 'OpenVibe.Media', links: [{ label: 'Videos', href: '/?tab=videos' }, { label: 'Clips', href: '/?tab=clips' }] })}++<main>++${body}++</main>++${require('openvibe-shared/frame').footer({ service: 'media', variant: 'compact', updates: '/updates' })}++${frameScripts({ history, footer })}++</body>++</html>`;++}++++module.exports = {++ NETWORK_URL, SITE_NAME, DEFAULT_OG_IMAGE, APP_PUBLIC_URLS, appUrl,++ esc, abs, snip, isoDate, isoDuration, fmtDuration, fmtDate, jsonForScript,++ headTags, baseCss, frameScripts, defaultFooterLinks, page,++};@@@ -15,9 +15,9 @@const path = require('path');const config = require('../config');--const chrome = require('./page-chrome');++const pageFrame = require('./page-frame');--const { esc, abs, snip, appUrl, isoDate, isoDuration, fmtDuration, fmtDate, SITE_NAME, NETWORK_URL } = chrome;++const { esc, abs, snip, appUrl, isoDate, isoDuration, fmtDuration, fmtDate, SITE_NAME, NETWORK_URL } = pageFrame;const VIDEO_MIME = { '.mp4': 'video/mp4', '.webm': 'video/webm', '.mkv': 'video/x-matroska' };/** Link-preview and search crawlers: they want the page, never the bytes. */@@@ -140,7 +140,7 @@ function renderWatchPage(kind, record) {.player video { display: block; width: 100%; max-height: 72vh; background: #000; }.h2 { font-size: 1rem; margin: 1.2rem 0 .3rem; color: var(--text); }`;-- return chrome.page({++ return pageFrame.page({seo: {title: `${title} — ${SITE_NAME}`, description, canonical, robots,image: thumb || undefined, ogType: 'video.other',@@@ -212,7 +212,7 @@ function renderPastePage(paste) {.shot { margin: 0 0 1rem; }.shot img { max-width: 100%; border-radius: 10px; border: 1px solid var(--line); }`;-- return chrome.page({++ return pageFrame.page({seo: {title: `${title} — ${SITE_NAME}`, description,// Canonical on Community: search engines index the paste there, not here.@@@ -329,6 +329,17 @@ router.get('/a/:id', (req, res) => {// Clips / Images / Text / Thumbnails / Files), each card linking back to its// source page in the owning app. See public/browse.js.router.get('/', (req, res) => require('./browse').handle(req, res));++++// What shipped on OpenVibe.Media: the shared update log every OpenVibe site has.++router.get('/updates', (req, res) => {++ const pf = require('./page-frame');++ const frame = require('openvibe-shared/frame');++ res.set('Cache-Control', 'public, max-age=60').type('html').send(pf.page({++ seo: { title: 'What shipped on OpenVibe.Media', description: 'Every change deployed to OpenVibe.Media, newest first, with the Patch notes that gather them.', canonical: pf.abs('/updates') },++ body: frame.updatesBody({ service: 'media', siteName: 'OpenVibe.Media' }) + frame.shippedScript(),++ footer: { variant: 'full' },++ }));++});router.get('/browse', (req, res) => require('./browse').handle(req, res));// ── Dev data APIs: transcripts / AI timelines / chat insight ─@@@ -1,6 +1,6 @@'use strict';// The public pages: /v and /c watch pages on browser navigation (bytes otherwise),--// the paste viewer's Community canonical, and the shared chrome + SEO on all of them.++// the paste viewer's Community canonical, and the OpenVibe Frame + SEO on all of them.const assert = require('assert');const fs = require('fs');const os = require('os');@@@ -14,7 +14,7 @@ process.env.MEDIA_PUBLIC_URL = 'https://media.test';const db = require('../server/db/database');require('../server/views/service').ensureSchema();const pages = require('../server/public/pages');--const chrome = require('../server/public/page-chrome');++const pageFrame = require('../server/public/page-frame');// ── wantsHtmlPage: navigations and preview bots get the page, players get bytes ──const req = (headers, query = {}) => ({ method: 'GET', headers, query });@@@ -27,7 +27,7 @@ assert.strictEqual(pages.wantsHtmlPage(req({ accept: '*/*' })), false, 'curl / fassert.strictEqual(pages.wantsHtmlPage(req({ accept: '*/*', 'user-agent': 'Mozilla/5.0 (compatible; Discordbot/2.0)' })), true, 'preview bots get the page');assert.strictEqual(pages.wantsHtmlPage({ method: 'POST', headers: { accept: 'text/html' }, query: {} }), false);--// ── Watch page: SEO + chrome + canonical rules ──++// ── Watch page: SEO + Frame + canonical rules ──const vod = {id: 42, app_id: 'live', title: 'Late night <build>', description: 'We built a thing & it worked.',file_path: '/x/vod-42.mp4', thumbnail_url: '/t/vod-42-1.jpg', duration_seconds: 3725, view_count: 1234,@@@ -75,7 +75,7 @@ assert.strictEqual(pld.keywords, 'js, demo');const shot = pages.renderPastePage({ ...paste, slug: 'img-1', type: 'screenshot', screenshot_path: '/x/y.png', content: 'a caption' });assert.ok(shot.includes('<meta property="og:image" content="https://media.test/p/img-1/screenshot">'));assert.ok(shot.includes('"@type":"ImageObject"'));--assert.ok(!/\bfree\b/i.test(html + ph + shot + chrome.baseCss()), 'no cost claims in served HTML');++assert.ok(!/\bfree\b/i.test(html + ph + shot + pageFrame.baseCss()), 'no cost claims in served HTML');// ── End to end through the router: navigation → page, ?raw=1 → bytes path ──const express = require('express');nnew file mode 100644@@@ -0,0 +1,16 @@++'use strict';++// The shared update system on openvibe.media: the index shows what shipped, /updates is the log (the++// markup every OpenVibe site renders, from openvibe-shared/frame), the footer links it, and the++// navbar signs out through this site (logoutUrl).++const assert = require('assert');++const pageFrame = require('../server/public/page-frame');++const frame = require('openvibe-shared/frame');++++const doc = pageFrame.page({ seo: { title: 'What shipped on OpenVibe.Media', description: 'x', canonical: pageFrame.abs('/updates') }, body: frame.updatesBody({ service: 'media', siteName: 'OpenVibe.Media' }), footer: { variant: 'full' } });++assert.ok(doc.includes('What shipped on OpenVibe.Media') && doc.includes('data-ov-shipped="log" data-service="media"'));++assert.ok(doc.includes('rel="canonical"') && doc.includes('/updates"'));++assert.ok(doc.includes('"updates":"/updates"'), 'the footer links this site\'s log');++assert.ok(doc.includes('"logoutUrl":"/auth/logout?next={path}"'), 'Sign out in the shared navbar ends this site\'s session');++assert.ok(doc.includes('data-ov-shipped="latest" data-service="media" href="/updates"'), 'the footer\'s shipped line');++assert.ok(!/chrome-ssr/.test(require('fs').readFileSync(require.resolve('../server/public/browse.js'), 'utf8')), 'openvibe-shared/frame, not the old name');++console.log('shipped pages: all checks passed');
@@ -18,7 +18,7 @@
@@ -18,7 +18,7 @@
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.33.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.33.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.5.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.5.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.5.1",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.11.1",
"sharp": "^0.33.4"
"sharp": "^0.33.4"
}
}
},
},
@@ -1944,9 +1944,9 @@
@@ -1944,9 +1944,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
- "version": "1.5.1",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.5.1",
- "integrity": "sha512-1XWRFwN8br3iiy2Z8fmnXQMgR76VlfMpHjP1xr5uKLCJmtvtNSWO1BgA1kQmVtyBY7g+1n6YFIjahaD4qWbD/Q==",
+ "version": "1.11.1",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.11.1",
+ "integrity": "sha512-hBaZlZRVZjkGfsPQE16rQIou2lUuusADea3Evx1tVjB84FTHGHS7ZALfcXaNOk7dR53HOVTJrQ0njmScdreUiw==",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0"
"jsonwebtoken": "^9.0.0"
@@ -20,7 +20,7 @@
@@ -20,7 +20,7 @@
"dotenv": "^16.4.5",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"express": "^4.19.2",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.5.1",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.11.1",
"sharp": "^0.33.4"
"sharp": "^0.33.4"
}
}
}
}
@@ -18,7 +18,8 @@ const PAGE_SIZE = 48;
@@ -18,7 +18,8 @@ const PAGE_SIZE = 48;
const THUMB_DIR = path.resolve(config.thumbnails.path);
const THUMB_DIR = path.resolve(config.thumbnails.path);
// Public base URL per app for "source" links — shared with the watch/paste pages.
// Public base URL per app for "source" links — shared with the watch/paste pages.
-const { appUrl } = require('./page-chrome');
+const { appUrl } = require('./page-frame');
+const frame = require('openvibe-shared/frame');
const esc = (s) => String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
const esc = (s) => String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
const abs = (u) => (!u ? null : (/^https?:\/\//i.test(u) ? u : `${config.publicUrl}${u.startsWith('/') ? '' : '/'}${u}`));
const abs = (u) => (!u ? null : (/^https?:\/\//i.test(u) ? u : `${config.publicUrl}${u.startsWith('/') ? '' : '/'}${u}`));
@@ -270,13 +271,14 @@ nav.tabs a .n{opacity:.75;font-size:.78rem;margin-left:.3rem}
@@ -270,13 +271,14 @@ nav.tabs a .n{opacity:.75;font-size:.78rem;margin-left:.3rem}
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
${require('openvibe-shared/app-icon').headTags({ site: 'media', iconBase: '/assets' })}
${require('openvibe-shared/app-icon').headTags({ site: 'media', iconBase: '/assets' })}
</head><body>
</head><body>
-<div id="navbar-mount"></div>${require('openvibe-shared/chrome-ssr').noscriptNav({ name: 'OpenVibe.Media' })}
+<div id="navbar-mount"></div>${frame.noscriptNav({ name: 'OpenVibe.Media' })}
<header class="intro"><h1>Every public file on the network</h1><span class="sub">videos, clips, images, pastes & thumbnails from all OpenVibe sites</span></header>
<header class="intro"><h1>Every public file on the network</h1><span class="sub">videos, clips, images, pastes & thumbnails from all OpenVibe sites</span></header>
<nav class="tabs" aria-label="Media types">${TABS.map(([k, label]) => `<a class="${k === tab ? 'on' : ''}" href="/?tab=${k}">${label}<span class="n">${(counts[k] ?? 0).toLocaleString()}</span></a>`).join('')}</nav>
<nav class="tabs" aria-label="Media types">${TABS.map(([k, label]) => `<a class="${k === tab ? 'on' : ''}" href="/?tab=${k}">${label}<span class="n">${(counts[k] ?? 0).toLocaleString()}</span></a>`).join('')}</nav>
${data.cards.length ? `<div class="grid">${data.cards.map(renderCard).join('')}</div>` : '<div class="empty">Nothing here yet.</div>'}
${data.cards.length ? `<div class="grid">${data.cards.map(renderCard).join('')}</div>` : '<div class="empty">Nothing here yet.</div>'}
<div class="pager">${nav(page - 1, '‹ Prev', page <= 1)}<span>Page ${page} / ${pages} · ${data.total.toLocaleString()} items</span>${nav(page + 1, 'Next ›', page >= pages)}</div>
<div class="pager">${nav(page - 1, '‹ Prev', page <= 1)}<span>Page ${page} / ${pages} · ${data.total.toLocaleString()} items</span>${nav(page + 1, 'Next ›', page >= pages)}</div>
-${require('openvibe-shared/footer').ssr({ service: 'media', variant: 'full' })}
-${require('./page-chrome').chromeScripts({ footer: { variant: 'full' } })}
+${tab === 'all' && page === 1 ? `<div style="max-width:1200px;margin:0 auto;padding:0 1rem">${frame.shipped({ service: 'media', title: 'Recently shipped on OpenVibe.Media' })}</div>` : ''}
+${frame.footer({ service: 'media', variant: 'full', updates: '/updates' })}
+${require('./page-frame').frameScripts({ footer: { variant: 'full' } })}
</body></html>`;
</body></html>`;
}
}
deleted file mode 100644
deleted file mode 100644
@@ -1,201 +0,0 @@
@@ -1,201 +0,0 @@
-/**
- * OpenVibe.Media — the shell every server-rendered page shares.
- *
- * One place for: the network chrome (openvibe.network's theme-loader before
- * paint, navbar + footer after), the SEO head (title, description, canonical,
- * robots, Open Graph, Twitter card, JSON-LD) and the page palette, which
- * reads the shared theme tokens (--bg-primary, --accent, …) that
- * theme-loader.js sets on <html> and falls back to the default Vibe palette.
- */
-'use strict';
-
-const config = require('../config');
-
-const NETWORK_URL = (config.network && config.network.url) || 'https://openvibe.network';
-const SITE_NAME = 'OpenVibe.Media';
-const DEFAULT_OG_IMAGE = `${config.publicUrl}/og-image.png`;
-
-// Public base URL per app for canonical / "source" links (env-overridable JSON map).
-const APP_PUBLIC_URLS = (() => {
- const defaults = {
- live: 'https://openvibe.live',
- games: 'https://openvibe.games',
- tools: 'https://openvibe.tools',
- network: NETWORK_URL,
- community: process.env.COMMUNITY_PUBLIC_URL || 'https://openvibe.community',
- };
- try {
- const m = JSON.parse(process.env.APP_PUBLIC_URLS || '');
- if (m && typeof m === 'object') return { ...defaults, ...m };
- } catch { /* unset or malformed: defaults */ }
- return defaults;
-})();
-const appUrl = (appId) => APP_PUBLIC_URLS[appId] || APP_PUBLIC_URLS.live;
-
-const esc = (s) => String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
-const abs = (u) => (!u ? null : (/^https?:\/\//i.test(u) ? u : `${config.publicUrl}${u.startsWith('/') ? '' : '/'}${u}`));
-const snip = (s, n = 200) => { const t = String(s || '').replace(/\s+/g, ' ').trim(); return t.length > n ? t.slice(0, n - 1).trimEnd() + '…' : t; };
-/** JSON that is safe inside a <script>: no `</` can end the element early. */
-const jsonForScript = (v) => JSON.stringify(v).replace(/</g, '\\u003c');
-
-/** SQLite's "YYYY-MM-DD HH:MM:SS" (UTC) → ISO 8601, or null. */
-function isoDate(dt) {
- if (!dt) return null;
- const d = new Date(String(dt).includes('T') ? dt : `${dt}Z`.replace(' ', 'T'));
- return Number.isNaN(d.getTime()) ? null : d.toISOString();
-}
-/** Seconds → ISO 8601 duration (PT1H2M3S), or null. */
-function isoDuration(sec) {
- sec = Math.floor(Number(sec) || 0);
- if (sec <= 0) return null;
- const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60;
- return `PT${h ? h + 'H' : ''}${m ? m + 'M' : ''}${s || (!h && !m) ? s + 'S' : ''}`;
-}
-function fmtDuration(sec) {
- sec = Math.floor(Number(sec) || 0);
- const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60;
- return h ? `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}` : `${m}:${String(s).padStart(2, '0')}`;
-}
-function fmtDate(dt) {
- const iso = isoDate(dt);
- if (!iso) return '';
- try { return new Date(iso).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', timeZone: 'UTC' }); } catch { return iso.slice(0, 10); }
-}
-
-/**
- * The <head> a page needs: shared theme before paint, then the SEO set.
- * title, description, canonical (absolute), robots, image (absolute),
- * ogType, jsonLd (array of objects), video ({ url, type, width, height }).
- */
-function headTags(seo) {
- const title = esc(seo.title);
- const description = esc(snip(seo.description, 300));
- const image = seo.image || DEFAULT_OG_IMAGE;
- const twitterCard = seo.twitterCard || 'summary_large_image';
- const tags = [
- '<meta charset="utf-8">',
- '<meta name="viewport" content="width=device-width, initial-scale=1">',
- `<title>${title}</title>`,
- `<meta name="description" content="${description}">`,
- // Applies the user's theme to <html> synchronously; must run before any CSS paints.
- require('openvibe-shared/app-icon').headTags({ site: 'media', iconBase: '/assets' }),
- `<script src="${NETWORK_URL}/shared/theme-loader.js" defer></script>`,
- seo.canonical ? `<link rel="canonical" href="${esc(seo.canonical)}">` : '',
- `<meta name="robots" content="${esc(seo.robots || 'index, follow')}">`,
- `<meta property="og:site_name" content="${SITE_NAME}">`,
- `<meta property="og:type" content="${esc(seo.ogType || 'website')}">`,
- `<meta property="og:title" content="${title}">`,
- `<meta property="og:description" content="${description}">`,
- seo.canonical ? `<meta property="og:url" content="${esc(seo.canonical)}">` : '',
- `<meta property="og:image" content="${esc(image)}">`,
- image === DEFAULT_OG_IMAGE ? '<meta property="og:image:width" content="1200"><meta property="og:image:height" content="630">' : '',
- seo.video && seo.video.url ? [
- `<meta property="og:video" content="${esc(seo.video.url)}">`,
- `<meta property="og:video:secure_url" content="${esc(seo.video.url)}">`,
- seo.video.type ? `<meta property="og:video:type" content="${esc(seo.video.type)}">` : '',
- ].join('\n') : '',
- `<meta name="twitter:card" content="${esc(twitterCard)}">`,
- `<meta name="twitter:title" content="${title}">`,
- `<meta name="twitter:description" content="${description}">`,
- `<meta name="twitter:image" content="${esc(image)}">`,
- ...(seo.jsonLd || []).map(obj => `<script type="application/ld+json">${jsonForScript(obj)}</script>`),
- ];
- return tags.filter(Boolean).join('\n');
-}
-
-/** Page palette on the shared theme tokens (fallback: the default Vibe theme). */
-function baseCss() {
- return `
- :root { --bg: var(--bg-primary, #0a0f1c); --panel: var(--bg-card, #131c2e); --line: var(--border, #1f2d47);
- --text: var(--text-primary, #e6edf7); --muted: var(--text-secondary, #96a7c2);
- --link: var(--accent-light, #60a5fa); --acc: var(--accent, #3b82f6); --on-acc: var(--on-accent, #fff); }
- * { box-sizing: border-box; }
- html, body { margin: 0; min-height: 100%; }
- body { background: var(--bg); color: var(--text); font: 15px/1.6 system-ui, -apple-system, 'Segoe UI', sans-serif; display: flex; flex-direction: column; min-height: 100vh; }
- main { width: 100%; max-width: 1040px; margin: 0 auto; padding: 1.2rem 16px 2rem; flex: 1 0 auto; }
- a { color: var(--link); }
- h1 { font-size: 1.35rem; line-height: 1.3; margin: .2rem 0 .4rem; overflow-wrap: anywhere; }
- .meta { color: var(--muted); font-size: .86rem; margin: 0 0 1rem; display: flex; flex-wrap: wrap; gap: .25rem .6rem; align-items: center; }
- .meta a { text-decoration: none; }
- .meta a:hover { text-decoration: underline; }
- .note { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: .7rem 1rem; margin: 0 0 1rem; color: var(--muted); font-size: .9rem; }
- .note a { font-weight: 600; }
- .btn { display: inline-block; padding: .5rem .95rem; border-radius: 10px; background: var(--acc); color: var(--on-acc); text-decoration: none; font-weight: 600; font-size: .9rem; }
- .btn.ghost { background: transparent; color: var(--link); border: 1px solid var(--line); }
- .actions { display: flex; flex-wrap: wrap; gap: .5rem; margin: 0 0 1rem; }
- .desc { color: var(--muted); white-space: pre-wrap; overflow-wrap: anywhere; }
- #ov-footer { flex: none; }
-`;
-}
-
-/**
- * The scripts that mount the navbar + footer, after the page content so a
- * slow Network never blocks the body.
- * history { type, title } → recorded to the signed-in user's network history
- * footer { variant: 'full' | 'compact', links: [{ heading, items: [{ label, href }] }] }
- */
-function chromeScripts({ history, footer } = {}) {
- const navOpts = { service: 'media', apiBase: NETWORK_URL, silentLogin: `${config.publicUrl}/auth/login?silent=1&next={url}`, fedcmLogin: `${config.publicUrl}/auth/fedcm`, loginUrl: `${config.publicUrl}/auth/login?next={url}`, sessionUrl: '/auth/me' };
- if (history) navOpts.history = history;
- const footOpts = {
- service: 'media',
- variant: (footer && footer.variant) || 'compact',
- links: (footer && footer.links) || defaultFooterLinks(),
- mount: '#ov-footer',
- };
- return `<script src="${NETWORK_URL}/shared/navbar.js" defer></script>
-<script src="${NETWORK_URL}/shared/footer.js" defer></script>
-<script>
-(function () {
- var tries = 0;
- function boot() {
- if (!window.OpenVibeNavbar || !window.OpenVibeFooter) { if (++tries < 60) setTimeout(boot, 100); return; }
- try { OpenVibeNavbar.init(${jsonForScript(navOpts)}); } catch (e) { /* navbar optional */ }
- try { OpenVibeFooter.init(${jsonForScript(footOpts)}); } catch (e) { /* footer optional */ }
- }
- if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); else boot();
-})();
-</script>`;
-}
-
-function defaultFooterLinks() {
- return [
- {
- heading: 'Media',
- items: [
- { label: 'Media index', href: `${config.publicUrl}/` },
- { label: 'Videos', href: `${config.publicUrl}/?tab=videos` },
- { label: 'Clips', href: `${config.publicUrl}/?tab=clips` },
- { label: 'Pastes', href: `${appUrl('community')}/pastes` },
- ],
- },
- ];
-}
-
-/**
- * A whole document: `seo` → headTags, `css` extra styles, `body` the <main>
- * contents, `history` / `footer` → chromeScripts.
- */
-function page({ seo, css = '', body, history, footer }) {
- return `<!DOCTYPE html>
-<html lang="en">
-<head>
-${headTags(seo)}
-<style>${baseCss()}${css}</style>
-</head>
-<body>
-<div id="navbar-mount"></div>${require('openvibe-shared/chrome-ssr').noscriptNav({ name: 'OpenVibe.Media', links: [{ label: 'Videos', href: '/?tab=videos' }, { label: 'Clips', href: '/?tab=clips' }] })}
-<main>
-${body}
-</main>
-${require('openvibe-shared/footer').ssr({ service: 'media', variant: 'compact' })}
-${chromeScripts({ history, footer })}
-</body>
-</html>`;
-}
-
-module.exports = {
- NETWORK_URL, SITE_NAME, DEFAULT_OG_IMAGE, APP_PUBLIC_URLS, appUrl,
- esc, abs, snip, isoDate, isoDuration, fmtDuration, fmtDate, jsonForScript,
- headTags, baseCss, chromeScripts, defaultFooterLinks, page,
-};
new file mode 100644
new file mode 100644
@@ -0,0 +1,202 @@
@@ -0,0 +1,202 @@
+/**
+ * OpenVibe.Media — the shell every server-rendered page shares.
+ *
+ * One place for: the OpenVibe Frame (openvibe.network's theme-loader before
+ * paint, navbar + footer after), the SEO head (title, description, canonical,
+ * robots, Open Graph, Twitter card, JSON-LD) and the page palette, which
+ * reads the shared theme tokens (--bg-primary, --accent, …) that
+ * theme-loader.js sets on <html> and falls back to the default Vibe palette.
+ */
+'use strict';
+
+const config = require('../config');
+
+const NETWORK_URL = (config.network && config.network.url) || 'https://openvibe.network';
+const SITE_NAME = 'OpenVibe.Media';
+const DEFAULT_OG_IMAGE = `${config.publicUrl}/og-image.png`;
+
+// Public base URL per app for canonical / "source" links (env-overridable JSON map).
+const APP_PUBLIC_URLS = (() => {
+ const defaults = {
+ live: 'https://openvibe.live',
+ games: 'https://openvibe.games',
+ tools: 'https://openvibe.tools',
+ network: NETWORK_URL,
+ community: process.env.COMMUNITY_PUBLIC_URL || 'https://openvibe.community',
+ };
+ try {
+ const m = JSON.parse(process.env.APP_PUBLIC_URLS || '');
+ if (m && typeof m === 'object') return { ...defaults, ...m };
+ } catch { /* unset or malformed: defaults */ }
+ return defaults;
+})();
+const appUrl = (appId) => APP_PUBLIC_URLS[appId] || APP_PUBLIC_URLS.live;
+
+const esc = (s) => String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
+const abs = (u) => (!u ? null : (/^https?:\/\//i.test(u) ? u : `${config.publicUrl}${u.startsWith('/') ? '' : '/'}${u}`));
+const snip = (s, n = 200) => { const t = String(s || '').replace(/\s+/g, ' ').trim(); return t.length > n ? t.slice(0, n - 1).trimEnd() + '…' : t; };
+/** JSON that is safe inside a <script>: no `</` can end the element early. */
+const jsonForScript = (v) => JSON.stringify(v).replace(/</g, '\\u003c');
+
+/** SQLite's "YYYY-MM-DD HH:MM:SS" (UTC) → ISO 8601, or null. */
+function isoDate(dt) {
+ if (!dt) return null;
+ const d = new Date(String(dt).includes('T') ? dt : `${dt}Z`.replace(' ', 'T'));
+ return Number.isNaN(d.getTime()) ? null : d.toISOString();
+}
+/** Seconds → ISO 8601 duration (PT1H2M3S), or null. */
+function isoDuration(sec) {
+ sec = Math.floor(Number(sec) || 0);
+ if (sec <= 0) return null;
+ const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60;
+ return `PT${h ? h + 'H' : ''}${m ? m + 'M' : ''}${s || (!h && !m) ? s + 'S' : ''}`;
+}
+function fmtDuration(sec) {
+ sec = Math.floor(Number(sec) || 0);
+ const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60;
+ return h ? `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}` : `${m}:${String(s).padStart(2, '0')}`;
+}
+function fmtDate(dt) {
+ const iso = isoDate(dt);
+ if (!iso) return '';
+ try { return new Date(iso).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', timeZone: 'UTC' }); } catch { return iso.slice(0, 10); }
+}
+
+/**
+ * The <head> a page needs: shared theme before paint, then the SEO set.
+ * title, description, canonical (absolute), robots, image (absolute),
+ * ogType, jsonLd (array of objects), video ({ url, type, width, height }).
+ */
+function headTags(seo) {
+ const title = esc(seo.title);
+ const description = esc(snip(seo.description, 300));
+ const image = seo.image || DEFAULT_OG_IMAGE;
+ const twitterCard = seo.twitterCard || 'summary_large_image';
+ const tags = [
+ '<meta charset="utf-8">',
+ '<meta name="viewport" content="width=device-width, initial-scale=1">',
+ `<title>${title}</title>`,
+ `<meta name="description" content="${description}">`,
+ // Applies the user's theme to <html> synchronously; must run before any CSS paints.
+ require('openvibe-shared/app-icon').headTags({ site: 'media', iconBase: '/assets' }),
+ `<script src="${NETWORK_URL}/shared/theme-loader.js" defer></script>`,
+ seo.canonical ? `<link rel="canonical" href="${esc(seo.canonical)}">` : '',
+ `<meta name="robots" content="${esc(seo.robots || 'index, follow')}">`,
+ `<meta property="og:site_name" content="${SITE_NAME}">`,
+ `<meta property="og:type" content="${esc(seo.ogType || 'website')}">`,
+ `<meta property="og:title" content="${title}">`,
+ `<meta property="og:description" content="${description}">`,
+ seo.canonical ? `<meta property="og:url" content="${esc(seo.canonical)}">` : '',
+ `<meta property="og:image" content="${esc(image)}">`,
+ image === DEFAULT_OG_IMAGE ? '<meta property="og:image:width" content="1200"><meta property="og:image:height" content="630">' : '',
+ seo.video && seo.video.url ? [
+ `<meta property="og:video" content="${esc(seo.video.url)}">`,
+ `<meta property="og:video:secure_url" content="${esc(seo.video.url)}">`,
+ seo.video.type ? `<meta property="og:video:type" content="${esc(seo.video.type)}">` : '',
+ ].join('\n') : '',
+ `<meta name="twitter:card" content="${esc(twitterCard)}">`,
+ `<meta name="twitter:title" content="${title}">`,
+ `<meta name="twitter:description" content="${description}">`,
+ `<meta name="twitter:image" content="${esc(image)}">`,
+ ...(seo.jsonLd || []).map(obj => `<script type="application/ld+json">${jsonForScript(obj)}</script>`),
+ ];
+ return tags.filter(Boolean).join('\n');
+}
+
+/** Page palette on the shared theme tokens (fallback: the default Vibe theme). */
+function baseCss() {
+ return `
+ :root { --bg: var(--bg-primary, #0a0f1c); --panel: var(--bg-card, #131c2e); --line: var(--border, #1f2d47);
+ --text: var(--text-primary, #e6edf7); --muted: var(--text-secondary, #96a7c2);
+ --link: var(--accent-light, #60a5fa); --acc: var(--accent, #3b82f6); --on-acc: var(--on-accent, #fff); }
+ * { box-sizing: border-box; }
+ html, body { margin: 0; min-height: 100%; }
+ body { background: var(--bg); color: var(--text); font: 15px/1.6 system-ui, -apple-system, 'Segoe UI', sans-serif; display: flex; flex-direction: column; min-height: 100vh; }
+ main { width: 100%; max-width: 1040px; margin: 0 auto; padding: 1.2rem 16px 2rem; flex: 1 0 auto; }
+ a { color: var(--link); }
+ h1 { font-size: 1.35rem; line-height: 1.3; margin: .2rem 0 .4rem; overflow-wrap: anywhere; }
+ .meta { color: var(--muted); font-size: .86rem; margin: 0 0 1rem; display: flex; flex-wrap: wrap; gap: .25rem .6rem; align-items: center; }
+ .meta a { text-decoration: none; }
+ .meta a:hover { text-decoration: underline; }
+ .note { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: .7rem 1rem; margin: 0 0 1rem; color: var(--muted); font-size: .9rem; }
+ .note a { font-weight: 600; }
+ .btn { display: inline-block; padding: .5rem .95rem; border-radius: 10px; background: var(--acc); color: var(--on-acc); text-decoration: none; font-weight: 600; font-size: .9rem; }
+ .btn.ghost { background: transparent; color: var(--link); border: 1px solid var(--line); }
+ .actions { display: flex; flex-wrap: wrap; gap: .5rem; margin: 0 0 1rem; }
+ .desc { color: var(--muted); white-space: pre-wrap; overflow-wrap: anywhere; }
+ #ov-footer { flex: none; }
+`;
+}
+
+/**
+ * The scripts that mount the navbar + footer, after the page content so a
+ * slow Network never blocks the body.
+ * history { type, title } → recorded to the signed-in user's network history
+ * footer { variant: 'full' | 'compact', links: [{ heading, items: [{ label, href }] }] }
+ */
+function frameScripts({ history, footer } = {}) {
+ const navOpts = { service: 'media', apiBase: NETWORK_URL, silentLogin: `${config.publicUrl}/auth/login?silent=1&next={url}`, fedcmLogin: `${config.publicUrl}/auth/fedcm`, loginUrl: `${config.publicUrl}/auth/login?next={url}`, logoutUrl: '/auth/logout?next={path}', sessionUrl: '/auth/me' };
+ if (history) navOpts.history = history;
+ const footOpts = {
+ service: 'media',
+ variant: (footer && footer.variant) || 'compact',
+ links: (footer && footer.links) || defaultFooterLinks(),
+ mount: '#ov-footer',
+ updates: '/updates', // the footer's "shipped X ago" line and Updates link open this site's log
+ };
+ return `<script src="${NETWORK_URL}/shared/navbar.js" defer></script>
+<script src="${NETWORK_URL}/shared/footer.js" defer></script>
+<script>
+(function () {
+ var tries = 0;
+ function boot() {
+ if (!window.OpenVibeNavbar || !window.OpenVibeFooter) { if (++tries < 60) setTimeout(boot, 100); return; }
+ try { OpenVibeNavbar.init(${jsonForScript(navOpts)}); } catch (e) { /* navbar optional */ }
+ try { OpenVibeFooter.init(${jsonForScript(footOpts)}); } catch (e) { /* footer optional */ }
+ }
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); else boot();
+})();
+</script>`;
+}
+
+function defaultFooterLinks() {
+ return [
+ {
+ heading: 'Media',
+ items: [
+ { label: 'Media index', href: `${config.publicUrl}/` },
+ { label: 'Videos', href: `${config.publicUrl}/?tab=videos` },
+ { label: 'Clips', href: `${config.publicUrl}/?tab=clips` },
+ { label: 'Pastes', href: `${appUrl('community')}/pastes` },
+ ],
+ },
+ ];
+}
+
+/**
+ * A whole document: `seo` → headTags, `css` extra styles, `body` the <main>
+ * contents, `history` / `footer` → frameScripts.
+ */
+function page({ seo, css = '', body, history, footer }) {
+ return `<!DOCTYPE html>
+<html lang="en">
+<head>
+${headTags(seo)}
+<style>${baseCss()}${css}</style>
+</head>
+<body>
+<div id="navbar-mount"></div>${require('openvibe-shared/frame').noscriptNav({ name: 'OpenVibe.Media', links: [{ label: 'Videos', href: '/?tab=videos' }, { label: 'Clips', href: '/?tab=clips' }] })}
+<main>
+${body}
+</main>
+${require('openvibe-shared/frame').footer({ service: 'media', variant: 'compact', updates: '/updates' })}
+${frameScripts({ history, footer })}
+</body>
+</html>`;
+}
+
+module.exports = {
+ NETWORK_URL, SITE_NAME, DEFAULT_OG_IMAGE, APP_PUBLIC_URLS, appUrl,
+ esc, abs, snip, isoDate, isoDuration, fmtDuration, fmtDate, jsonForScript,
+ headTags, baseCss, frameScripts, defaultFooterLinks, page,
+};
@@ -15,9 +15,9 @@
@@ -15,9 +15,9 @@
const path = require('path');
const path = require('path');
const config = require('../config');
const config = require('../config');
-const chrome = require('./page-chrome');
+const pageFrame = require('./page-frame');
-const { esc, abs, snip, appUrl, isoDate, isoDuration, fmtDuration, fmtDate, SITE_NAME, NETWORK_URL } = chrome;
+const { esc, abs, snip, appUrl, isoDate, isoDuration, fmtDuration, fmtDate, SITE_NAME, NETWORK_URL } = pageFrame;
const VIDEO_MIME = { '.mp4': 'video/mp4', '.webm': 'video/webm', '.mkv': 'video/x-matroska' };
const VIDEO_MIME = { '.mp4': 'video/mp4', '.webm': 'video/webm', '.mkv': 'video/x-matroska' };
/** Link-preview and search crawlers: they want the page, never the bytes. */
/** Link-preview and search crawlers: they want the page, never the bytes. */
@@ -140,7 +140,7 @@ function renderWatchPage(kind, record) {
@@ -140,7 +140,7 @@ function renderWatchPage(kind, record) {
.player video { display: block; width: 100%; max-height: 72vh; background: #000; }
.player video { display: block; width: 100%; max-height: 72vh; background: #000; }
.h2 { font-size: 1rem; margin: 1.2rem 0 .3rem; color: var(--text); }`;
.h2 { font-size: 1rem; margin: 1.2rem 0 .3rem; color: var(--text); }`;
- return chrome.page({
+ return pageFrame.page({
seo: {
seo: {
title: `${title} — ${SITE_NAME}`, description, canonical, robots,
title: `${title} — ${SITE_NAME}`, description, canonical, robots,
image: thumb || undefined, ogType: 'video.other',
image: thumb || undefined, ogType: 'video.other',
@@ -212,7 +212,7 @@ function renderPastePage(paste) {
@@ -212,7 +212,7 @@ function renderPastePage(paste) {
.shot { margin: 0 0 1rem; }
.shot { margin: 0 0 1rem; }
.shot img { max-width: 100%; border-radius: 10px; border: 1px solid var(--line); }`;
.shot img { max-width: 100%; border-radius: 10px; border: 1px solid var(--line); }`;
- return chrome.page({
+ return pageFrame.page({
seo: {
seo: {
title: `${title} — ${SITE_NAME}`, description,
title: `${title} — ${SITE_NAME}`, description,
// Canonical on Community: search engines index the paste there, not here.
// Canonical on Community: search engines index the paste there, not here.
@@ -329,6 +329,17 @@ router.get('/a/:id', (req, res) => {
@@ -329,6 +329,17 @@ router.get('/a/:id', (req, res) => {
// Clips / Images / Text / Thumbnails / Files), each card linking back to its
// Clips / Images / Text / Thumbnails / Files), each card linking back to its
// source page in the owning app. See public/browse.js.
// source page in the owning app. See public/browse.js.
router.get('/', (req, res) => require('./browse').handle(req, res));
router.get('/', (req, res) => require('./browse').handle(req, res));
+
+// What shipped on OpenVibe.Media: the shared update log every OpenVibe site has.
+router.get('/updates', (req, res) => {
+ const pf = require('./page-frame');
+ const frame = require('openvibe-shared/frame');
+ res.set('Cache-Control', 'public, max-age=60').type('html').send(pf.page({
+ seo: { title: 'What shipped on OpenVibe.Media', description: 'Every change deployed to OpenVibe.Media, newest first, with the Patch notes that gather them.', canonical: pf.abs('/updates') },
+ body: frame.updatesBody({ service: 'media', siteName: 'OpenVibe.Media' }) + frame.shippedScript(),
+ footer: { variant: 'full' },
+ }));
+});
router.get('/browse', (req, res) => require('./browse').handle(req, res));
router.get('/browse', (req, res) => require('./browse').handle(req, res));
// ── Dev data APIs: transcripts / AI timelines / chat insight ─
// ── Dev data APIs: transcripts / AI timelines / chat insight ─
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
'use strict';
'use strict';
// The public pages: /v and /c watch pages on browser navigation (bytes otherwise),
// The public pages: /v and /c watch pages on browser navigation (bytes otherwise),
-// the paste viewer's Community canonical, and the shared chrome + SEO on all of them.
+// the paste viewer's Community canonical, and the OpenVibe Frame + SEO on all of them.
const assert = require('assert');
const assert = require('assert');
const fs = require('fs');
const fs = require('fs');
const os = require('os');
const os = require('os');
@@ -14,7 +14,7 @@ process.env.MEDIA_PUBLIC_URL = 'https://media.test';
@@ -14,7 +14,7 @@ process.env.MEDIA_PUBLIC_URL = 'https://media.test';
const db = require('../server/db/database');
const db = require('../server/db/database');
require('../server/views/service').ensureSchema();
require('../server/views/service').ensureSchema();
const pages = require('../server/public/pages');
const pages = require('../server/public/pages');
-const chrome = require('../server/public/page-chrome');
+const pageFrame = require('../server/public/page-frame');
// ── wantsHtmlPage: navigations and preview bots get the page, players get bytes ──
// ── wantsHtmlPage: navigations and preview bots get the page, players get bytes ──
const req = (headers, query = {}) => ({ method: 'GET', headers, query });
const req = (headers, query = {}) => ({ method: 'GET', headers, query });
@@ -27,7 +27,7 @@ assert.strictEqual(pages.wantsHtmlPage(req({ accept: '*/*' })), false, 'curl / f
@@ -27,7 +27,7 @@ assert.strictEqual(pages.wantsHtmlPage(req({ accept: '*/*' })), false, 'curl / f
assert.strictEqual(pages.wantsHtmlPage(req({ accept: '*/*', 'user-agent': 'Mozilla/5.0 (compatible; Discordbot/2.0)' })), true, 'preview bots get the page');
assert.strictEqual(pages.wantsHtmlPage(req({ accept: '*/*', 'user-agent': 'Mozilla/5.0 (compatible; Discordbot/2.0)' })), true, 'preview bots get the page');
assert.strictEqual(pages.wantsHtmlPage({ method: 'POST', headers: { accept: 'text/html' }, query: {} }), false);
assert.strictEqual(pages.wantsHtmlPage({ method: 'POST', headers: { accept: 'text/html' }, query: {} }), false);
-// ── Watch page: SEO + chrome + canonical rules ──
+// ── Watch page: SEO + Frame + canonical rules ──
const vod = {
const vod = {
id: 42, app_id: 'live', title: 'Late night <build>', description: 'We built a thing & it worked.',
id: 42, app_id: 'live', title: 'Late night <build>', description: 'We built a thing & it worked.',
file_path: '/x/vod-42.mp4', thumbnail_url: '/t/vod-42-1.jpg', duration_seconds: 3725, view_count: 1234,
file_path: '/x/vod-42.mp4', thumbnail_url: '/t/vod-42-1.jpg', duration_seconds: 3725, view_count: 1234,
@@ -75,7 +75,7 @@ assert.strictEqual(pld.keywords, 'js, demo');
@@ -75,7 +75,7 @@ assert.strictEqual(pld.keywords, 'js, demo');
const shot = pages.renderPastePage({ ...paste, slug: 'img-1', type: 'screenshot', screenshot_path: '/x/y.png', content: 'a caption' });
const shot = pages.renderPastePage({ ...paste, slug: 'img-1', type: 'screenshot', screenshot_path: '/x/y.png', content: 'a caption' });
assert.ok(shot.includes('<meta property="og:image" content="https://media.test/p/img-1/screenshot">'));
assert.ok(shot.includes('<meta property="og:image" content="https://media.test/p/img-1/screenshot">'));
assert.ok(shot.includes('"@type":"ImageObject"'));
assert.ok(shot.includes('"@type":"ImageObject"'));
-assert.ok(!/\bfree\b/i.test(html + ph + shot + chrome.baseCss()), 'no cost claims in served HTML');
+assert.ok(!/\bfree\b/i.test(html + ph + shot + pageFrame.baseCss()), 'no cost claims in served HTML');
// ── End to end through the router: navigation → page, ?raw=1 → bytes path ──
// ── End to end through the router: navigation → page, ?raw=1 → bytes path ──
const express = require('express');
const express = require('express');
new file mode 100644
new file mode 100644
@@ -0,0 +1,16 @@
@@ -0,0 +1,16 @@
+'use strict';
+// The shared update system on openvibe.media: the index shows what shipped, /updates is the log (the
+// markup every OpenVibe site renders, from openvibe-shared/frame), the footer links it, and the
+// navbar signs out through this site (logoutUrl).
+const assert = require('assert');
+const pageFrame = require('../server/public/page-frame');
+const frame = require('openvibe-shared/frame');
+
+const doc = pageFrame.page({ seo: { title: 'What shipped on OpenVibe.Media', description: 'x', canonical: pageFrame.abs('/updates') }, body: frame.updatesBody({ service: 'media', siteName: 'OpenVibe.Media' }), footer: { variant: 'full' } });
+assert.ok(doc.includes('What shipped on OpenVibe.Media') && doc.includes('data-ov-shipped="log" data-service="media"'));
+assert.ok(doc.includes('rel="canonical"') && doc.includes('/updates"'));
+assert.ok(doc.includes('"updates":"/updates"'), 'the footer links this site\'s log');
+assert.ok(doc.includes('"logoutUrl":"/auth/logout?next={path}"'), 'Sign out in the shared navbar ends this site\'s session');
+assert.ok(doc.includes('data-ov-shipped="latest" data-service="media" href="/updates"'), 'the footer\'s shipped line');
+assert.ok(!/chrome-ssr/.test(require('fs').readFileSync(require.resolve('../server/public/browse.js'), 'utf8')), 'openvibe-shared/frame, not the old name');
+console.log('shipped pages: all checks passed');