openvibers/openvibe.live Commits
Polish the Rundown feed and sticky search bar (#720)
Changes
@@@ -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; } }@@@ -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) => {@@@ -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 endconst MAX_EPISODES = 20; // how many episodes one page holdsconst MAX_TOPICS = 40; // how many topics one page holdsconst 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 : [],@@@ -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) {@@@ -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');@@@ -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); });@@@ -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');
@@ -243,8 +243,11 @@ a.rd-use:hover b { color: var(--accent); }
@@ -243,8 +243,11 @@ a.rd-use:hover b { color: var(--accent); }
/* ── The bar: search and filters (public/js/rundown.js) ──────────────────────────────────
/* ── 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
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. */
"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-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;
.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); }
border-radius: 12px; background: var(--bg-card); border: 1px solid var(--border); }
@@ -461,3 +464,84 @@ body.rd-sheet-open { overflow: hidden; }
@@ -461,3 +464,84 @@ body.rd-sheet-open { overflow: hidden; }
#rundown-root .rd-loadmore { min-height: 44px; }
#rundown-root .rd-loadmore { min-height: 44px; }
#rundown-root .rd-more-link { min-height: 44px; display: inline-flex; align-items: center; }
#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; } }
@@ -34,7 +34,17 @@ function _rdAgo(d) {
@@ -34,7 +34,17 @@ function _rdAgo(d) {
const h = Math.round(m / 60); if (h < 48) return `${h}h ago`;
const h = Math.round(m / 60); if (h < 48) return `${h}h ago`;
return `${Math.round(h / 24)}d 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 _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 _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) : ''; }
function _rdName(u) { return u ? (u.display_name || u.username) : ''; }
@@ -75,10 +85,11 @@ function _rdSpeak(btn) {
@@ -75,10 +85,11 @@ function _rdSpeak(btn) {
}
}
function _rdReactions(type, id, reactions = {}, mine = null) {
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);
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>`;
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 ────────────────────────────────────────────────────
// ── Cards ────────────────────────────────────────────────────
@@ -172,19 +183,29 @@ function _rdIssue(g) {
@@ -172,19 +183,29 @@ function _rdIssue(g) {
}
}
// ── The front page ───────────────────────────────────────────
// ── 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">
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>
<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>
<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>`;
</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
// 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.
// ("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() {
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) {
function _rdOnAir(list) {
if (!list.length) return '';
if (!list.length) return '';
@@ -507,6 +528,7 @@ function _rdState(query) {
@@ -507,6 +528,7 @@ function _rdState(query) {
following: one('following') === '1',
following: one('following') === '1',
mentions: one('mentions') === '1',
mentions: one('mentions') === '1',
kind: one('kind').slice(0, 20),
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. */
/** The state as a query string: the same filters, the same order, every time. */
@@ -522,6 +544,7 @@ function _rdQuery(state) {
@@ -522,6 +544,7 @@ function _rdQuery(state) {
if (state.following) p.set('following', '1');
if (state.following) p.set('following', '1');
if (state.mentions) p.set('mentions', '1');
if (state.mentions) p.set('mentions', '1');
if (state.kind) p.set('kind', state.kind);
if (state.kind) p.set('kind', state.kind);
+ if (state.page > 1) p.set('page', String(state.page));
const s = p.toString();
const s = p.toString();
return s ? '?' + s : '';
return s ? '?' + s : '';
}
}
@@ -535,6 +558,7 @@ let _rdPicker = '';
@@ -535,6 +558,7 @@ let _rdPicker = '';
let _rdPickQueries = { streamer: '', topic: '' };
let _rdPickQueries = { streamer: '', topic: '' };
let _rdSheetScroll = 0;
let _rdSheetScroll = 0;
let _rdSearchTimer = null;
let _rdSearchTimer = null;
+let _rdMoreObserver = null;
let _rdPreset = null;
let _rdPreset = null;
const _rdTopicNames = new Map();
const _rdTopicNames = new Map();
@@ -581,7 +605,7 @@ function _rdBar(state, preset) {
@@ -581,7 +605,7 @@ function _rdBar(state, preset) {
'<div class="rd-bar-row">' +
'<div class="rd-bar-row">' +
'<div class="rd-search">' +
'<div class="rd-search">' +
'<i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i>' +
'<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>' : '') +
(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>' +
'</div>' +
'<button type="button" class="rd-sheet-btn" data-rd-sheet="open" aria-haspopup="dialog">Filters' + (n ? ' (' + n + ')' : '') + '</button>' +
'<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) {
@@ -687,11 +711,11 @@ function _rdSheetPanel(state) {
function _rdTakeResult(s) {
function _rdTakeResult(s) {
const q = (s.quotes || [])[0];
const q = (s.quotes || [])[0];
return '<article class="rd-card rd-take" id="rd-take-' + s.id + '">' +
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>' +
'<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>') +
(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>';
'</article>';
}
}
// One take inside an episode: a line, with the moment it was said.
// One take inside an episode: a line, with the moment it was said.
@@ -702,11 +726,11 @@ function _rdTakeMini(s) {
@@ -702,11 +726,11 @@ function _rdTakeMini(s) {
'</div>';
'</div>';
}
}
// An episode card: the title, the logline, its takes folded to the top ones, and how many are folded away.
// 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 folded = takes.slice(0, e.folded || 3);
const rest = takes.slice(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
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>' +
? '<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>'
'<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) {
@@ -739,13 +763,31 @@ function _rdNoResults(d) {
}
}
function _rdResultsHtml(d, state) {
function _rdResultsHtml(d, state) {
if (state.show === 'topics') return (d.topics || []).length ? d.topics.map(_rdTopicCard).join('') : _rdNoResults(d);
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).
// The kinds, for the topics view (the old topics page had them).
function _rdKindChips(state) {
function _rdKindChips(state) {
@@ -762,8 +804,8 @@ function _rdRails(d) {
@@ -762,8 +804,8 @@ function _rdRails(d) {
const trending = (d.trending || []).map(_rdTopicFilter).join('');
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('');
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">' +
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>';
'</div>';
}
}
@@ -774,29 +816,109 @@ function _rdPathState(path, query) {
@@ -774,29 +816,109 @@ function _rdPathState(path, query) {
if (path === '/rundown/topics' && !st.show) st.show = 'topics';
if (path === '/rundown/topics' && !st.show) st.show = 'topics';
return st;
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) {
async function _rdBrowse(root, state) {
+ root.innerHTML = _rdBar(state, null) + _rdSkeleton();
+ _rdBarState();
const d = await api('/rundown/browse' + _rdQuery(state));
const d = await api('/rundown/browse' + _rdQuery(state));
_rdRememberTopics(d.topics);
_rdRememberTopics(d.topics);
_rdRememberTopics(d.trending);
_rdRememberTopics(d.trending);
root.innerHTML = _rdBar(state, null) +
root.innerHTML = _rdBar(state, null) +
'<div id="rd-active-host">' + _rdActiveChips(state, null) + '</div>' +
'<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 id="rd-rails-host">' + _rdRails(d) + '</div>' +
'<div class="rd-grid"><div class="rd-main">' +
'<div class="rd-grid"><div class="rd-main">' +
_rdOnAir(d.on_air || []) +
_rdOnAir(d.on_air || []) +
(state.show === 'topics' ? _rdKindChips(state) : '') +
(state.show === 'topics' ? _rdKindChips(state) : '') +
'<p class="rd-count" id="rd-count">' + _rdCountLine(d, state) + '</p>' +
'<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>';
'</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';
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. */
/** Only the results change while the words are typed: the page is not redrawn under the reader. */
async function _rdRefresh() {
async function _rdRefresh() {
const state = _rdPathState(location.pathname, new URLSearchParams(location.search));
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));
const d = await api('/rundown/browse' + _rdQuery(state));
_rdRememberTopics(d.topics);
_rdRememberTopics(d.topics);
- const results = document.getElementById('rd-results');
if (results) results.innerHTML = _rdResultsHtml(d, state);
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');
const count = document.getElementById('rd-count');
if (count) count.textContent = _rdCountLine(d, state);
if (count) count.textContent = _rdCountLine(d, state);
const active = document.getElementById('rd-active-host');
const active = document.getElementById('rd-active-host');
@@ -807,6 +929,7 @@ async function _rdRefresh() {
@@ -807,6 +929,7 @@ async function _rdRefresh() {
// ── What the bar does ──────────────────────────────────────────────────────
// ── What the bar does ──────────────────────────────────────────────────────
/** A new view: the filters in the URL, and the back button gets a step. */
/** A new view: the filters in the URL, and the back button gets a step. */
function _rdGo(state) {
function _rdGo(state) {
+ state.page = 1;
const url = _rdUrl(state);
const url = _rdUrl(state);
if (url === location.pathname + location.search) return;
if (url === location.pathname + location.search) return;
navigate(url);
navigate(url);
@@ -908,6 +1031,7 @@ function _rdFold(btn) {
@@ -908,6 +1031,7 @@ function _rdFold(btn) {
async function _rdSearch(value) {
async function _rdSearch(value) {
const state = _rdState(new URLSearchParams(location.search));
const state = _rdState(new URLSearchParams(location.search));
state.q = String(value || '').trim();
state.q = String(value || '').trim();
+ state.page = 1;
const url = _rdUrl(state);
const url = _rdUrl(state);
if (location.pathname !== '/rundown' && location.pathname !== '/rundown/topics') { navigate(url); return; }
if (location.pathname !== '/rundown' && location.pathname !== '/rundown/topics') { navigate(url); return; }
if (url === location.pathname + location.search) return;
if (url === location.pathname + location.search) return;
@@ -946,6 +1070,15 @@ async function loadRundownPage(segments = []) {
@@ -946,6 +1070,15 @@ async function loadRundownPage(segments = []) {
document.addEventListener('click', async (e) => {
document.addEventListener('click', async (e) => {
const root = document.getElementById('rundown-root');
const root = document.getElementById('rundown-root');
if (!root || !root.contains(e.target)) return;
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]');
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; }
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]');
const more = e.target.closest('[data-rd-more]');
@@ -978,6 +1111,14 @@ document.addEventListener('click', async (e) => {
@@ -978,6 +1111,14 @@ document.addEventListener('click', async (e) => {
const restore = e.target.closest('[data-rd-restore]');
const restore = e.target.closest('[data-rd-restore]');
if (restore) { e.preventDefault(); _rdRestore(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 ────────────────────────────────────────────────────────
// ── The bar's events ────────────────────────────────────────────────────────
document.addEventListener('click', (e) => {
document.addEventListener('click', (e) => {
@@ -21,7 +21,8 @@ const read = require('./read');
@@ -21,7 +21,8 @@ const read = require('./read');
const { VISIBLE, AUDIENCE_JOINS, SEGMENT_COLS, EPISODE_COLS } = 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_EPISODES = 20; // how many episodes one page holds
const MAX_TOPICS = 40; // how many topics 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
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 = {}) {
@@ -47,8 +48,9 @@ function parse(query = {}) {
const when = Object.prototype.hasOwnProperty.call(WHENS, one(query.when).toLowerCase()) ? one(query.when).toLowerCase() : null;
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 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 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 {
return {
- q, streamer, topic, when, show, sort,
+ q, streamer, topic, when, show, sort, page,
video: truthy(one(query.video)),
video: truthy(one(query.video)),
following: truthy(one(query.following)),
following: truthy(one(query.following)),
mentions: truthy(one(query.mentions)),
mentions: truthy(one(query.mentions)),
@@ -163,14 +165,16 @@ async function results(st, { users, topicId, prefix, viewer }) {
@@ -163,14 +165,16 @@ async function results(st, { users, topicId, prefix, viewer }) {
const topf = topicFilters(st, { users, 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 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
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
${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 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
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
${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 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,
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 }) {
@@ -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) };
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. */
/** The topics whose names are closest to what was searched: the way out of an empty search. */
async function closestTopics(st, { topicId }) {
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");
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 } = {}) {
@@ -222,6 +247,7 @@ async function browse(query = {}, { viewer = null } = {}) {
const r = await results(st, { users, topicId, prefix, viewer: v.userId });
const r = await results(st, { users, topicId, prefix, viewer: v.userId });
const c = await counts(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 takes = await read.segmentsOut(r.takeRows, v.key);
const episodes = await read.episodesOut(r.epRows, v.key);
const episodes = await read.episodesOut(r.epRows, v.key);
@@ -264,8 +290,10 @@ async function browse(query = {}, { viewer = null } = {}) {
@@ -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 });
const withTakes = (e) => ({ ...e, takes: epTakes.get(e.stream_id) || [], takes_total: totals.get(e.stream_id) || 0, folded: FOLDED });
return {
return {
filters: st,
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),
active: activeKeys(st),
counts: c,
counts: c,
+ stats,
takes: show === 'episodes' || show === 'topics' ? [] : takes,
takes: show === 'episodes' || show === 'topics' ? [] : takes,
episodes: show === 'takes' || show === 'topics' ? [] : episodes.map(withTakes),
episodes: show === 'takes' || show === 'topics' ? [] : episodes.map(withTakes),
topics: show === 'topics' ? topics : [],
topics: show === 'topics' ? topics : [],
@@ -215,7 +215,7 @@ async function _pageMeta(routePath, { page = 1, query = null } = {}) {
@@ -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(/^\/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(CHANNEL_PATH_RE))) return await _channelMeta(m[1], page);
if ((m = p.match(/^\/recap\/(\d+)$/))) return await _recapMeta(parseInt(m[1], 10));
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.
// 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: [] };
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).
// 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) {
@@ -426,7 +426,7 @@ function _rundownKey(query) {
if (String(q.q || '').trim()) parts.push('q');
if (String(q.q || '').trim()) parts.push('q');
return parts.join('&');
return parts.join('&');
}
}
-async function _rundownMeta(p, query) {
+async function _rundownMeta(p, query, page = 1) {
const read = require('../rundown/read');
const read = require('../rundown/read');
const crumbs = [{ name: 'Home', url: '/' }, { name: 'The Rundown', url: '/rundown' }];
const crumbs = [{ name: 'Home', url: '/' }, { name: 'The Rundown', url: '/rundown' }];
const base = { image: shareCard('live-rundown'), ogType: 'website' };
const base = { image: shareCard('live-rundown'), ogType: 'website' };
@@ -440,15 +440,21 @@ async function _rundownMeta(p, query) {
@@ -440,15 +440,21 @@ async function _rundownMeta(p, query) {
if (p === '/rundown') {
if (p === '/rundown') {
let home = null;
let home = null;
try { home = await read.home({}); } catch { 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 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 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 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);
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 }),
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)],
_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') {
if (p === '/rundown/topics') {
const plain = await _rundownMeta('/rundown', null);
const plain = await _rundownMeta('/rundown', null);
@@ -879,6 +885,7 @@ function render(meta, urlPath) {
@@ -879,6 +885,7 @@ function render(meta, urlPath) {
let html = _base();
let html = _base();
if (!html) return null;
if (!html) return null;
html = assets.renderRoute(html, urlPath || meta.canonicalPath || '/');
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,
// 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.
// 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) {
@@ -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}$)/;
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) {
function _pageParam(p, query) {
const feed = p === '/content' || p === '/moments';
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 : '';
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) {
async function middleware(req, res, next) {
@@ -53,7 +53,9 @@ assert.strictEqual(ctx._rdQuery(ctx._rdState(new URLSearchParams(ctx._rdQuery(be
@@ -53,7 +53,9 @@ assert.strictEqual(ctx._rdQuery(ctx._rdState(new URLSearchParams(ctx._rdQuery(be
'and the old URL restores the old view');
'and the old URL restores the old view');
// ── The bar: the search, the chips, the keyboard ──
// ── 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, /setTimeout\(\(\) => _rdSearch\(value\), 250\)/, 'the results follow the typing, 250 ms later');
assert.match(js, /e\.key !== '\/'/, '"/" focuses the search');
assert.match(js, /e\.key !== '\/'/, '"/" focuses the search');
assert.match(js, /data-rd-sheet="open"/, 'the phone has one Filters button');
assert.match(js, /data-rd-sheet="open"/, 'the phone has one Filters button');
@@ -143,13 +143,27 @@ assert.ok(r.suggestion.topics.some((t) => t.slug === 'french-fridges-hacked'), '
@@ -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 ──
// ── 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' }),
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({ 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({ 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.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.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({ q: 'x' })), 'a search is a filter');
assert.ok(!browse.isFiltered(browse.parse({})), 'an empty bar is not 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');
console.log('rundown search: all checks passed');
})().catch((err) => { console.error(err); process.exit(1); });
})().catch((err) => { console.error(err); process.exit(1); });
@@ -2,8 +2,7 @@
@@ -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,
* 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
* 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
* node test/rundown-uses.test.js
*/
*/
@@ -24,16 +23,17 @@ const pick = (name) => {
@@ -24,16 +23,17 @@ const pick = (name) => {
const ctx = { console };
const ctx = { console };
vm.createContext(ctx);
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();
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(!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.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.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);
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');
console.log('rundown uses: all checks passed');