perf(feeds): cursor-paginated infinite scroll on /videos

Add VideoController@feedMore -> JSON { items, next_cursor, has_more }
that pages the current filter (all / shorts / match / music / lang:xx)
with Laravel's cursorPaginate(24). Cursor is stable across inserts, so
scrolling never duplicates or skips a video when something is uploaded
mid-session.

Client side: a #feed-sentinel + IntersectionObserver (rootMargin
400px) appends the next page of cards to the existing grid without a
page reload. A Load More button is always wired as the fallback when
IO is unavailable or a fetch fails. Both reuse the existing
<x-video-card/> component, so the newly fetched cards get the same
singleton edit modal + width/height thumbs as the server-rendered ones.

The initial 60-card server render stays, so the first viewport is
unchanged. The limit(60) and limit(50) caps in index() can now be
treated as "first page size" rather than a hard ceiling.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
ghassanandClaude Opus 4.7 committed 2026-10-21 00:50:59 +03:00
1 parent 73620a0f24
commit 8fdac4b483
3 files changed
+136 -1

No files matched your search

+75
View File
@@ -211,6 +211,17 @@
@endif
@endif
{{-- Infinite-scroll sentinel. The IntersectionObserver down in @section('scripts')
fetches the next cursor page when this comes into view and appends the
rendered cards to the grid above. A visible "Load more" button is the
always-available fallback. --}}
<div id="feed-sentinel"
data-feed-filter="{{ $filter ?? 'all' }}"
style="height:1px;"></div>
<div id="feed-loadmore-wrap" style="display:none; text-align:center; padding:16px 0;">
<button type="button" id="feed-loadmore" class="action-btn">Load more</button>
</div>
</div>{{-- /filter-content --}}
@endsection
@@ -354,5 +365,69 @@
if (e.state && e.state.filterUrl) swapTo(e.state.filterUrl, false);
});
})();
/* ─── Cursor-paginated infinite scroll ───────────────────────────────────
The server renders the first page of 60 cards. Everything after that is
fetched here, one 24-card page at a time, when the #feed-sentinel scrolls
into view. The sentinel sits right after the grid, so we never fetch
until the user has actually scrolled past the first page.
State lives on #feed-sentinel.dataset so a SPA filter-swap can reset it
without having to rebind listeners. Fallback: an always-visible "Load
more" button that users without IntersectionObserver can click. */
(function () {
const sentinel = document.getElementById('feed-sentinel');
const grid = document.querySelector('.yt-video-grid');
const btnWrap = document.getElementById('feed-loadmore-wrap');
const btn = document.getElementById('feed-loadmore');
if (!sentinel || !grid) return;
let cursor = ''; // null token = next request is the first continuation page
let busy = false;
let done = false;
const filter = sentinel.dataset.feedFilter || 'all';
async function loadMore() {
if (busy || done) return;
busy = true;
try {
const url = new URL("{{ route('videos.feed.more') }}", window.location.origin);
url.searchParams.set('filter', filter);
if (cursor) url.searchParams.set('cursor', cursor);
const resp = await fetch(url.toString(), {
headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' },
credentials: 'same-origin',
});
if (!resp.ok) throw new Error('bad status: ' + resp.status);
const data = await resp.json();
if (data.items) {
grid.insertAdjacentHTML('beforeend', data.items);
}
cursor = data.next_cursor || '';
done = !data.has_more;
} catch (e) {
// Reveal the manual Load More so a flaky network doesn't dead-end the user.
btnWrap.style.display = 'block';
} finally {
busy = false;
if (done) {
btnWrap.style.display = 'none';
sentinel.style.display = 'none';
}
}
}
btn?.addEventListener('click', loadMore);
if ('IntersectionObserver' in window) {
const io = new IntersectionObserver((entries) => {
if (entries.some(e => e.isIntersecting)) loadMore();
}, { rootMargin: '400px 0px' });
io.observe(sentinel);
} else {
// No IntersectionObserver → surface the Load More fallback immediately.
btnWrap.style.display = 'block';
}
})();
</script>
@endsection