openvibers/openvibe.live Commits
Home rails: two-up on phones, Load more instead of a pager
Changes
@@@ -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; } }@@@ -88,7 +88,7 @@overlay) and is fetched without blocking the first paint: media="print" makes the browsertreat 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>@@@ -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 = {}) {
@@ -14955,3 +14955,58 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
@@ -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-btn { font-size: 0.74rem; padding: 5px 11px; }
#page-home .hero-philosophy-link { font-size: 0.72rem; }
#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; } }
@@ -88,7 +88,7 @@
@@ -88,7 +88,7 @@
overlay) and is fetched without blocking the first paint: media="print" makes the browser
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. -->
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="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-fx.css?v=11">
<link rel="stylesheet" href="/css/home-tour.css?v=7">
<link rel="stylesheet" href="/css/home-tour.css?v=7">
<link rel="stylesheet" href="/css/i18n-star.css?v=5">
<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
@@ -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="/live-notify.js?v=3" defer></script>
<script src="/js/ov-skeleton.js?v=1" 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/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-player.js?v=22" defer></script>
<script src="/js/stream-pip.js?v=1" defer></script>
<script src="/js/stream-pip.js?v=1" defer></script>
<script src="/js/chat-shared.js?v=3" defer></script>
<script src="/js/chat-shared.js?v=3" defer></script>
@@ -2231,24 +2231,25 @@ async function loadHomeDigest() {
@@ -2231,24 +2231,25 @@ async function loadHomeDigest() {
box.style.display = '';
box.style.display = '';
}
}
-async function loadHomeRecentOnline(page) {
+async function loadHomeRecentOnline(page, append) {
if (page !== undefined) _homeRecentOnlinePage = page;
if (page !== undefined) _homeRecentOnlinePage = page;
const offset = (_homeRecentOnlinePage - 1) * HOME_RECENT_ONLINE_PAGE_SIZE;
const offset = (_homeRecentOnlinePage - 1) * HOME_RECENT_ONLINE_PAGE_SIZE;
try {
try {
const recentData = await api(`/streams/recently-online?limit=${HOME_RECENT_ONLINE_PAGE_SIZE}&offset=${offset}`);
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');
renderHomePagination('stream-grid-recent-pagination', recentData.total || 0, _homeRecentOnlinePage, HOME_RECENT_ONLINE_PAGE_SIZE, 'loadHomeRecentOnline');
} catch { /* silent */ }
} catch { /* silent */ }
}
}
-function renderRecentlyOnline(containerId, streamers) {
+function renderRecentlyOnline(containerId, streamers, append) {
const container = document.getElementById(containerId);
const container = document.getElementById(containerId);
if (!container) return;
if (!container) return;
if (!streamers.length) {
if (!streamers.length) {
- container.innerHTML = '<p class="muted">No recent streamers</p>';
+ if (!append) container.innerHTML = '<p class="muted">No recent streamers</p>';
return;
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 msList = (s.managed_streams || []);
const avatar = _avatarSpan(s.avatar_url, s.username, s.profile_color);
const avatar = _avatarSpan(s.avatar_url, s.username, s.profile_color);
const channelHref = `/@${s.username}`;
const channelHref = `/@${s.username}`;
@@ -2289,10 +2290,10 @@ function renderRecentlyOnline(containerId, streamers) {
@@ -2289,10 +2290,10 @@ function renderRecentlyOnline(containerId, streamers) {
<div class="streamer-group-streams">${streamsHtml}</div>
<div class="streamer-group-streams">${streamsHtml}</div>
</div>
</div>
`;
`;
- }).join('');
+ }).join(''));
}
}
-async function loadHomeRecentVods(page) {
+async function loadHomeRecentVods(page, append) {
if (page !== undefined) _homeRecentVodsPage = page;
if (page !== undefined) _homeRecentVodsPage = page;
const offset = (_homeRecentVodsPage - 1) * HOME_RECENT_VODS_PAGE_SIZE;
const offset = (_homeRecentVodsPage - 1) * HOME_RECENT_VODS_PAGE_SIZE;
try {
try {
@@ -2303,7 +2304,8 @@ async function loadHomeRecentVods(page) {
@@ -2303,7 +2304,8 @@ async function loadHomeRecentVods(page) {
if (!header || !grid) return;
if (!header || !grid) return;
if (!vods.length && _homeRecentVodsPage === 1) { header.style.display = 'none'; grid.innerHTML = ''; return; }
if (!vods.length && _homeRecentVodsPage === 1) { header.style.display = 'none'; grid.innerHTML = ''; return; }
header.style.display = '';
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}`;
const href = `/vod/${v.id}`;
return `
return `
<a class="stream-card" href="${href}" onclick="return handleLinkClick(event, '${href}')">
<a class="stream-card" href="${href}" onclick="return handleLinkClick(event, '${href}')">
@@ -2323,14 +2325,14 @@ async function loadHomeRecentVods(page) {
@@ -2323,14 +2325,14 @@ async function loadHomeRecentVods(page) {
</div>
</div>
</a>
</a>
`;
`;
- }).join('');
+ }).join(''));
renderHomePagination('home-recent-vods-pagination', data.total || 0, _homeRecentVodsPage, HOME_RECENT_VODS_PAGE_SIZE, 'loadHomeRecentVods');
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 });
await apiSWR(`/streams/recent-vods?limit=${HOME_RECENT_VODS_PAGE_SIZE}&offset=${offset}`, render, { ttl: 180000 });
} catch { /* silent */ }
} catch { /* silent */ }
}
}
-async function loadHomeClips(page) {
+async function loadHomeClips(page, append) {
if (page !== undefined) _homeClipsPage = page;
if (page !== undefined) _homeClipsPage = page;
const offset = (_homeClipsPage - 1) * HOME_CLIPS_PAGE_SIZE;
const offset = (_homeClipsPage - 1) * HOME_CLIPS_PAGE_SIZE;
try {
try {
@@ -2340,7 +2342,8 @@ async function loadHomeClips(page) {
@@ -2340,7 +2342,8 @@ async function loadHomeClips(page) {
const grid = document.getElementById('home-clips-grid');
const grid = document.getElementById('home-clips-grid');
if (!clips.length && _homeClipsPage === 1) { if (header) header.style.display = 'none'; return; }
if (!clips.length && _homeClipsPage === 1) { if (header) header.style.display = 'none'; return; }
if (header) header.style.display = '';
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}')">
<a class="stream-card" href="/clip/${c.id}" onclick="return handleLinkClick(event, '/clip/${c.id}')">
<div class="stream-card-thumb">
<div class="stream-card-thumb">
${thumbImg(c.thumbnail_url, 'fa-scissors', c.title, `/api/thumbnails/generate/clip/${c.id}`)}
${thumbImg(c.thumbnail_url, 'fa-scissors', c.title, `/api/thumbnails/generate/clip/${c.id}`)}
@@ -2357,14 +2360,14 @@ async function loadHomeClips(page) {
@@ -2357,14 +2360,14 @@ async function loadHomeClips(page) {
${_cardAiHTML(c.ai_overview_short, c.ai_overview)}
${_cardAiHTML(c.ai_overview_short, c.ai_overview)}
</div>
</div>
</a>
</a>
- `).join('');
+ `).join(''));
renderHomePagination('home-clips-pagination', data.total || 0, _homeClipsPage, HOME_CLIPS_PAGE_SIZE, 'loadHomeClips');
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 });
await apiSWR(`/clips?limit=${HOME_CLIPS_PAGE_SIZE}&offset=${offset}`, render, { ttl: 180000 });
} catch { /* silent */ }
} catch { /* silent */ }
}
}
-async function loadHomePastes(page) {
+async function loadHomePastes(page, append) {
if (page !== undefined) _homePastesPage = page;
if (page !== undefined) _homePastesPage = page;
const offset = (_homePastesPage - 1) * HOME_PASTES_PAGE_SIZE;
const offset = (_homePastesPage - 1) * HOME_PASTES_PAGE_SIZE;
try {
try {
@@ -2374,7 +2377,8 @@ async function loadHomePastes(page) {
@@ -2374,7 +2377,8 @@ async function loadHomePastes(page) {
const list = document.getElementById('home-pastes-list');
const list = document.getElementById('home-pastes-list');
if (!pastes.length && _homePastesPage === 1) { if (header) header.style.display = 'none'; return; }
if (!pastes.length && _homePastesPage === 1) { if (header) header.style.display = 'none'; return; }
if (header) header.style.display = '';
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 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 preview = p.type === 'paste' ? esc((p.content || '').slice(0, 220)).replace(/\n{3,}/g, '\n\n') : '';
const media = p.type === 'screenshot' && p.screenshot_url
const media = p.type === 'screenshot' && p.screenshot_url
@@ -2395,7 +2399,7 @@ async function loadHomePastes(page) {
@@ -2395,7 +2399,7 @@ async function loadHomePastes(page) {
</div>
</div>
</div>
</div>
</a>`;
</a>`;
- }).join('');
+ }).join(''));
renderHomePagination('home-pastes-pagination', data.total || 0, _homePastesPage, HOME_PASTES_PAGE_SIZE, 'loadHomePastes');
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 });
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
@@ -2815,8 +2819,26 @@ function renderVodsPagination(containerId, page, total, pageSize, setterName, it
}
}
// Thin wrapper for homepage section pagination — same visual style as renderVodsPagination.
// 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) {
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 = {}) {
async function renderChannelVodsSection(username, liveStreams, vods, meta = {}) {