openvibers/openvibe.media Commits
Media's front page: the newest media as a rail, not the whole index (23 phone screens -> 6) (#219)
Changes
@@@ -23,6 +23,7 @@ const { appUrl, watchPageUrl, isoDate } = pageFrame;const cache = require('openvibe-shared/cache-policy');const frame = require('openvibe-shared/frame');const showcase = require('openvibe-shared/showcase');++const kit = require('openvibe-shared/kit');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}`));@@@ -228,6 +229,10 @@ const TABS = [['all', 'All'], ['videos', 'Videos'], ['clips', 'Clips'], ['emotes// The index's own rules; page-frame's baseCss supplies the palette (--panel, --line, --muted, --acc …) and the reset.// Card rules are scoped to .card: baseCss styles .meta and .desc for the watch pages.const BROWSE_CSS = `++.sc-kinds{display:flex;flex-wrap:wrap;gap:8px;margin:4px 0 0}++.sc-kinds .ovk-btn{display:inline-flex;align-items:center;min-height:36px;padding:6px 13px;border:1px solid var(--line);border-radius:999px;background:var(--panel);color:inherit;font-weight:700;font-size:.88rem;text-decoration:none}.sc-kinds .ovk-btn:hover{border-color:var(--acc)}++.sc-media .ovk-i{width:1.15em;height:1.15em;flex:none;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;vertical-align:-.2em}++@media (min-width:760px){.sc-media .ovk-rail-track{grid-auto-flow:row;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));grid-auto-columns:auto;overflow:visible;margin:0;padding:2px 0 10px}.sc-media .ovk-rail-btn{display:none}}header.intro{display:flex;align-items:baseline;gap:.2rem .8rem;flex-wrap:wrap;padding:0 0 .4rem}header.intro h1{font-size:1.15rem;margin:0}header.intro .sub{color:var(--muted);font-size:.85rem}@@@ -256,9 +261,6 @@ nav.tabs a .n{opacity:.75;font-size:.78rem;margin-left:.3rem}nav.tabs a.on .n{op/* The list's heading takes the showcase sections' face and size (openvibe-shared/showcase.css), like the rest of the home. */.sec-h{font-family:var(--ov-font,'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif);font-size:clamp(22px,2.6vw,30px);letter-spacing:-.02em;margin:56px 0 .6rem}.sc-media .sec-h{margin-top:0} /* on the home the list is a showcase section, whose own margin spaces it */--.hero-stats{margin:0;display:grid;gap:10px}--.hero-stats div{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:12px 16px}--.hero-stats dt{font-size:1.25rem;font-weight:700}.hero-stats dd{margin:2px 0 0;color:var(--muted);font-size:.92rem}.faq{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:4px 16px;margin:8px 0}.faq[open]{padding-bottom:12px}.faq summary{cursor:pointer;font-weight:600;padding:8px 0}.faq p{margin:4px 0 0;color:var(--muted)}`;@@@ -321,10 +323,15 @@ const { url } = await objects.signedUrl(obj.id, { ttl: 300 });` },}) + `<section class="sc-sec" id="questions" aria-labelledby="faq-h"><h2 id="faq-h">Questions</h2>${HOME_FAQ.map((f) => `<details class="faq"><summary>${esc(f.q)}</summary><p>${esc(f.a)}</p></details>`).join('')}</section>`;}--function renderPage(tab, page, data, counts) {++// The kit card's type by media kind (its shape and its icon when there is no picture).++const CARD_TYPE = { video: 'video', vod: 'vod', clip: 'clip', emote: 'meme', sound: 'post', thumbnail: 'video', file: 'post' };++const asCard = (c) => kit.card({ href: c.view, title: c.title || c.kind, type: CARD_TYPE[c.kind] || 'post', image: c.thumb || '', ratio: '16/9', corner: c.duration ? fmtDur(c.duration) : '', badge: c.kind && c.kind !== 'video' ? String(c.kind).toUpperCase() : '', when: c.date || null, share: false });++++function renderPage(tab, page, data, counts, { home = false } = {}) {const pages = Math.max(1, Math.ceil(data.total / PAGE_SIZE));const nav = (p, label, dis) => dis ? `<span class="pg dis" aria-disabled="true">${label}</span>` : `<a class="pg" href="/?tab=${tab}&page=${p}">${label}</a>`;-- const first = tab === 'all' && page === 1;++ // The front page is "/" itself; "/?tab=all" (and every other tab and page) is the index.++ const first = home;const canonical = `${config.publicUrl}/${first ? '' : `?tab=${tab}${page > 1 ? `&page=${page}` : ''}`}`;// The one <h1> on the page: the showcase hero on the index's front page, the plain intro elsewhere.const stats = [@@@ -337,20 +344,20 @@ function renderPage(tab, page, data, counts) {accent: 'for OpenVibe and your apps.',lede: 'Every video, clip, picture and file on the OpenVibe sites lives here, and your apps get the same API: objects and S3, thumbnails in any size, streaming playback and live frames. Public media plays and downloads without an account.',actions: [++ // Two, so they sit side by side on a phone; watching lives on OpenVibe.Video, one tap from every card.{ label: 'Browse public media', href: '#media', primary: true },-- { label: 'Watch on OpenVibe.Video', href: 'https://openvibe.video/' },{ label: 'Use it from your app', href: '#api' },],-- html: stats.length ? `<dl class="hero-stats">${stats.map(([n, d]) => `<div><dt>${Number(n).toLocaleString('en-US')}</dt><dd>${d}</dd></div>`).join('')}</dl>` : undefined,-- }) + '<section class="sc-sec sc-part-of"><p class="sc-note">Part of <a href="https://openvibe.services/">OpenVibe Services</a>: the building blocks every OpenVibe product runs on, with one console, one account and one SDK.</p></section>' : '<header class="intro"><h1>Every public file on the network</h1><span class="sub">videos, clips, files & thumbnails from all OpenVibe sites</span></header>';++ // One strip of numbers (it scrolls sideways on a phone instead of stacking four tiles).++ html: stats.length ? kit.stats(stats, { label: 'OpenVibe.Media in numbers' }) : undefined,++ }) : '<header class="intro"><h1>Every public file on the network</h1><span class="sub">videos, clips, files & thumbnails from all OpenVibe sites</span></header>';// On the home the list is a section like the ones around it (the same inset, so its heading, tabs and grid line up// with the hero and the developer sections); later pages and tabs keep the plain index layout.const list = `<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>`;const body = `${heading}--${first ? `<section class="sc-sec sc-media" aria-labelledby="media"><h2 id="media" class="sec-h">Latest public media</h2>--${list}</section>` : list}++${first ? `<section class="sc-sec sc-media">${kit.rail({ id: 'media', title: 'Latest public media', more: { href: '/?tab=all', label: `See all ${data.total.toLocaleString('en-US')}` }, items: data.cards.slice(0, 12).map(asCard) })}<p class="sc-kinds" aria-label="Browse by kind">${TABS.filter(([k]) => k !== 'all' && (counts[k] ?? 0) > 0).map(([k, label]) => kit.button({ href: `/?tab=${k}`, label: `${label} ${(counts[k] ?? 0).toLocaleString('en-US')}`, kind: 'quiet', small: true })).join(' ')}</p></section>` : list}${first ? developerSections() : ''}${first ? frame.shipped({ service: 'media', title: 'Recently shipped on OpenVibe.Media' }) : ''}`;return pageFrame.page({@@@ -360,6 +367,7 @@ ${first ? frame.shipped({ service: 'media', title: 'Recently shipped on OpenVibejsonLd: page === 1 ? [...browseJsonLd(tab, data.cards), ...(first ? [homeFaqJsonLd()] : [])] : [],},css: BROWSE_CSS,++ kit: first,body,// Later pages are noindex and carry no structured data, the summary's WebPage included.summary: page === 1 ? {@@@ -382,11 +390,11 @@ async function handle(req, res) {// The listing names media that can become private at any moment: no stale serving, or a cache// could still hand out the old listing afterwards.res.set({ 'Cache-Control': cache.htmlHeaders({ maxAge: 30, swr: 0 }), 'Content-Type': 'text/html; charset=utf-8' });-- res.send(renderPage(tab, page, data, await tabCounts()));++ res.send(renderPage(tab, page, data, await tabCounts(), { home: req.query.tab == null && page === 1 }));} catch (err) {console.error('[Browse] render error:', err.message);res.status(500).send('Failed to render media index');}}--module.exports = { handle };++module.exports = { handle, renderPage };@@@ -82,7 +82,7 @@ function fmtDate(dt) {* `shell.page`'s `head`, so the shell still owns title, description, canonical, robots, og/twitter* mirroring and JSON-LD.*/--function extraHead(seo, css) {++function extraHead(seo, css, kit = false) {const image = seo.image || DEFAULT_OG_IMAGE;const videoTags = seo.video && seo.video.url ? [`<meta property="og:video" content="${esc(seo.video.url)}">`,@@@ -93,6 +93,9 @@ function extraHead(seo, css) {appIcon.headTags({ site: 'media', iconBase: '/assets' }),// openvibe-shared/showcase's stylesheet (the index's hero; the kit's sections on any page).`<link rel="stylesheet" href="${ovServe.url('showcase.css')}">`,++ // A page with the kit's cards and rails (the front page's latest media) carries room.css, their look; it needs++ // neither kit.css nor kit-web.js (no menus on these cards, and wide screens wrap the rail instead of scrolling it).++ kit ? `<link rel="stylesheet" href="${ovServe.url('room.css')}">` : '',// A page with a video plays it in the network's player (openvibe-shared player.js/css on video[data-ov-player]).seo.video ? `<link rel="stylesheet" href="${ovServe.url('player.css')}">\n<script src="${ovServe.url('player.js')}" defer></script>` : '',`<style>${baseCss()}${css}</style>`,@@@ -201,7 +204,7 @@ function oneGraph(nodes) {return { '@context': 'https://schema.org', '@graph': [].concat(nodes).map(({ '@context': _context, ...node }) => node) };}--function page({ seo, css = '', body, history, footer, summary, agentLayer = false }) {++function page({ seo, css = '', body, history, footer, summary, agentLayer = false, kit = false }) {const sum = summary == null ? null : typeof summary === 'object' ? summary : { summary };const summaryData = sum ? seoLib.pageSummary({ title: seo.title, summary: sum.summary, facts: sum.facts, url: sum.url || seo.canonical, updated: sum.updated }) : null;const html = shell.page({@@@ -217,7 +220,7 @@ function page({ seo, css = '', body, history, footer, summary, agentLayer = falstype: seo.ogType,robots: seo.robots || 'index, follow',jsonLd: oneGraph([...[].concat(seo.jsonLd || []), ...(summaryData ? [summaryData.jsonLd] : [])]),-- head: extraHead(seo, css) + (summaryData && summaryData.meta ? `<meta name="ai-summary" content="${summaryData.meta}">` : '') + footerBootScript(footer),++ head: extraHead(seo, css, kit) + (summaryData && summaryData.meta ? `<meta name="ai-summary" content="${summaryData.meta}">` : '') + footerBootScript(footer),body: `${summaryData ? summaryData.body : ''}<div id="navbar-mount"></div>\n<main id="main">\n${body}\n</main>`,navLinks: [{ label: 'Videos', href: '/?tab=videos' }, { label: 'Clips', href: '/?tab=clips' }],navbar: navOptions({ history }),@@@ -23,7 +23,7 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');jsRawKB: 290, // 286 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 279.9 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 274.1 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 260.1 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 248.9 Shared 4.12.0: the light/dark switch, the seasonal mark and the theme pairs; 241 with openvibe-shared 4.9.0 (status text twins in every theme, 2026-10-23); 230.9 (was 197.7)jsBrotliKB: 72, // 70.1 Shared 4.33.0 navbar glyph widths; 68.2 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 66.7 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 64.1 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 61.5 Shared 4.12.0: the light/dark switch, the seasonal mark and the theme pairs; 56.1 with openvibe-shared 4.10.0 (Report a problem in the footer, 2026-10-24); 53.7 (was 45.6)cssFiles: 2, // 1 (showcase.css)-- cssRawKB: 28, // 23.3 openvibe-shared 4.26.1 (hero visual); 17.8 with Shared 4.13.0 (2026-10-26); 11.6 same-origin (showcase.css; was 0)++ cssRawKB: 44, // 38.6 the front page's latest media as a rail of kit cards (room.css, 15.7 KB raw): the page shed a 48-card grid, 17 of its 23 phone screens; 23.3 openvibe-shared 4.26.1 (hero visual); 17.8 with Shared 4.13.0 (2026-10-26); 11.6 same-origin (showcase.css; was 0)externalFiles: 3, // 2};@@@ -163,8 +163,15 @@ const http = require('http');assert.ok(ld.some((x) => x['@type'] === 'WebPage'), 'the summary\'s WebPage');assert.strictEqual(h1s(r.body), 1, 'exactly one <h1>');assert.strictEqual((r.body.match(/<!doctype/gi) || []).length, 1, 'one document');-- for (const k of ['all', 'videos', 'clips', 'emotes', 'sounds', 'thumbnails', 'files']) assert.ok(r.body.includes(`href="/?tab=${k}"`), `tab ${k}`);-- assert.ok(r.body.includes('class="pager"') && r.body.includes('Page 1 /'), 'the pager');++ // The front page is a taste and a door: the newest items as a rail, the kinds that have items as filters, and++ // See all → the index (/?tab=all), which keeps every tab and the pager.++ assert.ok(r.body.includes('class="ovk-rail') && r.body.includes('class="ovk-card'), 'the newest items as a rail of cards');++ assert.ok(r.body.includes('href="/?tab=all"') && /See all [0-9,]+/.test(r.body), 'See all opens the index');++ assert.ok(/\/shared\/room\.css\?v=/.test(r.body) && !/\/shared\/kit\.css\?v=/.test(r.body) && !/\/shared\/kit-web\.js/.test(r.body), 'only the cards\' and rails\' look (room.css), no kit.css or kit-web.js');++ assert.ok(!r.body.includes('class="pager"'), 'no pager on the front page');++ const index = await get('/?tab=all', nav);++ for (const k of ['all', 'videos', 'clips', 'emotes', 'sounds', 'thumbnails', 'files']) assert.ok(index.body.includes(`href="/?tab=${k}"`), `tab ${k}`);++ assert.ok(index.body.includes('class="pager"') && index.body.includes('Page 1 /') && !index.body.includes('sc-hero'), 'the index: every tab, the pager, no hero');assert.ok(r.body.includes('<meta name="robots" content="index, follow">'));// The front page is also Media's public home for developers: what it does, how an app uses it, the limits it// enforces (limitsOf, the same numbers as /limits.json) and the questions, which are FAQPage data too.
@@ -23,6 +23,7 @@ const { appUrl, watchPageUrl, isoDate } = pageFrame;
@@ -23,6 +23,7 @@ const { appUrl, watchPageUrl, isoDate } = pageFrame;
const cache = require('openvibe-shared/cache-policy');
const cache = require('openvibe-shared/cache-policy');
const frame = require('openvibe-shared/frame');
const frame = require('openvibe-shared/frame');
const showcase = require('openvibe-shared/showcase');
const showcase = require('openvibe-shared/showcase');
+const kit = require('openvibe-shared/kit');
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}`));
@@ -228,6 +229,10 @@ const TABS = [['all', 'All'], ['videos', 'Videos'], ['clips', 'Clips'], ['emotes
@@ -228,6 +229,10 @@ const TABS = [['all', 'All'], ['videos', 'Videos'], ['clips', 'Clips'], ['emotes
// The index's own rules; page-frame's baseCss supplies the palette (--panel, --line, --muted, --acc …) and the reset.
// The index's own rules; page-frame's baseCss supplies the palette (--panel, --line, --muted, --acc …) and the reset.
// Card rules are scoped to .card: baseCss styles .meta and .desc for the watch pages.
// Card rules are scoped to .card: baseCss styles .meta and .desc for the watch pages.
const BROWSE_CSS = `
const BROWSE_CSS = `
+.sc-kinds{display:flex;flex-wrap:wrap;gap:8px;margin:4px 0 0}
+.sc-kinds .ovk-btn{display:inline-flex;align-items:center;min-height:36px;padding:6px 13px;border:1px solid var(--line);border-radius:999px;background:var(--panel);color:inherit;font-weight:700;font-size:.88rem;text-decoration:none}.sc-kinds .ovk-btn:hover{border-color:var(--acc)}
+.sc-media .ovk-i{width:1.15em;height:1.15em;flex:none;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;vertical-align:-.2em}
+@media (min-width:760px){.sc-media .ovk-rail-track{grid-auto-flow:row;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));grid-auto-columns:auto;overflow:visible;margin:0;padding:2px 0 10px}.sc-media .ovk-rail-btn{display:none}}
header.intro{display:flex;align-items:baseline;gap:.2rem .8rem;flex-wrap:wrap;padding:0 0 .4rem}
header.intro{display:flex;align-items:baseline;gap:.2rem .8rem;flex-wrap:wrap;padding:0 0 .4rem}
header.intro h1{font-size:1.15rem;margin:0}
header.intro h1{font-size:1.15rem;margin:0}
header.intro .sub{color:var(--muted);font-size:.85rem}
header.intro .sub{color:var(--muted);font-size:.85rem}
@@ -256,9 +261,6 @@ nav.tabs a .n{opacity:.75;font-size:.78rem;margin-left:.3rem}nav.tabs a.on .n{op
@@ -256,9 +261,6 @@ nav.tabs a .n{opacity:.75;font-size:.78rem;margin-left:.3rem}nav.tabs a.on .n{op
/* The list's heading takes the showcase sections' face and size (openvibe-shared/showcase.css), like the rest of the home. */
/* The list's heading takes the showcase sections' face and size (openvibe-shared/showcase.css), like the rest of the home. */
.sec-h{font-family:var(--ov-font,'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif);font-size:clamp(22px,2.6vw,30px);letter-spacing:-.02em;margin:56px 0 .6rem}
.sec-h{font-family:var(--ov-font,'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif);font-size:clamp(22px,2.6vw,30px);letter-spacing:-.02em;margin:56px 0 .6rem}
.sc-media .sec-h{margin-top:0} /* on the home the list is a showcase section, whose own margin spaces it */
.sc-media .sec-h{margin-top:0} /* on the home the list is a showcase section, whose own margin spaces it */
-.hero-stats{margin:0;display:grid;gap:10px}
-.hero-stats div{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:12px 16px}
-.hero-stats dt{font-size:1.25rem;font-weight:700}.hero-stats dd{margin:2px 0 0;color:var(--muted);font-size:.92rem}
.faq{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:4px 16px;margin:8px 0}
.faq{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:4px 16px;margin:8px 0}
.faq[open]{padding-bottom:12px}.faq summary{cursor:pointer;font-weight:600;padding:8px 0}.faq p{margin:4px 0 0;color:var(--muted)}
.faq[open]{padding-bottom:12px}.faq summary{cursor:pointer;font-weight:600;padding:8px 0}.faq p{margin:4px 0 0;color:var(--muted)}
`;
`;
@@ -321,10 +323,15 @@ const { url } = await objects.signedUrl(obj.id, { ttl: 300 });` },
@@ -321,10 +323,15 @@ const { url } = await objects.signedUrl(obj.id, { ttl: 300 });` },
}) + `<section class="sc-sec" id="questions" aria-labelledby="faq-h"><h2 id="faq-h">Questions</h2>${HOME_FAQ.map((f) => `<details class="faq"><summary>${esc(f.q)}</summary><p>${esc(f.a)}</p></details>`).join('')}</section>`;
}) + `<section class="sc-sec" id="questions" aria-labelledby="faq-h"><h2 id="faq-h">Questions</h2>${HOME_FAQ.map((f) => `<details class="faq"><summary>${esc(f.q)}</summary><p>${esc(f.a)}</p></details>`).join('')}</section>`;
}
}
-function renderPage(tab, page, data, counts) {
+// The kit card's type by media kind (its shape and its icon when there is no picture).
+const CARD_TYPE = { video: 'video', vod: 'vod', clip: 'clip', emote: 'meme', sound: 'post', thumbnail: 'video', file: 'post' };
+const asCard = (c) => kit.card({ href: c.view, title: c.title || c.kind, type: CARD_TYPE[c.kind] || 'post', image: c.thumb || '', ratio: '16/9', corner: c.duration ? fmtDur(c.duration) : '', badge: c.kind && c.kind !== 'video' ? String(c.kind).toUpperCase() : '', when: c.date || null, share: false });
+
+function renderPage(tab, page, data, counts, { home = false } = {}) {
const pages = Math.max(1, Math.ceil(data.total / PAGE_SIZE));
const pages = Math.max(1, Math.ceil(data.total / PAGE_SIZE));
const nav = (p, label, dis) => dis ? `<span class="pg dis" aria-disabled="true">${label}</span>` : `<a class="pg" href="/?tab=${tab}&page=${p}">${label}</a>`;
const nav = (p, label, dis) => dis ? `<span class="pg dis" aria-disabled="true">${label}</span>` : `<a class="pg" href="/?tab=${tab}&page=${p}">${label}</a>`;
- const first = tab === 'all' && page === 1;
+ // The front page is "/" itself; "/?tab=all" (and every other tab and page) is the index.
+ const first = home;
const canonical = `${config.publicUrl}/${first ? '' : `?tab=${tab}${page > 1 ? `&page=${page}` : ''}`}`;
const canonical = `${config.publicUrl}/${first ? '' : `?tab=${tab}${page > 1 ? `&page=${page}` : ''}`}`;
// The one <h1> on the page: the showcase hero on the index's front page, the plain intro elsewhere.
// The one <h1> on the page: the showcase hero on the index's front page, the plain intro elsewhere.
const stats = [
const stats = [
@@ -337,20 +344,20 @@ function renderPage(tab, page, data, counts) {
@@ -337,20 +344,20 @@ function renderPage(tab, page, data, counts) {
accent: 'for OpenVibe and your apps.',
accent: 'for OpenVibe and your apps.',
lede: 'Every video, clip, picture and file on the OpenVibe sites lives here, and your apps get the same API: objects and S3, thumbnails in any size, streaming playback and live frames. Public media plays and downloads without an account.',
lede: 'Every video, clip, picture and file on the OpenVibe sites lives here, and your apps get the same API: objects and S3, thumbnails in any size, streaming playback and live frames. Public media plays and downloads without an account.',
actions: [
actions: [
+ // Two, so they sit side by side on a phone; watching lives on OpenVibe.Video, one tap from every card.
{ label: 'Browse public media', href: '#media', primary: true },
{ label: 'Browse public media', href: '#media', primary: true },
- { label: 'Watch on OpenVibe.Video', href: 'https://openvibe.video/' },
{ label: 'Use it from your app', href: '#api' },
{ label: 'Use it from your app', href: '#api' },
],
],
- html: stats.length ? `<dl class="hero-stats">${stats.map(([n, d]) => `<div><dt>${Number(n).toLocaleString('en-US')}</dt><dd>${d}</dd></div>`).join('')}</dl>` : undefined,
- }) + '<section class="sc-sec sc-part-of"><p class="sc-note">Part of <a href="https://openvibe.services/">OpenVibe Services</a>: the building blocks every OpenVibe product runs on, with one console, one account and one SDK.</p></section>' : '<header class="intro"><h1>Every public file on the network</h1><span class="sub">videos, clips, files & thumbnails from all OpenVibe sites</span></header>';
+ // One strip of numbers (it scrolls sideways on a phone instead of stacking four tiles).
+ html: stats.length ? kit.stats(stats, { label: 'OpenVibe.Media in numbers' }) : undefined,
+ }) : '<header class="intro"><h1>Every public file on the network</h1><span class="sub">videos, clips, files & thumbnails from all OpenVibe sites</span></header>';
// On the home the list is a section like the ones around it (the same inset, so its heading, tabs and grid line up
// On the home the list is a section like the ones around it (the same inset, so its heading, tabs and grid line up
// with the hero and the developer sections); later pages and tabs keep the plain index layout.
// with the hero and the developer sections); later pages and tabs keep the plain index layout.
const list = `<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>
const list = `<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>`;
const body = `${heading}
const body = `${heading}
-${first ? `<section class="sc-sec sc-media" aria-labelledby="media"><h2 id="media" class="sec-h">Latest public media</h2>
-${list}</section>` : list}
+${first ? `<section class="sc-sec sc-media">${kit.rail({ id: 'media', title: 'Latest public media', more: { href: '/?tab=all', label: `See all ${data.total.toLocaleString('en-US')}` }, items: data.cards.slice(0, 12).map(asCard) })}<p class="sc-kinds" aria-label="Browse by kind">${TABS.filter(([k]) => k !== 'all' && (counts[k] ?? 0) > 0).map(([k, label]) => kit.button({ href: `/?tab=${k}`, label: `${label} ${(counts[k] ?? 0).toLocaleString('en-US')}`, kind: 'quiet', small: true })).join(' ')}</p></section>` : list}
${first ? developerSections() : ''}
${first ? developerSections() : ''}
${first ? frame.shipped({ service: 'media', title: 'Recently shipped on OpenVibe.Media' }) : ''}`;
${first ? frame.shipped({ service: 'media', title: 'Recently shipped on OpenVibe.Media' }) : ''}`;
return pageFrame.page({
return pageFrame.page({
@@ -360,6 +367,7 @@ ${first ? frame.shipped({ service: 'media', title: 'Recently shipped on OpenVibe
@@ -360,6 +367,7 @@ ${first ? frame.shipped({ service: 'media', title: 'Recently shipped on OpenVibe
jsonLd: page === 1 ? [...browseJsonLd(tab, data.cards), ...(first ? [homeFaqJsonLd()] : [])] : [],
jsonLd: page === 1 ? [...browseJsonLd(tab, data.cards), ...(first ? [homeFaqJsonLd()] : [])] : [],
},
},
css: BROWSE_CSS,
css: BROWSE_CSS,
+ kit: first,
body,
body,
// Later pages are noindex and carry no structured data, the summary's WebPage included.
// Later pages are noindex and carry no structured data, the summary's WebPage included.
summary: page === 1 ? {
summary: page === 1 ? {
@@ -382,11 +390,11 @@ async function handle(req, res) {
@@ -382,11 +390,11 @@ async function handle(req, res) {
// The listing names media that can become private at any moment: no stale serving, or a cache
// The listing names media that can become private at any moment: no stale serving, or a cache
// could still hand out the old listing afterwards.
// could still hand out the old listing afterwards.
res.set({ 'Cache-Control': cache.htmlHeaders({ maxAge: 30, swr: 0 }), 'Content-Type': 'text/html; charset=utf-8' });
res.set({ 'Cache-Control': cache.htmlHeaders({ maxAge: 30, swr: 0 }), 'Content-Type': 'text/html; charset=utf-8' });
- res.send(renderPage(tab, page, data, await tabCounts()));
+ res.send(renderPage(tab, page, data, await tabCounts(), { home: req.query.tab == null && page === 1 }));
} catch (err) {
} catch (err) {
console.error('[Browse] render error:', err.message);
console.error('[Browse] render error:', err.message);
res.status(500).send('Failed to render media index');
res.status(500).send('Failed to render media index');
}
}
}
}
-module.exports = { handle };
+module.exports = { handle, renderPage };
@@ -82,7 +82,7 @@ function fmtDate(dt) {
@@ -82,7 +82,7 @@ function fmtDate(dt) {
* `shell.page`'s `head`, so the shell still owns title, description, canonical, robots, og/twitter
* `shell.page`'s `head`, so the shell still owns title, description, canonical, robots, og/twitter
* mirroring and JSON-LD.
* mirroring and JSON-LD.
*/
*/
-function extraHead(seo, css) {
+function extraHead(seo, css, kit = false) {
const image = seo.image || DEFAULT_OG_IMAGE;
const image = seo.image || DEFAULT_OG_IMAGE;
const videoTags = seo.video && seo.video.url ? [
const videoTags = seo.video && seo.video.url ? [
`<meta property="og:video" content="${esc(seo.video.url)}">`,
`<meta property="og:video" content="${esc(seo.video.url)}">`,
@@ -93,6 +93,9 @@ function extraHead(seo, css) {
@@ -93,6 +93,9 @@ function extraHead(seo, css) {
appIcon.headTags({ site: 'media', iconBase: '/assets' }),
appIcon.headTags({ site: 'media', iconBase: '/assets' }),
// openvibe-shared/showcase's stylesheet (the index's hero; the kit's sections on any page).
// openvibe-shared/showcase's stylesheet (the index's hero; the kit's sections on any page).
`<link rel="stylesheet" href="${ovServe.url('showcase.css')}">`,
`<link rel="stylesheet" href="${ovServe.url('showcase.css')}">`,
+ // A page with the kit's cards and rails (the front page's latest media) carries room.css, their look; it needs
+ // neither kit.css nor kit-web.js (no menus on these cards, and wide screens wrap the rail instead of scrolling it).
+ kit ? `<link rel="stylesheet" href="${ovServe.url('room.css')}">` : '',
// A page with a video plays it in the network's player (openvibe-shared player.js/css on video[data-ov-player]).
// A page with a video plays it in the network's player (openvibe-shared player.js/css on video[data-ov-player]).
seo.video ? `<link rel="stylesheet" href="${ovServe.url('player.css')}">\n<script src="${ovServe.url('player.js')}" defer></script>` : '',
seo.video ? `<link rel="stylesheet" href="${ovServe.url('player.css')}">\n<script src="${ovServe.url('player.js')}" defer></script>` : '',
`<style>${baseCss()}${css}</style>`,
`<style>${baseCss()}${css}</style>`,
@@ -201,7 +204,7 @@ function oneGraph(nodes) {
@@ -201,7 +204,7 @@ function oneGraph(nodes) {
return { '@context': 'https://schema.org', '@graph': [].concat(nodes).map(({ '@context': _context, ...node }) => node) };
return { '@context': 'https://schema.org', '@graph': [].concat(nodes).map(({ '@context': _context, ...node }) => node) };
}
}
-function page({ seo, css = '', body, history, footer, summary, agentLayer = false }) {
+function page({ seo, css = '', body, history, footer, summary, agentLayer = false, kit = false }) {
const sum = summary == null ? null : typeof summary === 'object' ? summary : { summary };
const sum = summary == null ? null : typeof summary === 'object' ? summary : { summary };
const summaryData = sum ? seoLib.pageSummary({ title: seo.title, summary: sum.summary, facts: sum.facts, url: sum.url || seo.canonical, updated: sum.updated }) : null;
const summaryData = sum ? seoLib.pageSummary({ title: seo.title, summary: sum.summary, facts: sum.facts, url: sum.url || seo.canonical, updated: sum.updated }) : null;
const html = shell.page({
const html = shell.page({
@@ -217,7 +220,7 @@ function page({ seo, css = '', body, history, footer, summary, agentLayer = fals
@@ -217,7 +220,7 @@ function page({ seo, css = '', body, history, footer, summary, agentLayer = fals
type: seo.ogType,
type: seo.ogType,
robots: seo.robots || 'index, follow',
robots: seo.robots || 'index, follow',
jsonLd: oneGraph([...[].concat(seo.jsonLd || []), ...(summaryData ? [summaryData.jsonLd] : [])]),
jsonLd: oneGraph([...[].concat(seo.jsonLd || []), ...(summaryData ? [summaryData.jsonLd] : [])]),
- head: extraHead(seo, css) + (summaryData && summaryData.meta ? `<meta name="ai-summary" content="${summaryData.meta}">` : '') + footerBootScript(footer),
+ head: extraHead(seo, css, kit) + (summaryData && summaryData.meta ? `<meta name="ai-summary" content="${summaryData.meta}">` : '') + footerBootScript(footer),
body: `${summaryData ? summaryData.body : ''}<div id="navbar-mount"></div>\n<main id="main">\n${body}\n</main>`,
body: `${summaryData ? summaryData.body : ''}<div id="navbar-mount"></div>\n<main id="main">\n${body}\n</main>`,
navLinks: [{ label: 'Videos', href: '/?tab=videos' }, { label: 'Clips', href: '/?tab=clips' }],
navLinks: [{ label: 'Videos', href: '/?tab=videos' }, { label: 'Clips', href: '/?tab=clips' }],
navbar: navOptions({ history }),
navbar: navOptions({ history }),
@@ -23,7 +23,7 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');
@@ -23,7 +23,7 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');
jsRawKB: 290, // 286 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 279.9 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 274.1 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 260.1 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 248.9 Shared 4.12.0: the light/dark switch, the seasonal mark and the theme pairs; 241 with openvibe-shared 4.9.0 (status text twins in every theme, 2026-10-23); 230.9 (was 197.7)
jsRawKB: 290, // 286 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 279.9 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 274.1 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 260.1 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 248.9 Shared 4.12.0: the light/dark switch, the seasonal mark and the theme pairs; 241 with openvibe-shared 4.9.0 (status text twins in every theme, 2026-10-23); 230.9 (was 197.7)
jsBrotliKB: 72, // 70.1 Shared 4.33.0 navbar glyph widths; 68.2 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 66.7 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 64.1 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 61.5 Shared 4.12.0: the light/dark switch, the seasonal mark and the theme pairs; 56.1 with openvibe-shared 4.10.0 (Report a problem in the footer, 2026-10-24); 53.7 (was 45.6)
jsBrotliKB: 72, // 70.1 Shared 4.33.0 navbar glyph widths; 68.2 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 66.7 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 64.1 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 61.5 Shared 4.12.0: the light/dark switch, the seasonal mark and the theme pairs; 56.1 with openvibe-shared 4.10.0 (Report a problem in the footer, 2026-10-24); 53.7 (was 45.6)
cssFiles: 2, // 1 (showcase.css)
cssFiles: 2, // 1 (showcase.css)
- cssRawKB: 28, // 23.3 openvibe-shared 4.26.1 (hero visual); 17.8 with Shared 4.13.0 (2026-10-26); 11.6 same-origin (showcase.css; was 0)
+ cssRawKB: 44, // 38.6 the front page's latest media as a rail of kit cards (room.css, 15.7 KB raw): the page shed a 48-card grid, 17 of its 23 phone screens; 23.3 openvibe-shared 4.26.1 (hero visual); 17.8 with Shared 4.13.0 (2026-10-26); 11.6 same-origin (showcase.css; was 0)
externalFiles: 3, // 2
externalFiles: 3, // 2
};
};
@@ -163,8 +163,15 @@ const http = require('http');
@@ -163,8 +163,15 @@ const http = require('http');
assert.ok(ld.some((x) => x['@type'] === 'WebPage'), 'the summary\'s WebPage');
assert.ok(ld.some((x) => x['@type'] === 'WebPage'), 'the summary\'s WebPage');
assert.strictEqual(h1s(r.body), 1, 'exactly one <h1>');
assert.strictEqual(h1s(r.body), 1, 'exactly one <h1>');
assert.strictEqual((r.body.match(/<!doctype/gi) || []).length, 1, 'one document');
assert.strictEqual((r.body.match(/<!doctype/gi) || []).length, 1, 'one document');
- for (const k of ['all', 'videos', 'clips', 'emotes', 'sounds', 'thumbnails', 'files']) assert.ok(r.body.includes(`href="/?tab=${k}"`), `tab ${k}`);
- assert.ok(r.body.includes('class="pager"') && r.body.includes('Page 1 /'), 'the pager');
+ // The front page is a taste and a door: the newest items as a rail, the kinds that have items as filters, and
+ // See all → the index (/?tab=all), which keeps every tab and the pager.
+ assert.ok(r.body.includes('class="ovk-rail') && r.body.includes('class="ovk-card'), 'the newest items as a rail of cards');
+ assert.ok(r.body.includes('href="/?tab=all"') && /See all [0-9,]+/.test(r.body), 'See all opens the index');
+ assert.ok(/\/shared\/room\.css\?v=/.test(r.body) && !/\/shared\/kit\.css\?v=/.test(r.body) && !/\/shared\/kit-web\.js/.test(r.body), 'only the cards\' and rails\' look (room.css), no kit.css or kit-web.js');
+ assert.ok(!r.body.includes('class="pager"'), 'no pager on the front page');
+ const index = await get('/?tab=all', nav);
+ for (const k of ['all', 'videos', 'clips', 'emotes', 'sounds', 'thumbnails', 'files']) assert.ok(index.body.includes(`href="/?tab=${k}"`), `tab ${k}`);
+ assert.ok(index.body.includes('class="pager"') && index.body.includes('Page 1 /') && !index.body.includes('sc-hero'), 'the index: every tab, the pager, no hero');
assert.ok(r.body.includes('<meta name="robots" content="index, follow">'));
assert.ok(r.body.includes('<meta name="robots" content="index, follow">'));
// The front page is also Media's public home for developers: what it does, how an app uses it, the limits it
// The front page is also Media's public home for developers: what it does, how an app uses it, the limits it
// enforces (limitsOf, the same numbers as /limits.json) and the questions, which are FAQPage data too.
// enforces (limitsOf, the same numbers as /limits.json) and the questions, which are FAQPage data too.