feat(channel): /@handle/{tab} aliases + cursor pagination on Videos tab
Two complementary additions that share the same bridge-not-rewrite
philosophy — reuse the existing single-page channel view instead of
splitting its 2000 lines into five route-driven partials.
/@handle/{tab}: videos / shorts / playlists / posts / about / wall
redirect to the equivalent /channel/{slug}#tab URL (302). The channel
page's existing hash-tab JS already handles landing on the right tab;
these aliases just make the URLs shareable in the YouTube style.
Channel Videos tab Load More: new JSON endpoint
GET /channel/{username}/videos/more returns the next cursor page of
server-rendered video cards. A sentinel + IntersectionObserver below
the grid fetches the next page when scrolled into view, with a Load
More button as fallback. Behavior mirrors the Phase 2 home feed —
users with thousands of uploads no longer get a 300-item grid on
first paint.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
1 parent
f7c3b236ab
commit
677e8711dd
3 files changed
+99
-1
No files matched your search
@@ -701,6 +701,56 @@ $headerSocialMap = [
|
||||
<span>No videos match your search</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Cursor-paginated Load More. The sentinel sits below the grid and
|
||||
fetches the next slice when scrolled into view. A manual Load More
|
||||
button is the fallback for browsers without IntersectionObserver. --}}
|
||||
<div id="chVidSentinel"
|
||||
data-username="{{ $user->username }}"
|
||||
data-more-url="{{ route('channel.videos.more', ['username' => $user->username]) }}"
|
||||
style="height:1px;"></div>
|
||||
<div id="chVidLoadMoreWrap" style="display:none; text-align:center; padding:16px 0;">
|
||||
<button type="button" id="chVidLoadMoreBtn" class="action-btn">Load more</button>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
const grid = document.getElementById('chVidGrid');
|
||||
const sentinel = document.getElementById('chVidSentinel');
|
||||
const btnWrap = document.getElementById('chVidLoadMoreWrap');
|
||||
const btn = document.getElementById('chVidLoadMoreBtn');
|
||||
if (!grid || !sentinel) return;
|
||||
let cursor = '', busy = false, done = false;
|
||||
const url = sentinel.dataset.moreUrl;
|
||||
async function loadMore() {
|
||||
if (busy || done) return;
|
||||
busy = true;
|
||||
try {
|
||||
const u = new URL(url, window.location.origin);
|
||||
if (cursor) u.searchParams.set('cursor', cursor);
|
||||
const r = await fetch(u.toString(), {
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
if (!r.ok) throw new Error('bad');
|
||||
const d = await r.json();
|
||||
if (d.items) grid.insertAdjacentHTML('beforeend', d.items);
|
||||
cursor = d.next_cursor || '';
|
||||
done = !d.has_more;
|
||||
if (done) { sentinel.style.display = 'none'; btnWrap.style.display = 'none'; }
|
||||
} catch (e) {
|
||||
btnWrap.style.display = 'block';
|
||||
} finally { busy = false; }
|
||||
}
|
||||
btn?.addEventListener('click', loadMore);
|
||||
if ('IntersectionObserver' in window) {
|
||||
new IntersectionObserver(entries => {
|
||||
if (entries.some(e => e.isIntersecting)) loadMore();
|
||||
}, { rootMargin: '400px 0px' }).observe(sentinel);
|
||||
} else {
|
||||
btnWrap.style.display = 'block';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
@else
|
||||
<div class="ch-empty">
|
||||
<div class="ch-empty-icon"><i class="bi bi-camera-video"></i></div>
|
||||
|
||||
Reference in new issue
Block a user