openvibers/openvibe.media Commits

Media's front page: the newest media as a rail, not the whole index (23 phone screens -> 6) (#219)

OP OpenVibers committed 15 days ago · d9850087af · 4 files, +36 −18

The front page embedded the full public index: a page of 48 cards, one per row on a phone, 14,485 px of the page's
19,600. It now shows a taste and a door: the 12 newest items as a kit rail (swipe on a phone; a wrapping grid from
760 px, so no arrow script), See all N -> the index at /?tab=all (unchanged: every tab, the pager), and the kinds that
have items as quick filters. The hero's numbers are one kit.stats strip instead of four stacked tiles, the hero keeps
two actions so they sit side by side on a phone, and the repeated 'Part of OpenVibe Services' paragraph goes (the
eyebrow says it). Only room.css is added (no kit.css, no kit-web.js): cssRawKB budget 28 -> 44 (measured 38.6), stated
in the budget's note; JS unchanged.

Changes

ddiff --git a/server/public/browse.js b/server/public/browse.js
iindex 1a0eb31..1c41098 100644
---- a/server/public/browse.js
++++ b/server/public/browse.js
@@@ -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, '&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}`));
@@@ -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 &amp; 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 &amp; 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 OpenVibe
jsonLd: 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 };
ddiff --git a/server/public/page-frame.js b/server/public/page-frame.js
iindex c07216f..78cdbdc 100644
---- a/server/public/page-frame.js
++++ b/server/public/page-frame.js
@@@ -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 = fals
type: 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 }),
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
iindex 64dfffc..88f067e 100644
---- a/test/perf-budget.test.js
++++ b/test/perf-budget.test.js
@@@ -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
};
ddiff --git a/test/public-pages.test.js b/test/public-pages.test.js
iindex 56c08a9..0fb340d 100644
---- a/test/public-pages.test.js
++++ b/test/public-pages.test.js
@@@ -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.