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
@@ -18,7 +18,7 @@ class UserController extends Controller
|
|||||||
{
|
{
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->middleware('auth')->except(['channel']);
|
$this->middleware('auth')->except(['channel', 'channelVideosMore']);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Typeahead search for members (used by the "Share to member" picker)
|
// Typeahead search for members (used by the "Share to member" picker)
|
||||||
@@ -372,6 +372,38 @@ class UserController extends Controller
|
|||||||
return redirect()->route('history')->with('toast_success', 'Watch history cleared.');
|
return redirect()->route('history')->with('toast_success', 'Watch history cleared.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON endpoint powering the "Load more" button on the channel Videos
|
||||||
|
* tab. Takes a username + optional cursor and returns a page of
|
||||||
|
* server-rendered video cards + next cursor. Reuses the cursorPaginate
|
||||||
|
* pattern Phase 2 established for the home feed.
|
||||||
|
*/
|
||||||
|
public function channelVideosMore(Request $request, string $username)
|
||||||
|
{
|
||||||
|
$owner = User::where('username', $username)->firstOrFail();
|
||||||
|
$viewer = Auth::user();
|
||||||
|
$isOwner = $viewer && $viewer->id === $owner->id;
|
||||||
|
|
||||||
|
$query = $isOwner
|
||||||
|
? Video::where('user_id', $owner->id)
|
||||||
|
: Video::public()->where('user_id', $owner->id);
|
||||||
|
|
||||||
|
$paginator = $query->where('is_shorts', false)
|
||||||
|
->latest('id')
|
||||||
|
->cursorPaginate(24);
|
||||||
|
|
||||||
|
$html = '';
|
||||||
|
foreach ($paginator->items() as $video) {
|
||||||
|
$html .= view('components.video-card', ['video' => $video, 'hideChannelInfo' => true])->render();
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'items' => $html,
|
||||||
|
'next_cursor' => optional($paginator->nextCursor())->encode(),
|
||||||
|
'has_more' => $paginator->hasMorePages(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Subscriptions feed — the public videos uploaded by every channel the
|
* Subscriptions feed — the public videos uploaded by every channel the
|
||||||
* viewer is subscribed to, newest first. Replaces the dead href="#" that
|
* viewer is subscribed to, newest first. Replaces the dead href="#" that
|
||||||
|
|||||||
@@ -701,6 +701,56 @@ $headerSocialMap = [
|
|||||||
<span>No videos match your search</span>
|
<span>No videos match your search</span>
|
||||||
</div>
|
</div>
|
||||||
</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
|
@else
|
||||||
<div class="ch-empty">
|
<div class="ch-empty">
|
||||||
<div class="ch-empty-icon"><i class="bi bi-camera-video"></i></div>
|
<div class="ch-empty-icon"><i class="bi bi-camera-video"></i></div>
|
||||||
|
|||||||
@@ -188,6 +188,8 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
|||||||
|
|
||||||
// Channel - public for viewing. {username} is the user's slug (non-sequential, safe to share).
|
// Channel - public for viewing. {username} is the user's slug (non-sequential, safe to share).
|
||||||
Route::get('/channel/{username?}', [UserController::class, 'channel'])->name('channel');
|
Route::get('/channel/{username?}', [UserController::class, 'channel'])->name('channel');
|
||||||
|
Route::get('/channel/{username}/videos/more', [UserController::class, 'channelVideosMore'])
|
||||||
|
->name('channel.videos.more');
|
||||||
// YouTube-style @handle alias. For now `handle` is just the existing
|
// YouTube-style @handle alias. For now `handle` is just the existing
|
||||||
// `username` slug — if the two ever diverge, flip the controller lookup
|
// `username` slug — if the two ever diverge, flip the controller lookup
|
||||||
// in UserController::channel() to prefer a dedicated `handle` column.
|
// in UserController::channel() to prefer a dedicated `handle` column.
|
||||||
@@ -197,6 +199,20 @@ Route::get('/@{handle}', [UserController::class, 'channel'])
|
|||||||
->where('handle', '[A-Za-z0-9][A-Za-z0-9._-]*')
|
->where('handle', '[A-Za-z0-9][A-Za-z0-9._-]*')
|
||||||
->name('channel.handle');
|
->name('channel.handle');
|
||||||
|
|
||||||
|
// Tab aliases: /@handle/videos lands on the Videos tab, /@handle/shorts on
|
||||||
|
// Shorts, etc. These bridge to the single-page channel view by forwarding
|
||||||
|
// the tab name as a hash — the channel.blade.php JS already handles
|
||||||
|
// #wall/#videos/#shorts/#playlists/#about/#posts on load.
|
||||||
|
Route::get('/@{handle}/{tab}', function (string $handle, string $tab) {
|
||||||
|
$allowed = ['wall', 'videos', 'shorts', 'playlists', 'posts', 'about'];
|
||||||
|
if (! in_array($tab, $allowed, true)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
$user = \App\Models\User::where('username', $handle)->firstOrFail();
|
||||||
|
return redirect(route('channel', $user->channel) . '#' . $tab, 302);
|
||||||
|
})->where(['handle' => '[A-Za-z0-9][A-Za-z0-9._-]*', 'tab' => '[a-z]+'])
|
||||||
|
->name('channel.handle.tab');
|
||||||
|
|
||||||
// Post routes - auth required
|
// Post routes - auth required
|
||||||
Route::middleware(['auth'])->group(function () {
|
Route::middleware(['auth'])->group(function () {
|
||||||
Route::post('/channel/{user}/posts', [PostController::class, 'store'])->name('posts.store');
|
Route::post('/channel/{user}/posts', [PostController::class, 'store'])->name('posts.store');
|
||||||
|
|||||||
Reference in new issue
Block a user