From 677e8711dd2aa9a55d3b37703376d38ebacbd4bc Mon Sep 17 00:00:00 2001 From: ghassan Date: Wed, 21 Oct 2026 12:33:05 +0300 Subject: [PATCH] feat(channel): /@handle/{tab} aliases + cursor pagination on Videos tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- app/Http/Controllers/UserController.php | 34 ++++++++++++++++- resources/views/user/channel.blade.php | 50 +++++++++++++++++++++++++ routes/web.php | 16 ++++++++ 3 files changed, 99 insertions(+), 1 deletion(-) diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index 80fea93..92d3854 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -18,7 +18,7 @@ class UserController extends Controller { 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) @@ -372,6 +372,38 @@ class UserController extends Controller 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 * viewer is subscribed to, newest first. Replaces the dead href="#" that diff --git a/resources/views/user/channel.blade.php b/resources/views/user/channel.blade.php index 5840fd3..66cdcc6 100644 --- a/resources/views/user/channel.blade.php +++ b/resources/views/user/channel.blade.php @@ -701,6 +701,56 @@ $headerSocialMap = [ No videos match your search + + {{-- 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. --}} +
+ + @else
diff --git a/routes/web.php b/routes/web.php index ec5d52c..6d75028 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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). 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 // `username` slug — if the two ever diverge, flip the controller lookup // 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._-]*') ->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 Route::middleware(['auth'])->group(function () { Route::post('/channel/{user}/posts', [PostController::class, 'store'])->name('posts.store');