openvibers/openvibe.live Commits

Home rails: two-up on phones, Load more instead of a pager

OP OpenVibers committed 16 Sept 2026 · 3816706da9 · 3 files, +95 −18

Four cards at one per row meant the VOD, clip and paste rails each took a full screen of
scrolling to get past — a taste of what's there read as spam. The secondary rails go two-up on a
phone with a trimmed card: the title clamps to two lines, the meta row shrinks, and the AI
summary is hidden, because three lines of blurb per card buries the thumbnails at that size. Live
Now stays one per row; it is what people came for and it should be big.

The Prev/Next pager is now a single Load more button that appends the next batch and says how
many are left, disappearing when there are none. A page counter belongs on a browse page where
someone is hunting for a particular thing — on a front-page rail it is machinery nobody wants to
operate, and the dedicated VOD, clip and paste pages are one tap away for anyone who does.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CMxJm1dB8rdsaMSgNjVa82

Changes

ddiff --git a/public/css/style.css b/public/css/style.css
iindex 10661f6a..07b167d9 100644
---- a/public/css/style.css
++++ b/public/css/style.css
@@@ -14955,3 +14955,58 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
#page-home .hero-philosophy-btn { font-size: 0.74rem; padding: 5px 11px; }
#page-home .hero-philosophy-link { font-size: 0.72rem; }
}
++
++/* ── Home rails on a phone ───────────────────────────────────────────────────
++ One 300px-minimum card per row meant four VODs, four clips and four pastes each took a full
++ screen of scrolling to get past, which read as spam rather than a taste of what's there. The
++ secondary rails go two-up on a phone with a trimmed-down card: the AI summary and the relative
++ timestamp are detail for a browse page, not for a glance on the way down the front page.
++
++ Live Now keeps one card per row. It is the thing people came for and it should be big. */
++@media (max-width: 640px) {
++ #stream-grid-recent, #home-recent-vods-grid, #home-clips-grid {
++ grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin-bottom: 20px;
++ }
++ #stream-grid-recent .stream-card-title,
++ #home-recent-vods-grid .stream-card-title,
++ #home-clips-grid .stream-card-title {
++ font-size: 0.82rem; line-height: 1.25; display: -webkit-box; -webkit-line-clamp: 2;
++ -webkit-box-orient: vertical; overflow: hidden;
++ }
++ #stream-grid-recent .stream-card-info,
++ #home-recent-vods-grid .stream-card-info,
++ #home-clips-grid .stream-card-info { padding: 8px 9px 9px; }
++ #stream-grid-recent .stream-card-streamer,
++ #home-recent-vods-grid .stream-card-streamer,
++ #home-clips-grid .stream-card-streamer { font-size: 0.72rem; gap: 5px; }
++ /* The AI blurb is three lines of text per card; at two-up it buries the thumbnails. */
++ #home-recent-vods-grid .card-ai-overview,
++ #home-clips-grid .card-ai-overview { display: none; }
++ #home-pastes-list { gap: 10px; }
++}
++
++/* ── Load more ───────────────────────────────────────────────────────────────
++ A Prev/Next pager with a page counter belongs on a browse page where someone is hunting for a
++ particular thing. On a front-page rail it is machinery nobody wants to operate. */
++.home-load-more {
++ display: flex; align-items: center; justify-content: center; gap: 9px;
++ width: 100%; max-width: 420px; margin: 2px auto 26px; padding: 11px 18px;
++ font: inherit; font-size: 0.85rem; font-weight: 750; cursor: pointer;
++ color: color-mix(in srgb, var(--accent) 88%, var(--text-primary));
++ background: color-mix(in srgb, var(--accent) 9%, transparent);
++ border: 1px solid color-mix(in srgb, var(--accent) 28%, transparent);
++ border-radius: 14px;
++ transition: transform 0.18s cubic-bezier(.2, 1, .3, 1), background 0.2s, border-color 0.2s, box-shadow 0.22s;
++}
++.home-load-more:hover {
++ transform: translateY(-1px);
++ background: color-mix(in srgb, var(--accent) 16%, transparent);
++ border-color: color-mix(in srgb, var(--accent) 52%, transparent);
++ box-shadow: 0 8px 22px color-mix(in srgb, var(--accent) 20%, transparent);
++}
++.home-load-more:active { transform: translateY(0) scale(0.99); }
++.home-load-more:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
++.home-load-more i { font-size: 0.74rem; transition: transform 0.25s; }
++.home-load-more:hover i { transform: translateY(2px); }
++.home-load-more small { font-size: 0.72rem; font-weight: 600; color: var(--text-muted); }
++@media (prefers-reduced-motion: reduce) { .home-load-more, .home-load-more i { transition: none; } }
ddiff --git a/public/index.html b/public/index.html
iindex 77803666..196634a5 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -88,7 +88,7 @@
overlay) and is fetched without blocking the first paint: media="print" makes the browser
treat it as non-critical, and the onload flips it back to all once it has arrived. -->
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
-- <link rel="stylesheet" href="/css/style.css?v=176">
++ <link rel="stylesheet" href="/css/style.css?v=177">
<link rel="stylesheet" href="/css/home-fx.css?v=11">
<link rel="stylesheet" href="/css/home-tour.css?v=7">
<link rel="stylesheet" href="/css/i18n-star.css?v=5">
@@@ -5130,7 +5130,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<script src="/live-notify.js?v=3" defer></script>
<script src="/js/ov-skeleton.js?v=1" defer></script>
<script src="/js/ov-odometer.js?v=3" defer></script>
--<script src="/js/app.js?v=151" defer></script>
++<script src="/js/app.js?v=152" defer></script>
<script src="/js/stream-player.js?v=22" defer></script>
<script src="/js/stream-pip.js?v=1" defer></script>
<script src="/js/chat-shared.js?v=3" defer></script>
ddiff --git a/public/js/app.js b/public/js/app.js
iindex cec7fac8..bdeb9d63 100644
---- a/public/js/app.js
++++ b/public/js/app.js
@@@ -2231,24 +2231,25 @@ async function loadHomeDigest() {
box.style.display = '';
}
--async function loadHomeRecentOnline(page) {
++async function loadHomeRecentOnline(page, append) {
if (page !== undefined) _homeRecentOnlinePage = page;
const offset = (_homeRecentOnlinePage - 1) * HOME_RECENT_ONLINE_PAGE_SIZE;
try {
const recentData = await api(`/streams/recently-online?limit=${HOME_RECENT_ONLINE_PAGE_SIZE}&offset=${offset}`);
-- renderRecentlyOnline('stream-grid-recent', recentData.streamers || []);
++ renderRecentlyOnline('stream-grid-recent', recentData.streamers || [], append);
renderHomePagination('stream-grid-recent-pagination', recentData.total || 0, _homeRecentOnlinePage, HOME_RECENT_ONLINE_PAGE_SIZE, 'loadHomeRecentOnline');
} catch { /* silent */ }
}
--function renderRecentlyOnline(containerId, streamers) {
++function renderRecentlyOnline(containerId, streamers, append) {
const container = document.getElementById(containerId);
if (!container) return;
if (!streamers.length) {
-- container.innerHTML = '<p class="muted">No recent streamers</p>';
++ if (!append) container.innerHTML = '<p class="muted">No recent streamers</p>';
return;
}
-- container.innerHTML = streamers.map(s => {
++ const _put = (html) => { if (append) container.insertAdjacentHTML('beforeend', html); else container.innerHTML = html; };
++ _put(streamers.map(s => {
const msList = (s.managed_streams || []);
const avatar = _avatarSpan(s.avatar_url, s.username, s.profile_color);
const channelHref = `/@${s.username}`;
@@@ -2289,10 +2290,10 @@ function renderRecentlyOnline(containerId, streamers) {
<div class="streamer-group-streams">${streamsHtml}</div>
</div>
`;
-- }).join('');
++ }).join(''));
}
--async function loadHomeRecentVods(page) {
++async function loadHomeRecentVods(page, append) {
if (page !== undefined) _homeRecentVodsPage = page;
const offset = (_homeRecentVodsPage - 1) * HOME_RECENT_VODS_PAGE_SIZE;
try {
@@@ -2303,7 +2304,8 @@ async function loadHomeRecentVods(page) {
if (!header || !grid) return;
if (!vods.length && _homeRecentVodsPage === 1) { header.style.display = 'none'; grid.innerHTML = ''; return; }
header.style.display = '';
-- grid.innerHTML = vods.map(v => {
++ const _put = (html) => { if (append) grid.insertAdjacentHTML('beforeend', html); else grid.innerHTML = html; };
++ _put(vods.map(v => {
const href = `/vod/${v.id}`;
return `
<a class="stream-card" href="${href}" onclick="return handleLinkClick(event, '${href}')">
@@@ -2323,14 +2325,14 @@ async function loadHomeRecentVods(page) {
</div>
</a>
`;
-- }).join('');
++ }).join(''));
renderHomePagination('home-recent-vods-pagination', data.total || 0, _homeRecentVodsPage, HOME_RECENT_VODS_PAGE_SIZE, 'loadHomeRecentVods');
};
await apiSWR(`/streams/recent-vods?limit=${HOME_RECENT_VODS_PAGE_SIZE}&offset=${offset}`, render, { ttl: 180000 });
} catch { /* silent */ }
}
--async function loadHomeClips(page) {
++async function loadHomeClips(page, append) {
if (page !== undefined) _homeClipsPage = page;
const offset = (_homeClipsPage - 1) * HOME_CLIPS_PAGE_SIZE;
try {
@@@ -2340,7 +2342,8 @@ async function loadHomeClips(page) {
const grid = document.getElementById('home-clips-grid');
if (!clips.length && _homeClipsPage === 1) { if (header) header.style.display = 'none'; return; }
if (header) header.style.display = '';
-- grid.innerHTML = clips.map(c => `
++ const _put = (html) => { if (append) grid.insertAdjacentHTML('beforeend', html); else grid.innerHTML = html; };
++ _put(clips.map(c => `
<a class="stream-card" href="/clip/${c.id}" onclick="return handleLinkClick(event, '/clip/${c.id}')">
<div class="stream-card-thumb">
${thumbImg(c.thumbnail_url, 'fa-scissors', c.title, `/api/thumbnails/generate/clip/${c.id}`)}
@@@ -2357,14 +2360,14 @@ async function loadHomeClips(page) {
${_cardAiHTML(c.ai_overview_short, c.ai_overview)}
</div>
</a>
-- `).join('');
++ `).join(''));
renderHomePagination('home-clips-pagination', data.total || 0, _homeClipsPage, HOME_CLIPS_PAGE_SIZE, 'loadHomeClips');
};
await apiSWR(`/clips?limit=${HOME_CLIPS_PAGE_SIZE}&offset=${offset}`, render, { ttl: 180000 });
} catch { /* silent */ }
}
--async function loadHomePastes(page) {
++async function loadHomePastes(page, append) {
if (page !== undefined) _homePastesPage = page;
const offset = (_homePastesPage - 1) * HOME_PASTES_PAGE_SIZE;
try {
@@@ -2374,7 +2377,8 @@ async function loadHomePastes(page) {
const list = document.getElementById('home-pastes-list');
if (!pastes.length && _homePastesPage === 1) { if (header) header.style.display = 'none'; return; }
if (header) header.style.display = '';
-- list.innerHTML = pastes.map(p => {
++ const _put = (html) => { if (append) list.insertAdjacentHTML('beforeend', html); else list.innerHTML = html; };
++ _put(pastes.map(p => {
const icon = p.type === 'screenshot' ? 'fa-image' : (p.language && p.language !== 'plaintext' ? 'fa-code' : 'fa-file-lines');
const preview = p.type === 'paste' ? esc((p.content || '').slice(0, 220)).replace(/\n{3,}/g, '\n\n') : '';
const media = p.type === 'screenshot' && p.screenshot_url
@@@ -2395,7 +2399,7 @@ async function loadHomePastes(page) {
</div>
</div>
</a>`;
-- }).join('');
++ }).join(''));
renderHomePagination('home-pastes-pagination', data.total || 0, _homePastesPage, HOME_PASTES_PAGE_SIZE, 'loadHomePastes');
};
await apiSWR(`/pastes?limit=${HOME_PASTES_PAGE_SIZE}&offset=${offset}`, render, { ttl: 180000 });
@@@ -2815,8 +2819,26 @@ function renderVodsPagination(containerId, page, total, pageSize, setterName, it
}
// Thin wrapper for homepage section pagination — same visual style as renderVodsPagination.
++/**
++ * Home rails get a "Load more" button, not a pager.
++ *
++ * Prev/Next with a page counter belongs on a browse page where someone is hunting for a specific
++ * thing. On a front page rail nobody wants to operate a paginator — they want a bit more of what
++ * they're looking at, or they want the dedicated page. So: one button that appends the next
++ * batch, showing how many are left, and it disappears when there are none.
++ */
function renderHomePagination(containerId, total, page, pageSize, setterName) {
-- renderVodsPagination(containerId, page, total, pageSize, setterName, 'items');
++ const el = document.getElementById(containerId);
++ if (!el) return;
++ const shown = page * pageSize;
++ const left = Math.max(0, (total || 0) - shown);
++ if (left <= 0) { el.innerHTML = ''; return; }
++ const next = Math.min(left, pageSize);
++ el.innerHTML = `<button type="button" class="home-load-more" onclick="${setterName}(${page + 1}, true)">
++ <i class="fa-solid fa-arrow-down"></i>
++ <span>Load ${next} more</span>
++ <small>${left.toLocaleString()} left</small>
++ </button>`;
}
async function renderChannelVodsSection(username, liveStreams, vods, meta = {}) {