openvibers/openvibe.live Commits

Polish the Rundown feed and sticky search bar (#720)

OP OpenVibers committed 4 days ago · 8277b9b58b · 7 files, +327 −50

Changes

ddiff --git a/public/css/features/rundown.css b/public/css/features/rundown.css
iindex bfb51e99..6cb39bbb 100644
---- a/public/css/features/rundown.css
++++ b/public/css/features/rundown.css
@@@ -243,8 +243,11 @@ a.rd-use:hover b { color: var(--accent); }
/* ── The bar: search and filters (public/js/rundown.js) ──────────────────────────────────
Sticky under the navbar on every Rundown page. The phone gets the search box and one
"Filters (N)" button, which opens a sheet with the same sections. */
--.rd-bar { position: sticky; top: var(--navbar-h); z-index: 30; margin: 0 0 10px; padding: 10px 0;
-- background: var(--bg-primary, var(--bg-secondary)); border-bottom: 1px solid var(--border); }
++.rd-bar { position: sticky; top: var(--rd-nav-bottom, var(--ovnav-h, var(--navbar-h))); z-index: 30; box-sizing: border-box; width: 100vw;
++ margin: 0 0 10px calc(50% - 50vw); padding: 10px max(clamp(16px, 2vw, 28px), calc((100vw - var(--content-max, 1440px)) / 2 + 28px));
++ background: var(--bg-primary, var(--bg-secondary)); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
++ border-bottom: 1px solid transparent; transition: border-color .18s ease; }
++.rd-bar.is-stuck { border-bottom-color: var(--border); }
.rd-bar-row { display: flex; gap: 10px; align-items: center; }
.rd-search { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; height: 44px; padding: 0 12px;
border-radius: 12px; background: var(--bg-card); border: 1px solid var(--border); }
@@@ -461,3 +464,84 @@ body.rd-sheet-open { overflow: hidden; }
#rundown-root .rd-loadmore { min-height: 44px; }
#rundown-root .rd-more-link { min-height: 44px; display: inline-flex; align-items: center; }
}
++
++/* The feed uses one reading rhythm from the masthead to the last take. */
++#rundown-root .rd-mast { margin: 18px 0 12px; padding: 0; border: 0; }
++#rundown-root .rd-mast-line { padding: 0; border: 0; letter-spacing: 0; text-transform: none; }
++#rundown-root .rd-title { margin: 10px 0 8px; font-size: clamp(2rem, 5vw, 3.6rem); line-height: 1.05; font-weight: 800; text-transform: none; }
++#rundown-root .rd-tagline { max-width: 62ch; font-size: 1rem; line-height: 1.5; }
++#rundown-root .rd-date-chip { display: inline-flex; align-items: center; min-height: 26px; padding: 2px 10px; border-radius: 999px; background: var(--bg-tertiary); color: var(--text-secondary); font-size: .8rem; font-weight: 700; }
++#rundown-root .rd-stats { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; margin: 16px 0; }
++#rundown-root .rd-stat { display: grid; grid-template-columns: 22px auto; align-items: center; column-gap: 7px; padding: 11px 12px; border: 1px solid var(--border); border-radius: 14px; background: var(--bg-card); }
++#rundown-root .rd-stat i { grid-row: span 2; color: var(--accent-light, var(--accent)); }
++#rundown-root .rd-stat strong { font-size: 1.3rem; line-height: 1.2; font-variant-numeric: tabular-nums; }
++#rundown-root .rd-stat span { color: var(--text-secondary); font-size: .78rem; }
++#rundown-root .rd-spark { grid-column: 1 / -1; width: 100%; height: 22px; margin-top: 4px; fill: none; stroke: var(--accent-light, var(--accent)); stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
++#rundown-root .rd-how { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 10px; margin: 0 0 16px; padding: 12px 14px; border: 1px solid var(--border); border-radius: 14px; background: var(--bg-card); }
++#rundown-root .rd-how p { margin: 0; }
++#rundown-root .rd-how-actions { display: flex; flex-wrap: wrap; gap: 7px; }
++#rundown-root .rd-how-actions a { display: inline-flex; align-items: center; min-height: 36px; padding: 5px 10px; border: 1px solid var(--border); border-radius: 9px; background: var(--bg-tertiary); color: var(--text-primary); font-size: .84rem; font-weight: 700; text-decoration: none; }
++#rundown-root .rd-day { margin: 20px 0 10px; padding-top: 14px; border-top: 1px solid var(--border); font-size: .85rem; }
++#rundown-root .rd-stream-head { margin: 12px 0 7px; color: var(--text-secondary); font-size: .83rem; font-weight: 700; }
++#rundown-root .rd-feed-group { margin-bottom: 14px; }
++#rundown-root .rd-feed-group .rd-card { margin-bottom: 8px; }
++#rundown-root .rd-group-more { margin: 5px 0 14px; }
++#rundown-root .rd-group-more summary { width: fit-content; min-height: 36px; padding: 7px 10px; border-radius: 9px; color: var(--accent-light, var(--accent)); font-size: .85rem; font-weight: 700; cursor: pointer; }
++#rundown-root .rd-group-more summary:hover { background: var(--bg-tertiary); }
++#rundown-root .rd-card { position: relative; margin-bottom: 12px; border-radius: 14px; }
++#rundown-root .rd-take { padding: 12px 14px; }
++#rundown-root .rd-take .rd-h3 { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; margin: 0 0 5px; font-size: 1rem; line-height: 1.4; }
++#rundown-root .rd-take .rd-what, #rundown-root .rd-take .rd-quote p { font-size: .96rem; line-height: 1.5; color: var(--text-primary); }
++#rundown-root .rd-take .rd-quote { margin: 0; padding: 0; border: 0; border-radius: 0; background: none; }
++#rundown-root .rd-take .rd-quote p, #rundown-root .rd-take .rd-what { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; overflow: hidden; font-style: normal; }
++#rundown-root .rd-compact-top { gap: 5px 8px; padding-right: 32px; margin-bottom: 7px; min-width: 0; }
++#rundown-root .rd-compact-top .rd-av { width: 28px; height: 28px; }
++#rundown-root .rd-compact-top .rd-chip { padding: 2px 8px; font-size: .75rem; }
++#rundown-root .rd-compact-top time { color: var(--text-muted); font-size: .78rem; white-space: nowrap; }
++#rundown-root .rd-compact-top .rd-watch { margin-left: auto; font-size: .78rem; white-space: nowrap; }
++#rundown-root .rd-take .rd-card-foot { justify-content: flex-start; margin-top: 7px; padding: 0; border: 0; }
++#rundown-root .rd-take .rd-card-foot:has(.rd-react-used:empty):not(:has(.rd-own-x)) { height: 0; margin: 0; }
++#rundown-root .rd-take .rd-card-foot:has(.rd-react-used:empty) .rd-react { min-height: 0; }
++#rundown-root .rd-react { position: relative; display: flex; align-items: center; min-height: 30px; }
++#rundown-root .rd-card .rd-react { position: static; }
++#rundown-root .rd-react-used { display: flex; flex-wrap: wrap; gap: 5px; }
++#rundown-root .rd-react-btn { min-height: 28px; padding: 2px 8px; font-size: .82rem; }
++#rundown-root .rd-react-add { display: inline-grid; place-items: center; width: 28px; height: 28px; padding: 0; border: 1px solid var(--border); border-radius: 8px; background: var(--bg-card); color: var(--text-secondary); cursor: pointer; }
++#rundown-root .rd-card .rd-react-add { position: absolute; top: 10px; right: 12px; opacity: 0; }
++#rundown-root .rd-card:hover .rd-react-add, #rundown-root .rd-card:focus-within .rd-react-add, #rundown-root .rd-react.is-open .rd-react-add { opacity: 1; }
++#rundown-root .rd-react-add-phone { display: none; }
++#rundown-root .rd-react-pop { display: none; position: absolute; right: 0; top: 32px; z-index: 35; gap: 2px; padding: 5px; border: 1px solid var(--border); border-radius: 10px; background: var(--bg-card); box-shadow: 0 10px 30px rgba(0,0,0,.22); }
++#rundown-root .rd-react.is-open .rd-react-pop { display: flex; }
++#rundown-root .rd-react-choice { width: 34px; height: 34px; padding: 0; border: 0; border-radius: 7px; background: none; cursor: pointer; font-size: 1rem; }
++#rundown-root .rd-react-choice:hover, #rundown-root .rd-react-choice:focus-visible { background: var(--bg-tertiary); transform: scale(1.12); }
++#rundown-root .rd-card .rd-react-pop { top: 42px; right: 12px; }
++#rundown-root .rd-react-btn { animation: rd-reaction-pop .18s ease-out; }
++@keyframes rd-reaction-pop { from { transform: scale(.75); } to { transform: scale(1); } }
++#rundown-root .rd-skeletons { display: grid; gap: 12px; margin: 14px 0; }
++#rundown-root .rd-skeleton { display: grid; gap: 9px; padding: 16px; border: 1px solid var(--border); border-radius: 14px; background: var(--bg-card); }
++#rundown-root .rd-skeleton span { display: block; height: 12px; border-radius: 6px; background: var(--bg-tertiary); animation: rd-pulse 1.2s ease-in-out infinite alternate; }
++#rundown-root .rd-skeleton span:first-child { width: 36%; }
++#rundown-root .rd-skeleton span:last-child { width: 68%; }
++@keyframes rd-pulse { to { opacity: .42; } }
++#rundown-root .rd-next { display: block; width: fit-content; min-height: 44px; margin: 16px auto; padding: 10px 18px; border: 1px solid var(--border); border-radius: 10px; background: var(--bg-card); font-weight: 700; text-decoration: none; }
++#rundown-root .rd-rail-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
++#rundown-root .rd-rail-all { font-size: .82rem; font-weight: 700; white-space: nowrap; }
++@media (max-width: 720px) {
++ #rundown-root .rd-stats { gap: 6px; }
++ #rundown-root .rd-stat { grid-template-columns: 1fr; gap: 2px; padding: 9px; }
++ #rundown-root .rd-stat i { grid-row: auto; }
++ #rundown-root .rd-card .rd-react-add { position: static; opacity: 1; margin-left: 5px; width: 44px; height: 44px; }
++ #rundown-root .rd-take .rd-card-foot:has(.rd-react-used:empty):not(:has(.rd-own-x)) { height: auto; margin-top: 5px; }
++ #rundown-root .rd-take .rd-card-foot:has(.rd-react-used:empty) .rd-react { min-height: 36px; }
++ #rundown-root .rd-react-add-desktop { display: none; }
++ #rundown-root .rd-react-add-phone { display: inline; font-size: .75rem; }
++ #rundown-root .rd-react-pop { top: 40px; left: 0; right: auto; }
++ #rundown-root .rd-card .rd-react-pop { top: auto; bottom: 48px; left: 12px; right: auto; }
++ #rundown-root .rd-compact-top { padding-right: 0; }
++ #rundown-root .rd-take .rd-compact-top a.rd-av { position: relative; width: 28px; height: 28px; min-width: 28px; min-height: 28px; overflow: visible; }
++ #rundown-root .rd-take .rd-compact-top a.rd-av img { border-radius: 50%; }
++ #rundown-root .rd-take .rd-compact-top a.rd-av::after { content: ''; position: absolute; inset: -8px; }
++ #rundown-root .rd-take .rd-compact-top .rd-chip { position: relative; min-height: 28px; }
++ #rundown-root .rd-take .rd-compact-top .rd-chip::after { content: ''; position: absolute; inset: -8px; }
++}
++@media (prefers-reduced-motion: reduce) { .rd-bar, #rundown-root .rd-react-btn, #rundown-root .rd-skeleton span { transition: none; animation: none; } }
ddiff --git a/public/js/rundown.js b/public/js/rundown.js
iindex c0517dc9..8e09a408 100644
---- a/public/js/rundown.js
++++ b/public/js/rundown.js
@@@ -34,7 +34,17 @@ function _rdAgo(d) {
const h = Math.round(m / 60); if (h < 48) return `${h}h ago`;
return `${Math.round(h / 24)}d ago`;
}
--function _rdDay(d) { try { return _rdTime(d).toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' }); } catch { return ''; } }
++function _rdDay(d) {
++ try {
++ const day = _rdTime(d);
++ const now = new Date();
++ const key = (x) => `${x.getFullYear()}-${x.getMonth()}-${x.getDate()}`;
++ if (key(day) === key(now)) return 'Today';
++ now.setDate(now.getDate() - 1);
++ if (key(day) === key(now)) return 'Yesterday';
++ return day.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' });
++ } catch { return ''; }
++}
function _rdStamp(sec) { sec = Math.max(0, Math.floor(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')}` : `${m}`) + `:${String(s).padStart(2, '0')}`; }
function _rdLength(min) { min = Number(min) || 0; return min >= 60 ? `${Math.floor(min / 60)}h ${String(min % 60).padStart(2, '0')}m` : `${min}m`; }
function _rdName(u) { return u ? (u.display_name || u.username) : ''; }
@@@ -75,10 +85,11 @@ function _rdSpeak(btn) {
}
function _rdReactions(type, id, reactions = {}, mine = null) {
-- return `<div class="rd-react" role="group" aria-label="React">${RD_REACT.map(([k, icon, label]) => {
++ const used = RD_REACT.filter(([k]) => Number(reactions[k] || 0) > 0);
++ return `<div class="rd-react" role="group" aria-label="React"><div class="rd-react-used">${used.map(([k, icon, label]) => {
const n = Number(reactions[k] || 0);
return `<button type="button" class="rd-react-btn${mine === k ? ' is-mine' : ''}" data-rd-react="${type}:${id}:${k}" aria-pressed="${mine === k}" aria-label="${_rdEsc(label)}${n ? `, ${n}` : ''}"><span aria-hidden="true">${icon}</span>${n ? `<b>${n}</b>` : ''}</button>`;
-- }).join('')}</div>`;
++ }).join('')}</div><button type="button" class="rd-react-add" data-rd-react-open aria-label="Add a reaction" aria-expanded="false"><span class="rd-react-add-desktop" aria-hidden="true">☺</span><span class="rd-react-add-phone" aria-hidden="true">+😀</span></button><div class="rd-react-pop" role="group" aria-label="Choose a reaction">${RD_REACT.map(([k, icon, label]) => `<button type="button" class="rd-react-choice" data-rd-react="${type}:${id}:${k}" aria-label="${_rdEsc(label)}">${icon}</button>`).join('')}</div></div>`;
}
// ── Cards ────────────────────────────────────────────────────
@@@ -172,19 +183,29 @@ function _rdIssue(g) {
}
// ── The front page ───────────────────────────────────────────
--function _rdMast(counts) {
-- const today = new Date().toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' });
-- const tally = counts ? [[counts.topics, 'topic'], [counts.takes, 'take'], [counts.episodes, 'episode']].filter(([n]) => n).map(([n, w]) => `${n} ${w}${n === 1 ? '' : 's'}`).join(' · ') : '';
++function _rdMast(counts, stats) {
++ const today = _rdDay(new Date().toISOString());
++ const tiles = counts ? [['fa-hashtag', 'topics', 'Topics'], ['fa-comment', 'takes', 'Takes'], ['fa-film', 'episodes', 'Episodes']] : [];
++ const spark = (series) => {
++ const high = Math.max(1, ...series);
++ const points = series.map((n, i) => `${i * 10},${20 - Math.round(n / high * 18)}`).join(' ');
++ return `<svg class="rd-spark" viewBox="0 0 60 22" aria-hidden="true"><polyline points="${points}"/></svg>`;
++ };
return `<header class="rd-mast">
-- <div class="rd-mast-line"><span>${_rdEsc(today)}</span>${tally ? `<span>${_rdEsc(tally)}</span>` : ''}</div>
++ <div class="rd-mast-line"><span class="rd-date-chip">${_rdEsc(today)}</span></div>
<h1 class="rd-title">The Rundown</h1>
<p class="rd-tagline">What streamers talked about, written up: the topics, their takes in their own words, and every stream as an episode.</p>
++ <div class="rd-stats">${tiles.map(([icon, key, label]) => {
++ const series = stats && stats[key];
++ return `<div class="rd-stat"><i class="fa-solid ${icon}" aria-hidden="true"></i><strong>${series ? series[6] : Number(counts[key]) || 0}</strong><span>${label}${series ? ` today · ${Number(counts[key]) || 0} all time` : ''}</span>${series ? spark(series) : ''}</div>`;
++ }).join('')}</div>
</header>`;
}
// What a visitor can do here, under the masthead of the front page. The page said what it is and never what it is for
// ("still don't rly know wtf to do with this feature", a viewer, 2026-10-20). "Jump to the moment" opens the top take's.
function _rdHow() {
-- return `<p class="rd-how">Catch up, follow a topic, jump to the moment, get written up: <a class="rd-how-link" href="#rd-about-h">how it works <i class="fa-solid fa-arrow-right" aria-hidden="true"></i></a>${_rdMe() && _rdMe().username ? ` · <a class="rd-how-link" href="/rundown/${encodeURIComponent(_rdMe().username)}#mine">your Rundown</a>` : ''}</p>`;
++ const yours = _rdMe() && _rdMe().username ? _rdA(`/rundown/${encodeURIComponent(_rdMe().username)}#mine`, 'Your Rundown') : '<a href="/api/auth/sso/login?next=%2Frundown">Your Rundown</a>';
++ return `<div class="rd-how"><p>Catch up on what was said, then jump into the moment.</p><div class="rd-how-actions"><a href="#rd-about-h">How it works</a>${yours}<a href="/rundown/topics">Follow topics</a></div></div>`;
}
function _rdOnAir(list) {
if (!list.length) return '';
@@@ -507,6 +528,7 @@ function _rdState(query) {
following: one('following') === '1',
mentions: one('mentions') === '1',
kind: one('kind').slice(0, 20),
++ page: Math.max(1, Math.min(100, Number.parseInt(one('page'), 10) || 1)),
};
}
/** The state as a query string: the same filters, the same order, every time. */
@@@ -522,6 +544,7 @@ function _rdQuery(state) {
if (state.following) p.set('following', '1');
if (state.mentions) p.set('mentions', '1');
if (state.kind) p.set('kind', state.kind);
++ if (state.page > 1) p.set('page', String(state.page));
const s = p.toString();
return s ? '?' + s : '';
}
@@@ -535,6 +558,7 @@ let _rdPicker = '';
let _rdPickQueries = { streamer: '', topic: '' };
let _rdSheetScroll = 0;
let _rdSearchTimer = null;
++let _rdMoreObserver = null;
let _rdPreset = null;
const _rdTopicNames = new Map();
@@@ -581,7 +605,7 @@ function _rdBar(state, preset) {
'<div class="rd-bar-row">' +
'<div class="rd-search">' +
'<i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i>' +
-- '<input id="rd-search" class="rd-search-input" type="search" autocomplete="off" spellcheck="false" placeholder="Search the Rundown: a topic, a streamer, something they said" value="' + _rdEsc(state.q) + '" aria-label="Search the Rundown">' +
++ '<input id="rd-search" class="rd-search-input" type="search" autocomplete="off" spellcheck="false" placeholder="Search topics, streamers, episodes" value="' + _rdEsc(state.q) + '" aria-label="Search the Rundown">' +
(state.q ? '<button type="button" class="rd-search-clear" data-rd-clear="q" aria-label="Clear the search"><i class="fa-solid fa-xmark" aria-hidden="true"></i></button>' : '') +
'</div>' +
'<button type="button" class="rd-sheet-btn" data-rd-sheet="open" aria-haspopup="dialog">Filters' + (n ? ' (' + n + ')' : '') + '</button>' +
@@@ -687,11 +711,11 @@ function _rdSheetPanel(state) {
function _rdTakeResult(s) {
const q = (s.quotes || [])[0];
return '<article class="rd-card rd-take" id="rd-take-' + s.id + '">' +
-- '<div class="rd-compact-top">' + _rdAv(s.user, 26) + '<span class="rd-compact-who">' + _rdUser(s.user) + '</span>' +
-- (s.topic ? _rdTopicFilter(s.topic) : '') + '<time datetime="' + _rdEsc(s.said_at) + '">' + _rdEsc(_rdAgo(s.said_at)) + '</time></div>' +
++ '<div class="rd-compact-top">' + _rdAv(s.user, 28) + '<span class="rd-compact-who">' + _rdUser(s.user) + '</span>' +
++ (s.topic ? _rdTopicFilter(s.topic) : '') + '<time datetime="' + _rdEsc(s.said_at) + '">' + _rdEsc(_rdAgo(s.said_at)) + '</time>' + _rdWatch(s.vod_id, s.sec) + '</div>' +
'<h3 class="rd-h3">' + _rdEsc(s.title) + '</h3>' +
(q ? '<blockquote class="rd-quote"><p>“' + _rdEsc(q.text) + '”</p></blockquote>' : '<p class="rd-what">' + _rdEsc(s.what) + '</p>') +
-- '<div class="rd-card-foot">' + _rdReactions('segment', s.id, s.reactions, s.my_reaction) + _rdWatch(s.vod_id, s.sec) + _rdOwnX(s) + '</div>' +
++ '<div class="rd-card-foot">' + _rdReactions('segment', s.id, s.reactions, s.my_reaction) + _rdOwnX(s) + '</div>' +
'</article>';
}
// One take inside an episode: a line, with the moment it was said.
@@@ -702,11 +726,11 @@ function _rdTakeMini(s) {
'</div>';
}
// An episode card: the title, the logline, its takes folded to the top ones, and how many are folded away.
--function _rdEpisodeResult(e) {
-- const takes = e.takes || [];
++function _rdEpisodeResult(e, compact = false) {
++ const takes = compact ? [] : e.takes || [];
const folded = takes.slice(0, e.folded || 3);
const rest = takes.slice(e.folded || 3);
-- const hidden = Math.max(0, (e.takes_total || takes.length) - folded.length);
++ const hidden = compact ? 0 : Math.max(0, (e.takes_total || takes.length) - folded.length);
const more = rest.length
? '<button type="button" class="rd-fold-btn" data-rd-fold="' + e.stream_id + '" data-rd-count="' + hidden + '">More takes (' + hidden + ')</button>' +
'<div class="rd-fold-more" data-rd-fold-body="' + e.stream_id + '" hidden>' + rest.map(_rdTakeMini).join('') + '</div>'
@@@ -739,13 +763,31 @@ function _rdNoResults(d) {
}
function _rdResultsHtml(d, state) {
if (state.show === 'topics') return (d.topics || []).length ? d.topics.map(_rdTopicCard).join('') : _rdNoResults(d);
-- if (state.show === 'episodes') return (d.episodes || []).length ? d.episodes.map(_rdEpisodeResult).join('') : _rdNoResults(d);
-- if (state.show === 'takes') return (d.takes || []).length ? d.takes.map(_rdTakeResult).join('') : _rdNoResults(d);
-- const items = [];
-- for (const t of d.takes || []) items.push({ at: String(t.said_at || ''), html: _rdTakeResult(t) });
-- for (const e of d.episodes || []) items.push({ at: String(e.aired_at || ''), html: _rdEpisodeResult(e) });
-- items.sort((a, b) => b.at.localeCompare(a.at));
-- return items.length ? items.map((x) => x.html).join('') : _rdNoResults(d);
++ const groups = new Map();
++ for (const s of d.takes || []) {
++ const id = String(s.stream_id);
++ if (!groups.has(id)) groups.set(id, { at: String(s.said_at || ''), user: s.user, takes: [], episode: null });
++ groups.get(id).takes.push(s);
++ }
++ if (state.show !== 'takes') for (const e of d.episodes || []) {
++ const id = String(e.stream_id);
++ if (!groups.has(id)) groups.set(id, { at: String(e.aired_at || ''), user: e.user, takes: [], episode: null });
++ const group = groups.get(id);
++ group.episode = e;
++ if (String(e.aired_at || '') > group.at) group.at = String(e.aired_at || '');
++ }
++ const items = [...groups.values()].sort((a, b) => b.at.localeCompare(a.at));
++ let day = '';
++ return items.length ? items.map((g) => {
++ const thisDay = _rdDay(g.at);
++ const dayHead = thisDay !== day ? '<h2 class="rd-day" id="rd-day-' + _rdEsc(g.at.slice(0, 10)) + '"><span class="rd-date-chip">' + _rdEsc(thisDay) + '</span></h2>' : '';
++ day = thisDay;
++ const first = g.takes.slice(0, 3).map(_rdTakeResult).join('');
++ const rest = g.takes.slice(3);
++ const folded = rest.length ? '<details class="rd-group-more"><summary>' + rest.length + ' more from this episode</summary>' + rest.map(_rdTakeResult).join('') + '</details>' : '';
++ const streamHead = '<p class="rd-stream-head">From ' + _rdEsc(_rdName(g.user) || 'a streamer') + '\'s ' + (g.episode ? 'episode' : 'stream') + '</p>';
++ return dayHead + '<section class="rd-feed-group">' + streamHead + (g.episode ? _rdEpisodeResult(g.episode, state.show !== 'episodes') : '') + first + folded + '</section>';
++ }).join('') : _rdNoResults(d);
}
// The kinds, for the topics view (the old topics page had them).
function _rdKindChips(state) {
@@@ -762,8 +804,8 @@ function _rdRails(d) {
const trending = (d.trending || []).map(_rdTopicFilter).join('');
const voices = (d.voices || []).map((v) => '<a class="rd-rail-item" href="/rundown?streamer=' + encodeURIComponent(v.user.username) + '">' + _rdFace(v.user, 24) + '<span>' + _rdEsc(_rdName(v.user)) + '</span></a>').join('');
return '<div class="rd-rails">' +
-- '<section class="rd-rail"><h2 class="rd-h2">Trending topics</h2><div class="rd-rail-track">' + (trending || '<span class="rd-quiet">No topics yet.</span>') + '</div></section>' +
-- '<section class="rd-rail"><h2 class="rd-h2">Who\'s been talking</h2><div class="rd-rail-track">' + (voices || '<span class="rd-quiet">Nobody yet.</span>') + '</div></section>' +
++ '<section class="rd-rail"><div class="rd-rail-head"><h2 class="rd-h2">Trending topics</h2>' + _rdA('/rundown/topics', 'See all', 'rd-rail-all') + '</div><div class="rd-rail-track">' + (trending || '<span class="rd-quiet">No topics yet.</span>') + '</div></section>' +
++ '<section class="rd-rail"><div class="rd-rail-head"><h2 class="rd-h2">Who\'s been talking</h2>' + _rdA('/rundown?show=takes', 'See all', 'rd-rail-all') + '</div><div class="rd-rail-track">' + (voices || '<span class="rd-quiet">Nobody yet.</span>') + '</div></section>' +
'</div>';
}
@@@ -774,29 +816,109 @@ function _rdPathState(path, query) {
if (path === '/rundown/topics' && !st.show) st.show = 'topics';
return st;
}
++function _rdSkeleton() {
++ return '<div class="rd-skeletons" aria-label="Loading the Rundown">' +
++ Array.from({ length: 3 }, () => '<div class="rd-skeleton"><span></span><span></span><span></span></div>').join('') + '</div>';
++}
++function _rdNextLink(d, state) {
++ if (!d.next_page) return '';
++ const href = _rdQuery({ ...state, page: d.next_page });
++ return '<a id="rd-next" class="rd-next" rel="next" href="/rundown' + _rdEsc(href) + '">Load more</a>';
++}
++function _rdObserveMore() {
++ if (_rdMoreObserver) _rdMoreObserver.disconnect();
++ const next = document.getElementById('rd-next');
++ if (!next || typeof IntersectionObserver === 'undefined') return;
++ _rdMoreObserver = new IntersectionObserver((entries) => {
++ if (entries.some((e) => e.isIntersecting)) _rdLoadMore();
++ }, { rootMargin: '600px 0px' });
++ _rdMoreObserver.observe(next);
++}
++async function _rdLoadMore() {
++ const next = document.getElementById('rd-next');
++ const results = document.getElementById('rd-results');
++ if (!next || !results || next.dataset.loading) return;
++ next.dataset.loading = '1';
++ if (_rdMoreObserver) _rdMoreObserver.disconnect();
++ next.insertAdjacentHTML('beforebegin', _rdSkeleton());
++ try {
++ const state = _rdPathState(location.pathname, new URLSearchParams(next.search));
++ const d = await api('/rundown/browse' + _rdQuery(state));
++ const holder = document.createElement('div');
++ holder.innerHTML = _rdResultsHtml(d, state);
++ const firstDay = holder.querySelector('.rd-day');
++ const lastDay = [...results.querySelectorAll('.rd-day')].pop();
++ if (firstDay && lastDay && firstDay.textContent === lastDay.textContent) firstDay.remove();
++ while (holder.firstChild) results.appendChild(holder.firstChild);
++ next.outerHTML = _rdNextLink(d, state);
++ _rdObserveMore();
++ } catch (err) {
++ next.textContent = 'Retry loading more';
++ next.removeAttribute('data-loading');
++ _rdObserveMore();
++ } finally {
++ document.querySelectorAll('.rd-main > .rd-skeletons').forEach((node) => node.remove());
++ }
++}
++let _rdBarListening = false;
++function _rdBarState() {
++ const update = () => {
++ const bar = document.querySelector('#rundown-root .rd-bar');
++ if (!bar) return;
++ const input = bar.querySelector('#rd-search');
++ if (input) input.placeholder = window.innerWidth < 520 ? 'Search topics' : 'Search topics, streamers, episodes';
++ const nav = document.querySelector('.openvibe-navbar');
++ const edge = Math.max(0, nav ? nav.getBoundingClientRect().bottom : 56);
++ bar.style.setProperty('--rd-nav-bottom', `${edge}px`);
++ bar.classList.toggle('is-stuck', window.scrollY > 4 && bar.getBoundingClientRect().top <= edge + 2);
++ };
++ update();
++ if (_rdBarListening) return;
++ _rdBarListening = true;
++ let until = 0, running = false;
++ const follow = () => {
++ update();
++ if (performance.now() < until) requestAnimationFrame(follow);
++ else running = false;
++ };
++ const start = () => { until = performance.now() + 400; if (!running) { running = true; requestAnimationFrame(follow); } };
++ const mount = document.getElementById('navbar-mount');
++ if (mount) new MutationObserver(start).observe(mount, { subtree: true, attributes: true, attributeFilter: ['class'] });
++ window.addEventListener('scroll', () => { update(); start(); }, { passive: true });
++ window.addEventListener('resize', update, { passive: true });
++}
async function _rdBrowse(root, state) {
++ root.innerHTML = _rdBar(state, null) + _rdSkeleton();
++ _rdBarState();
const d = await api('/rundown/browse' + _rdQuery(state));
_rdRememberTopics(d.topics);
_rdRememberTopics(d.trending);
root.innerHTML = _rdBar(state, null) +
'<div id="rd-active-host">' + _rdActiveChips(state, null) + '</div>' +
-- _rdMast(d.counts) + _rdHow() +
++ _rdMast(d.counts, d.stats) + _rdHow() +
'<div id="rd-rails-host">' + _rdRails(d) + '</div>' +
'<div class="rd-grid"><div class="rd-main">' +
_rdOnAir(d.on_air || []) +
(state.show === 'topics' ? _rdKindChips(state) : '') +
'<p class="rd-count" id="rd-count">' + _rdCountLine(d, state) + '</p>' +
-- '<div id="rd-results">' + _rdResultsHtml(d, state) + '</div>' +
++ '<div id="rd-results">' + _rdResultsHtml(d, state) + '</div>' + _rdNextLink(d, state) +
'</div><aside class="rd-side">' + _rdTrending(d.trending || []) + _rdVoices(d.voices || []) + _rdAbout() + '</aside></div>';
++ _rdBarState();
++ _rdObserveMore();
document.title = (state.q ? 'Search: ' + state.q + ' · ' : '') + 'The Rundown · OpenVibe.Live';
}
/** Only the results change while the words are typed: the page is not redrawn under the reader. */
async function _rdRefresh() {
const state = _rdPathState(location.pathname, new URLSearchParams(location.search));
++ const results = document.getElementById('rd-results');
++ if (results) results.innerHTML = _rdSkeleton();
const d = await api('/rundown/browse' + _rdQuery(state));
_rdRememberTopics(d.topics);
-- const results = document.getElementById('rd-results');
if (results) results.innerHTML = _rdResultsHtml(d, state);
++ const oldNext = document.getElementById('rd-next');
++ if (oldNext) oldNext.remove();
++ if (results) results.insertAdjacentHTML('afterend', _rdNextLink(d, state));
++ _rdObserveMore();
const count = document.getElementById('rd-count');
if (count) count.textContent = _rdCountLine(d, state);
const active = document.getElementById('rd-active-host');
@@@ -807,6 +929,7 @@ async function _rdRefresh() {
// ── What the bar does ──────────────────────────────────────────────────────
/** A new view: the filters in the URL, and the back button gets a step. */
function _rdGo(state) {
++ state.page = 1;
const url = _rdUrl(state);
if (url === location.pathname + location.search) return;
navigate(url);
@@@ -908,6 +1031,7 @@ function _rdFold(btn) {
async function _rdSearch(value) {
const state = _rdState(new URLSearchParams(location.search));
state.q = String(value || '').trim();
++ state.page = 1;
const url = _rdUrl(state);
if (location.pathname !== '/rundown' && location.pathname !== '/rundown/topics') { navigate(url); return; }
if (url === location.pathname + location.search) return;
@@@ -946,6 +1070,15 @@ async function loadRundownPage(segments = []) {
document.addEventListener('click', async (e) => {
const root = document.getElementById('rundown-root');
if (!root || !root.contains(e.target)) return;
++ const reactOpen = e.target.closest('[data-rd-react-open]');
++ if (reactOpen) {
++ e.preventDefault();
++ const group = reactOpen.closest('.rd-react');
++ document.querySelectorAll('.rd-react.is-open').forEach((node) => { if (node !== group) node.classList.remove('is-open'); });
++ group.classList.toggle('is-open');
++ reactOpen.setAttribute('aria-expanded', group.classList.contains('is-open') ? 'true' : 'false');
++ return;
++ }
const speak = e.target.closest('[data-rd-speak]');
if (speak) { e.preventDefault(); if (speak.classList.contains('is-speaking')) speak.classList.add('was-speaking'); _rdSpeak(speak); return; }
const more = e.target.closest('[data-rd-more]');
@@@ -978,6 +1111,14 @@ document.addEventListener('click', async (e) => {
const restore = e.target.closest('[data-rd-restore]');
if (restore) { e.preventDefault(); _rdRestore(restore); }
});
++document.addEventListener('click', (e) => {
++ if (e.target.closest('.rd-react')) return;
++ document.querySelectorAll('.rd-react.is-open').forEach((node) => {
++ node.classList.remove('is-open');
++ const button = node.querySelector('[data-rd-react-open]');
++ if (button) button.setAttribute('aria-expanded', 'false');
++ });
++});
// ── The bar's events ────────────────────────────────────────────────────────
document.addEventListener('click', (e) => {
ddiff --git a/server/rundown/browse.js b/server/rundown/browse.js
iindex d6e5de96..4c08c78b 100644
---- a/server/rundown/browse.js
++++ b/server/rundown/browse.js
@@@ -21,7 +21,8 @@ const read = require('./read');
const { VISIBLE, AUDIENCE_JOINS, SEGMENT_COLS, EPISODE_COLS } = read;
--const MAX_TAKES = 40; // how many takes one page of results holds
++const MAX_TAKES = 30; // the crawlable first page of takes
++const MORE_TAKES = 20; // subsequent pages as the reader nears the end
const MAX_EPISODES = 20; // how many episodes one page holds
const MAX_TOPICS = 40; // how many topics one page holds
const EPISODE_TAKES = 24; // how many takes of an episode are sent, so "More takes (N)" can open in place
@@@ -47,8 +48,9 @@ function parse(query = {}) {
const when = Object.prototype.hasOwnProperty.call(WHENS, one(query.when).toLowerCase()) ? one(query.when).toLowerCase() : null;
const show = SHOWS.includes(one(query.show).toLowerCase()) ? one(query.show).toLowerCase() : null;
const sort = SORTS.includes(one(query.sort).toLowerCase()) ? one(query.sort).toLowerCase() : 'newest';
++ const page = Math.max(1, Math.min(100, Number.parseInt(one(query.page), 10) || 1));
return {
-- q, streamer, topic, when, show, sort,
++ q, streamer, topic, when, show, sort, page,
video: truthy(one(query.video)),
following: truthy(one(query.following)),
mentions: truthy(one(query.mentions)),
@@@ -163,14 +165,16 @@ async function results(st, { users, topicId, prefix, viewer }) {
const topf = topicFilters(st, { users, prefix, viewer });
const takeOrder = st.sort === 'reacted' ? 'reacts DESC, s.said_at DESC' : 's.said_at DESC, s.id DESC';
++ const takeLimit = st.page === 1 ? MAX_TAKES : MORE_TAKES;
++ const takeOffset = st.page === 1 ? 0 : MAX_TAKES + (st.page - 2) * MORE_TAKES;
const takeRows = await db.all(`SELECT ${SEGMENT_COLS}, COALESCE(rx.n, 0) AS reacts FROM rundown_segments s
${AUDIENCE_JOINS('s')} LEFT JOIN (SELECT item_id, COUNT(*) AS n FROM rundown_reactions WHERE item_type = 'segment' GROUP BY item_id) rx ON rx.item_id = s.id
-- WHERE ${tf.where} ORDER BY ${takeOrder} LIMIT ${MAX_TAKES}`, tf.params);
++ WHERE ${tf.where} ORDER BY ${takeOrder} LIMIT @takeLimit OFFSET @takeOffset`, { ...tf.params, takeLimit, takeOffset });
const epOrder = st.sort === 'reacted' ? 'reacts DESC, r.aired_at DESC' : 'r.aired_at DESC';
const epRows = await db.all(`SELECT ${EPISODE_COLS}, COALESCE(rx.n, 0) AS reacts FROM rundown_episodes r
${AUDIENCE_JOINS('r')} LEFT JOIN (SELECT item_id, COUNT(*) AS n FROM rundown_reactions WHERE item_type = 'episode' GROUP BY item_id) rx ON rx.item_id = r.stream_id
-- WHERE ${ef.where} ORDER BY ${epOrder} LIMIT ${MAX_EPISODES}`, ef.params);
++ WHERE ${ef.where} ORDER BY ${epOrder} LIMIT @epLimit OFFSET @epOffset`, { ...ef.params, epLimit: MAX_EPISODES, epOffset: (st.page - 1) * MAX_EPISODES });
const topOrder = st.sort === 'takes' || st.sort === 'reacted' ? 'takes DESC, last_seen_at DESC' : 'last_seen_at DESC';
const topRows = await db.all(`SELECT t.id, t.slug, t.title, t.kind, t.headline, t.summary, t.first_seen_at, t.last_seen_at,
@@@ -192,6 +196,27 @@ async function counts(st, { users, topicId, prefix, viewer }) {
return { takes: Number(t.n), take_people: Number(t.people), episodes: Number(e.n), episode_people: Number(e.people), topics: Number(p.n) };
}
++/** Seven daily points for the masthead. The totals are the same filtered totals as the count line. */
++async function dailyStats(st, { users, topicId, prefix, viewer }) {
++ const tf = takeFilters(st, { users, topicId, prefix, viewer });
++ const ef = episodeFilters(st, { users, topicId, prefix, viewer });
++ const since = daysAgo(6).slice(0, 10);
++ const args = { ...tf.params, statsSince: since };
++ const [takes, topics, episodes] = await Promise.all([
++ db.all(`SELECT DATE(s.said_at) AS day, COUNT(*) AS n FROM rundown_segments s ${AUDIENCE_JOINS('s')}
++ WHERE ${tf.where} AND s.said_at >= @statsSince GROUP BY DATE(s.said_at)`, args),
++ db.all(`SELECT DATE(s.said_at) AS day, COUNT(DISTINCT s.topic_id) AS n FROM rundown_segments s ${AUDIENCE_JOINS('s')}
++ WHERE ${tf.where} AND s.said_at >= @statsSince GROUP BY DATE(s.said_at)`, args),
++ db.all(`SELECT DATE(r.aired_at) AS day, COUNT(*) AS n FROM rundown_episodes r ${AUDIENCE_JOINS('r')}
++ WHERE ${ef.where} AND r.aired_at >= @statsSince GROUP BY DATE(r.aired_at)`, { ...ef.params, statsSince: since }),
++ ]);
++ const shape = (rows) => {
++ const byDay = new Map(rows.map((r) => [String(r.day).slice(0, 10), Number(r.n)]));
++ return Array.from({ length: 7 }, (_, i) => byDay.get(daysAgo(6 - i).slice(0, 10)) || 0);
++ };
++ return { takes: shape(takes), topics: shape(topics), episodes: shape(episodes) };
++}
++
/** The topics whose names are closest to what was searched: the way out of an empty search. */
async function closestTopics(st, { topicId }) {
const rows = await db.all("SELECT slug, title, kind, takes FROM rundown_topics WHERE status = 'live' ORDER BY last_seen_at DESC LIMIT 200");
@@@ -222,6 +247,7 @@ async function browse(query = {}, { viewer = null } = {}) {
const r = await results(st, { users, topicId, prefix, viewer: v.userId });
const c = await counts(st, { users, topicId, prefix, viewer: v.userId });
++ const stats = st.page === 1 ? await dailyStats(st, { users, topicId, prefix, viewer: v.userId }) : null;
const takes = await read.segmentsOut(r.takeRows, v.key);
const episodes = await read.episodesOut(r.epRows, v.key);
@@@ -264,8 +290,10 @@ async function browse(query = {}, { viewer = null } = {}) {
const withTakes = (e) => ({ ...e, takes: epTakes.get(e.stream_id) || [], takes_total: totals.get(e.stream_id) || 0, folded: FOLDED });
return {
filters: st,
++ next_page: st.page < 100 && ((show !== 'episodes' && show !== 'topics' && c.takes > (st.page === 1 ? MAX_TAKES : MAX_TAKES + (st.page - 1) * MORE_TAKES)) || (show !== 'takes' && show !== 'topics' && c.episodes > st.page * MAX_EPISODES)) ? st.page + 1 : null,
active: activeKeys(st),
counts: c,
++ stats,
takes: show === 'episodes' || show === 'topics' ? [] : takes,
episodes: show === 'takes' || show === 'topics' ? [] : episodes.map(withTakes),
topics: show === 'topics' ? topics : [],
ddiff --git a/server/seo/pages.js b/server/seo/pages.js
iindex 7e2c7f3c..da5fd1c6 100644
---- a/server/seo/pages.js
++++ b/server/seo/pages.js
@@@ -215,7 +215,7 @@ async function _pageMeta(routePath, { page = 1, query = null } = {}) {
if ((m = p.match(/^\/p\/([A-Za-z0-9_-]+)$/))) return await _pasteMeta(m[1]);
if ((m = p.match(CHANNEL_PATH_RE))) return await _channelMeta(m[1], page);
if ((m = p.match(/^\/recap\/(\d+)$/))) return await _recapMeta(parseInt(m[1], 10));
-- if (p === '/rundown' || p.startsWith('/rundown/')) return await _rundownMeta(p, query);
++ if (p === '/rundown' || p.startsWith('/rundown/')) return await _rundownMeta(p, query, page);
// Search results (public/js/app-search.js) are never indexed; the page itself is findable.
if (p === '/search') return { title: `Search | ${SITE_NAME}`, description: 'Search channels, VODs and clips on OpenVibe.Live.', canonicalPath: '/search', ogType: 'website', robots: 'noindex,follow', jsonLd: [] };
// The global chat room: its own title and canonical (the shell left it with neither; browser check, WS-Q task 3).
@@@ -426,7 +426,7 @@ function _rundownKey(query) {
if (String(q.q || '').trim()) parts.push('q');
return parts.join('&');
}
--async function _rundownMeta(p, query) {
++async function _rundownMeta(p, query, page = 1) {
const read = require('../rundown/read');
const crumbs = [{ name: 'Home', url: '/' }, { name: 'The Rundown', url: '/rundown' }];
const base = { image: shareCard('live-rundown'), ogType: 'website' };
@@@ -440,15 +440,21 @@ async function _rundownMeta(p, query) {
if (p === '/rundown') {
let home = null;
try { home = await read.home({}); } catch { home = null; }
++ let feed = null;
++ try { feed = await require('../rundown/browse').browse({ page }); } catch { feed = null; }
const topics = (home && home.trending || []).map((t) => t.title);
const eps = (home && home.streams || []).filter((x) => x.episode).map((x) => ({ title: x.episode.title, stream_id: x.stream_id }));
const title = 'The Rundown — What Streamers Talked About';
const desc = clean(`Topics, takes and episodes from OpenVibe.Live streams, in the streamers' own words.${topics.length ? ` Trending: ${topics.slice(0, 4).join(', ')}.` : ''}`, 200);
-- return { ...base, title: `${title} | ${SITE_NAME}`, description: desc, canonicalPath: '/rundown', robots: 'index,follow',
++ const takes = feed && feed.takes || [];
++ const episodes = feed && feed.episodes || [];
++ const body = feed ? `<header class="rd-mast"><h1 class="rd-title">The Rundown</h1><p class="rd-tagline">${esc(desc)}</p></header><main class="rd-ssr-feed"><h2 class="rd-h2">Latest takes</h2>${takes.map((s) => `<article class="rd-card rd-take"><p class="rd-meta">${esc(s.user && (s.user.display_name || s.user.username) || '')} · ${esc(s.topic && s.topic.title || '')}</p><h3 class="rd-h3">${esc(s.title)}</h3><p class="rd-what">${esc((s.quotes && s.quotes[0] && s.quotes[0].text) || s.what || '')}</p></article>`).join('')}<h2 class="rd-h2">Episodes</h2>${episodes.map((e) => `<article class="rd-card rd-episode"><h3 class="rd-h3"><a href="/rundown/e/${Number(e.stream_id)}">${esc(e.title)}</a></h3><p class="rd-logline">${esc(e.logline || '')}</p></article>`).join('')}${feed.next_page ? `<a class="rd-next" rel="next" href="/rundown?page=${feed.next_page}">More takes and episodes</a>` : ''}</main>` : null;
++ return { ...base, title: `${title} | ${SITE_NAME}`, description: desc, canonicalPath: page > 1 ? `/rundown?page=${page}` : '/rundown', robots: 'index,follow', rundownBody: body,
jsonLd: [seo.jsonLd.webPage({ type: 'CollectionPage', name: title, url: abs('/rundown'), description: desc }),
-- ...(eps.length ? [seo.jsonLd.itemList('Latest episodes', eps.slice(0, 10).map((e) => ({ name: clean(e.title, 110), url: abs(`/rundown/e/${e.stream_id}`) })))] : []),
++ ...(takes.length ? [seo.jsonLd.itemList('Latest takes', takes.map((s) => ({ name: clean(s.title, 110), url: abs(`/rundown/e/${s.stream_id}#rd-take-${s.id}`) })))] : []),
++ ...(episodes.length ? [seo.jsonLd.itemList('Latest episodes', episodes.map((e) => ({ name: clean(e.title, 110), url: abs(`/rundown/e/${e.stream_id}`) })))] : []),
_breadcrumb(crumbs)],
-- snapshot: _detailSnapshot({ title: 'The Rundown', byline: topics.length ? `Trending: ${topics.slice(0, 6).join(', ')}` : null, desc, overview: eps.slice(0, 6).map((e) => e.title).join(' · ') || null, transcript: null, canonicalPath: '/rundown', watchLabel: 'Open The Rundown' }) };
++ snapshot: page === 1 ? _detailSnapshot({ title: 'The Rundown', byline: topics.length ? `Trending: ${topics.slice(0, 6).join(', ')}` : null, desc, overview: eps.slice(0, 6).map((e) => e.title).join(' · ') || null, transcript: null, canonicalPath: '/rundown', watchLabel: 'Open The Rundown' }) : null };
}
if (p === '/rundown/topics') {
const plain = await _rundownMeta('/rundown', null);
@@@ -879,6 +885,7 @@ function render(meta, urlPath) {
let html = _base();
if (!html) return null;
html = assets.renderRoute(html, urlPath || meta.canonicalPath || '/');
++ if (meta.rundownBody) html = html.replace('<div class="loading-spinner"><i class="fa-solid fa-circle-notch fa-spin"></i></div>', meta.rundownBody);
// The channel page's Ask your agent chip (openvibe-shared 4.135.0, plan Z3): the channel fragment leaves a slot,
// filled here where the channel and its stream are known. kit.askAgent escapes the text; other routes have no slot.
@@@ -975,12 +982,13 @@ function _cacheSet(key, html, ttl = CACHE_TTL_MS) {
const SEO_ROUTE_RE = /^\/(?:$|content$|moments$|vods$|clips$|pastes$|rundown(?:\/(?:topics|s\/[A-Za-z0-9_.-]{1,64}|t\/[a-z0-9-]{1,80}|e\/\d{1,12}|[A-Za-z0-9_.-]{1,64}))?$|chat$|search$|documentation$|updates$|vod\/\d+$|clip\/\d+$|recap\/\d+$|p\/[A-Za-z0-9_-]+$|@[A-Za-z0-9_]{3,24}$)/;
--/** ?page=N on a channel page (its video list); 1 everywhere else and for anything that is not a number. */
++/** ?page=N on a channel, Content, Moments or the Rundown; 1 on other pages. */
function _pageParam(p, query) {
const feed = p === '/content' || p === '/moments';
-- if (!feed && !CHANNEL_PATH_RE.test(p)) return 1;
++ const rundown = p === '/rundown';
++ if (!feed && !rundown && !CHANNEL_PATH_RE.test(p)) return 1;
const raw = query && typeof query.page === 'string' ? query.page : '';
-- return /^\d{1,4}$/.test(raw) ? Math.min(feed ? FEED_MAX_PAGE : CHANNEL_MAX_PAGE, Math.max(1, parseInt(raw, 10))) : 1;
++ return /^\d{1,4}$/.test(raw) ? Math.min(rundown ? 100 : (feed ? FEED_MAX_PAGE : CHANNEL_MAX_PAGE), Math.max(1, parseInt(raw, 10))) : 1;
}
async function middleware(req, res, next) {
ddiff --git a/test/rundown-bar.test.js b/test/rundown-bar.test.js
iindex 0c42b4b8..d62960d8 100644
---- a/test/rundown-bar.test.js
++++ b/test/rundown-bar.test.js
@@@ -53,7 +53,9 @@ assert.strictEqual(ctx._rdQuery(ctx._rdState(new URLSearchParams(ctx._rdQuery(be
'and the old URL restores the old view');
// ── The bar: the search, the chips, the keyboard ──
--assert.match(js, /placeholder="Search the Rundown: a topic, a streamer, something they said"/, 'the search says what it searches');
++assert.match(js, /placeholder="Search topics, streamers, episodes"/, 'the search says what it searches without truncating a long phrase');
++assert.strictEqual(ctx._rdQuery(ctx._rdState(new URLSearchParams('page=2'))), '?page=2', 'crawler pages have stable links');
++assert.match(js, /rel="next"/, 'the feed exposes its next page');
assert.match(js, /setTimeout\(\(\) => _rdSearch\(value\), 250\)/, 'the results follow the typing, 250 ms later');
assert.match(js, /e\.key !== '\/'/, '"/" focuses the search');
assert.match(js, /data-rd-sheet="open"/, 'the phone has one Filters button');
ddiff --git a/test/rundown-search.test.js b/test/rundown-search.test.js
iindex 1a5f7419..3dbdd109 100644
---- a/test/rundown-search.test.js
++++ b/test/rundown-search.test.js
@@@ -143,13 +143,27 @@ assert.ok(r.suggestion.topics.some((t) => t.slug === 'french-fridges-hacked'), '
// ── The state is read from the query string, and nothing else reaches the database ──
assert.deepStrictEqual(browse.parse({ q: 'x', streamer: 'a,b', topic: 'T', when: 'week', show: 'takes', sort: 'reacted', video: '1' }),
-- { q: 'x', streamer: ['a', 'b'], topic: 't', when: 'week', show: 'takes', sort: 'reacted', video: true, following: false, mentions: false, kind: '' });
++ { q: 'x', streamer: ['a', 'b'], topic: 't', when: 'week', show: 'takes', sort: 'reacted', page: 1, video: true, following: false, mentions: false, kind: '' });
assert.deepStrictEqual(browse.parse({ when: 'century', show: 'everything', sort: 'random' }).when, null, 'an unknown when is dropped');
assert.deepStrictEqual(browse.parse({ show: 'everything' }).show, null, 'an unknown show is dropped');
assert.deepStrictEqual(browse.parse({ sort: 'random' }).sort, 'newest', 'an unknown sort falls back to the newest');
assert.strictEqual(browse.parse({ q: "'; drop table users; --" }).q, "'; drop table users; --", 'the words are words, not SQL');
assert.ok(browse.isFiltered(browse.parse({ q: 'x' })), 'a search is a filter');
assert.ok(!browse.isFiltered(browse.parse({})), 'an empty bar is not a filter');
++assert.strictEqual(browse.parse({ page: '9999' }).page, 100, 'page numbers are bounded');
++
++// A crawler can follow page links; scrolling loads disjoint batches of twenty after the first thirty.
++for (let i = 0; i < 50; i++) await take({ stream: sA, user: goose, topic: tFridge, title: `Paging take ${i}`, said: ago(1000 + i) });
++const p1 = await browse.browse({ show: 'takes', page: '1' });
++const p2 = await browse.browse({ show: 'takes', page: '2' });
++const p3 = await browse.browse({ show: 'takes', page: '3' });
++assert.strictEqual(p1.takes.length, 30);
++assert.strictEqual(p2.takes.length, 20);
++assert.strictEqual(p3.takes.length, 3);
++assert.strictEqual(p1.next_page, 2);
++assert.strictEqual(p2.next_page, 3);
++assert.strictEqual(p3.next_page, null);
++assert.strictEqual(new Set([...p1.takes, ...p2.takes, ...p3.takes].map((s) => s.id)).size, 53, 'pages have no repeated takes');
console.log('rundown search: all checks passed');
})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/rundown-uses.test.js b/test/rundown-uses.test.js
iindex bd324e14..b178aa64 100644
---- a/test/rundown-uses.test.js
++++ b/test/rundown-uses.test.js
@@@ -2,8 +2,7 @@
/**
* The Rundown's front page says what a visitor can do there (public/js/rundown.js _rdHow): catch up on a stream,
* follow a topic, jump to the moment, get written up. A viewer said of the page "still don't rly know wtf to do
-- * with this feature" (2026-10-20): it said what it is, never what it is for. The four tiles it used to be became
-- * one line under the title (design 2026-11-06): on a phone they took a whole screen.
++ * with this feature" (2026-10-20): it said what it is, never what it is for. The action card stays compact on a phone.
*
* node test/rundown-uses.test.js
*/
@@@ -24,16 +23,17 @@ const pick = (name) => {
const ctx = { console };
vm.createContext(ctx);
--vm.runInContext(pick('_rdEsc') + pick('_rdMe') + pick('_rdHow'), ctx);
++vm.runInContext(pick('_rdEsc') + pick('_rdA') + pick('_rdMe') + pick('_rdHow'), ctx);
const line = ctx._rdHow();
--assert.match(line, /Catch up, follow a topic, jump to the moment, get written up/, 'the line says what the page is for');
--assert.match(line, /class="rd-how-link" href="#rd-about-h"/, 'and how it works points at the explanation');
++assert.match(line, /Catch up on what was said, then jump into the moment/, 'the card says what the page is for');
++assert.match(line, /href="#rd-about-h">How it works/, 'how it works points at the explanation');
++assert.match(line, /href="\/rundown\/topics">Follow topics/, 'the topic action opens the topic index');
assert.ok(!line.includes('rd-use'), 'the four tiles are one line now');
assert.ok(!js.includes('class="rd-uses"'), 'and the tiles are gone from the page');
assert.match(css, /\.rd-how \{/, 'the line has a style of its own');
--assert.ok(!line.includes('your Rundown'), 'a signed-out visitor has no Rundown of their own');
++assert.match(line, /href="\/api\/auth\/sso\/login\?next=%2Frundown">Your Rundown/, 'a signed-out visitor can sign in');
vm.runInContext("var currentUser = { username: 'goosely' };", ctx);
--assert.match(ctx._rdHow(), /href="\/rundown\/goosely#mine">your Rundown<\/a>/, 'a signed-in person reaches their own Rundown from the line');
++assert.match(ctx._rdHow(), /href="\/rundown\/goosely#mine"/, 'a signed-in person reaches their own Rundown from the card');
console.log('rundown uses: all checks passed');