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');