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)

OP OpenVibers committed 24 Sept 2026 · 3733437c5f · 9 files, +248 −218


Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex 2eca240..1416444 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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"
ddiff --git a/package.json b/package.json
iindex aba145f..0ac9ef8 100644
---- a/package.json
++++ b/package.json
@@@ -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"
}
}
ddiff --git a/server/public/browse.js b/server/public/browse.js
iindex 6c64f94..a75621f 100644
---- a/server/public/browse.js
++++ b/server/public/browse.js
@@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
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 &amp; 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>`;
}
ddiff --git a/server/public/page-chrome.js b/server/public/page-chrome.js
ddeleted file mode 100644
iindex dc2fdce..0000000
---- a/server/public/page-chrome.js
++++ /dev/null
@@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
--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,
--};
ddiff --git a/server/public/page-frame.js b/server/public/page-frame.js
nnew file mode 100644
iindex 0000000..a3c790c
---- /dev/null
++++ b/server/public/page-frame.js
@@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
++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,
++};
ddiff --git a/server/public/pages.js b/server/public/pages.js
iindex 152f7af..7f65e34 100644
---- a/server/public/pages.js
++++ b/server/public/pages.js
@@@ -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.
ddiff --git a/server/public/routes.js b/server/public/routes.js
iindex e804a71..67bdbf4 100644
---- a/server/public/routes.js
++++ b/server/public/routes.js
@@@ -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 ─
ddiff --git a/test/public-pages.test.js b/test/public-pages.test.js
iindex 6b080b5..69886b9 100644
---- a/test/public-pages.test.js
++++ b/test/public-pages.test.js
@@@ -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 / 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({ 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');
ddiff --git a/test/shipped-pages.test.js b/test/shipped-pages.test.js
nnew file mode 100644
iindex 0000000..e5cdb5c
---- /dev/null
++++ b/test/shipped-pages.test.js
@@@ -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');