Files
takeone-youtube-clone/routes/web.php
T
ghassanandClaude Opus 4.7 677e8711dd 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]>
2026-10-21 12:33:05 +03:00

367 lines
24 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
use App\Http\Controllers\CommentController;
use App\Http\Controllers\ImageUploadController;
use App\Http\Controllers\MatchEventController;
use App\Http\Controllers\MediaController;
use App\Http\Controllers\SportsMatchController;
use App\Http\Controllers\PostController;
use App\Http\Controllers\SuperAdminController;
use App\Http\Controllers\UserController;
use App\Http\Controllers\VideoController;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Route;
// Media asset routes — NAS-aware image serving (public, no auth)
Route::get('/media/thumbnails/{filename}', [MediaController::class, 'thumbnail'])->name('media.thumbnail')->where('filename', '.+');
Route::get('/media/avatars/{filename}', [MediaController::class, 'avatar'])->name('media.avatar')->where('filename', '.+');
Route::get('/media/banners/{filename}', [MediaController::class, 'banner'])->name('media.banner')->where('filename', '.+');
Route::get('/media/post-images/{filename}', [MediaController::class, 'postImage'])->name('media.post-image')->where('filename', '.+');
Route::get('/media/sports/{filename}', [MediaController::class, 'sportsImage'])->name('media.sports-image')->where('filename', '.+');
// Root route - show videos
Route::get('/', [VideoController::class, 'index'])->name('home');
// Video routes - public
Route::get('/videos', [VideoController::class, 'index'])->name('videos.index');
Route::get('/videos/feed/more', [VideoController::class, 'feedMore'])->name('videos.feed.more');
// Site-wide search. /videos/search is the historical URL; keep it as a
// signed-forward redirect so bookmarks and external links don't break.
Route::get('/search', [\App\Http\Controllers\SearchController::class, 'index'])->name('search');
Route::get('/videos/search', function (\Illuminate\Http\Request $request) {
return redirect()->route('search', $request->only('q'), 301);
})->name('videos.search');
Route::get('/trending', [VideoController::class, 'trending'])->name('videos.trending');
Route::get('/shorts', [VideoController::class, 'shorts'])->name('videos.shorts');
// Deep-link form: /shorts/{encoded-id} loads the feed scrolled to that short.
// The resolveRouteBinding on Video decodes the id; if it's gone, 404 lands
// on the branded errors/404 page.
Route::get('/shorts/{video}', [VideoController::class, 'shortsFocused'])->name('videos.shorts.focused');
Route::get('/videos/create', [VideoController::class, 'create'])->name('videos.create');
// Redirect legacy numeric video IDs to encoded URLs (backward compatibility)
Route::get('/videos/{numericId}', function ($numericId) {
$video = \App\Models\Video::findOrFail((int) $numericId);
$url = route('videos.show', $video);
if (request()->getQueryString()) {
$url .= '?' . request()->getQueryString();
}
return redirect($url, 301);
})->where('numericId', '[0-9]+');
Route::get('/videos/share/{token}', [VideoController::class, 'showByToken'])->name('videos.showByToken');
Route::get('/videos/{video}', [VideoController::class, 'show'])->name('videos.show');
Route::get('/videos/{video}/vs-preview', function (\App\Models\Video $video) {
abort_unless($video->type === 'match' && $video->sportsMatch, 404);
return view('videos.vs-preview', ['video' => $video]);
})->name('videos.vsPreview');
Route::get('/videos/{video}/stream', [VideoController::class, 'stream'])->name('videos.stream');
Route::get('/videos/{video}/audio-track/{track}', [VideoController::class, 'streamAudioTrack'])->name('videos.audio-track');
Route::get('/videos/{video}/hls/{file?}', [VideoController::class, 'hls'])->where(['file' => '.*'])->name('videos.hls');
Route::get('/videos/{video}/download', [VideoController::class, 'download'])->name('videos.download');
Route::get('/videos/{video}/download-mp3', [VideoController::class, 'downloadMp3'])->name('videos.downloadMp3');
Route::post('/videos/{video}/slideshow/generate', [VideoController::class, 'slideshowGenerate'])->name('videos.slideshow.generate');
Route::get('/videos/{video}/slideshow/progress', [VideoController::class, 'slideshowProgress'])->name('videos.slideshow.progress');
Route::get('/videos/{video}/recommendations', [VideoController::class, 'recommendations'])->name('videos.recommendations');
Route::get('/videos/{video}/player-data', [VideoController::class, 'playerData'])->name('videos.playerData');
Route::post('/videos/{video}/lyrics/generate', [VideoController::class, 'generateLyrics'])->name('videos.lyrics.generate')->middleware(['auth', 'throttle:20,1']);
Route::get('/videos/{video}/lyrics/progress', [VideoController::class, 'lyricsProgress'])->name('videos.lyrics.progress');
Route::post('/videos/{video}/lyrics/save', [VideoController::class, 'saveLyrics'])->name('videos.lyrics.save')->middleware(['auth', 'throttle:30,1']);
Route::delete('/videos/{video}/lyrics', [VideoController::class, 'deleteLyrics'])->name('videos.lyrics.delete')->middleware(['auth', 'throttle:20,1']);
Route::post('/videos/{video}/share', [VideoController::class, 'recordShare'])->name('videos.recordShare');
Route::post('/videos/{video}/description/translate', [VideoController::class, 'translateDescription'])->name('videos.description.translate')->middleware('throttle:30,1');
Route::post('/videos/{video}/share/email', [VideoController::class, 'shareByEmail'])->name('videos.shareEmail')->middleware(['auth', 'throttle:10,1']);
Route::post('/videos/{video}/share/members', [VideoController::class, 'shareWithMembers'])->name('videos.shareMembers')->middleware(['auth', 'throttle:20,1']);
Route::get('/videos/{video}/og-image', [VideoController::class, 'ogImage'])->name('videos.ogImage');
// vs-og-frame is the Browsershot-only, no-animation, 1200×630 render used by
// the OG image generator. Distinct from /vs-preview (line 43) which is a
// full-page interactive preview of the arena intro overlay.
Route::get('/videos/{video}/vs-og-frame', [VideoController::class, 'vsPreview'])->name('videos.vsOgFrame');
// Admin-only gallery of all generated OG preview PNGs (the composed VS
// cards that WhatsApp/Facebook see when a match video URL is shared).
Route::get('/admin/og-previews', function () {
abort_unless(auth()->check() && auth()->user()->isSuperAdmin(), 403);
$files = collect(glob(storage_path('app/og-cache/match-*.{jpg,png}'), GLOB_BRACE) ?: [])
->map(fn ($f) => [
'name' => basename($f),
'size' => filesize($f),
'mtime' => filemtime($f),
'id' => (int) (explode('-', pathinfo($f, PATHINFO_FILENAME))[1] ?? 0),
])
->sortByDesc('mtime')
->values();
return view('admin.og-previews', ['files' => $files]);
})->name('admin.og-previews');
Route::get('/admin/og-previews/{name}', function (string $name) {
abort_unless(auth()->check() && auth()->user()->isSuperAdmin(), 403);
abort_unless(preg_match('/^match-\d+-\d+\.(jpg|png)$/', $name), 404);
$path = storage_path('app/og-cache/' . $name);
abort_unless(is_file($path), 404);
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
return response()->file($path, ['Content-Type' => $ext === 'png' ? 'image/png' : 'image/jpeg']);
})->name('admin.og-previews.file');
Route::get('/s/{token}', [VideoController::class, 'accessShare'])->name('share.access');
Route::get('/videos/{video}/insights', [VideoController::class, 'insights'])->name('videos.insights')->middleware('auth');
Route::get('/videos/{video}/insights/country/{country}', [VideoController::class, 'insightsCountry'])->name('videos.insights.country')->middleware('auth');
Route::get('/videos/{video}/insights/day/{date}', [VideoController::class, 'insightsDay'])->name('videos.insights.day')->middleware('auth');
Route::get('/videos/{video}/insights/downloader/{userId}', [VideoController::class, 'insightsDownloaderHistory'])->name('videos.insights.downloader')->middleware('auth');
Route::get('/videos/{video}/insights/viewer/{who}', [VideoController::class, 'insightsViewer'])->where('who', '[ug]:[A-Za-z0-9\.:_-]+')->name('videos.insights.viewer')->middleware('auth');
Route::get('/videos/{video}/insights/share/{token}', [VideoController::class, 'insightsShare'])->where('token', '[A-Za-z0-9]+')->name('videos.insights.share')->middleware('auth');
Route::post('/videos/{video}/identify', [VideoController::class, 'identify'])->name('videos.identify');
// Video routes - auth + verified required
Route::middleware(['auth', 'verified'])->group(function () {
Route::get('/videos/create', [VideoController::class, 'create'])->name('videos.create');
Route::post('/videos', [VideoController::class, 'store'])->name('videos.store');
Route::post('/videos/upload-chunk', [VideoController::class, 'uploadChunk'])->name('videos.uploadChunk');
Route::get('/videos/{video}/edit', [VideoController::class, 'edit'])->name('videos.edit');
Route::put('/videos/{video}', [VideoController::class, 'update'])->name('videos.update');
Route::post('/videos/{video}/replace-file', [VideoController::class, 'replaceFile'])->name('videos.replaceFile');
Route::delete('/videos/{video}', [VideoController::class, 'destroy'])->name('videos.destroy');
Route::delete('/videos/{video}/audio-track/{track}', [VideoController::class, 'deleteAudioTrack'])->name('videos.audio-track.delete');
// Music-video merge — fold {source} into {target}. Owner-only.
Route::get('/videos/{target}/merge/candidates', [\App\Http\Controllers\VideoMergeController::class, 'candidates'])->name('videos.merge.candidates');
Route::get('/videos/{target}/merge/{source}/preview',[\App\Http\Controllers\VideoMergeController::class, 'preview'])->name('videos.merge.preview');
Route::post('/videos/{target}/merge/{source}', [\App\Http\Controllers\VideoMergeController::class, 'merge'])->name('videos.merge');
// Like/unlike routes
Route::post('/videos/{video}/like', [UserController::class, 'like'])->name('videos.like');
Route::post('/videos/{video}/unlike', [UserController::class, 'unlike'])->name('videos.unlike');
Route::post('/videos/{video}/toggle-like', [UserController::class, 'toggleLike'])->name('videos.toggleLike');
Route::post('/videos/{video}/toggle-dislike', [UserController::class, 'toggleDislike'])->name('videos.toggleDislike');
});
// Subscribe + notifications — auth but NOT email verification required
Route::middleware(['auth'])->group(function () {
Route::post('/channel/{user}/subscribe', [UserController::class, 'toggleSubscribe'])->name('channel.subscribe');
Route::get('/notifications/count', [UserController::class, 'notificationCount'])->name('notifications.count');
Route::get('/notifications/fetch', [UserController::class, 'fetchNotifications'])->name('notifications.fetch');
Route::post('/notifications/{id}/read', [UserController::class, 'markNotificationRead'])->name('notifications.read');
Route::post('/notifications/read-all', [UserController::class, 'markAllNotificationsRead'])->name('notifications.read-all');
Route::get('/users/search', [UserController::class, 'searchUsers'])->name('users.search');
});
// View progress + profile-visit tracking — accept both guests and authed users
Route::post('/videos/{video}/view-progress', [VideoController::class, 'trackProgress'])->name('videos.viewProgress');
Route::post('/profile-visits/{user}', [UserController::class, 'recordProfileVisit'])->name('profile.visit');
// Comment routes
Route::get('/videos/{video}/comments', [CommentController::class, 'index'])->name('comments.index');
Route::post('/comments/{comment}/translate', [CommentController::class, 'translate'])->name('comments.translate');
Route::middleware(['auth', 'verified'])->group(function () {
Route::post('/videos/{video}/comments', [CommentController::class, 'store'])->name('comments.store');
Route::put('/comments/{comment}', [CommentController::class, 'update'])->name('comments.update');
Route::delete('/comments/{comment}', [CommentController::class, 'destroy'])->name('comments.destroy');
Route::post('/comments/{comment}/like', [CommentController::class, 'like'])->name('comments.like');
Route::post('/comments/{comment}/heart', [CommentController::class, 'heart'])->name('comments.heart');
Route::post('/comments/{comment}/pin', [CommentController::class, 'pin'])->name('comments.pin');
});
// User routes
Route::middleware(['auth', 'verified'])->group(function () {
// Profile — redirect to own channel
Route::get('/profile', fn() => redirect()->route('channel', Auth::user()->channel))->name('profile');
Route::put('/profile', [UserController::class, 'updateProfile'])->name('profile.update');
// Settings
Route::get('/settings', [UserController::class, 'settings'])->name('settings');
Route::put('/settings', [UserController::class, 'updateSettings'])->name('settings.update');
Route::post('/settings/notifications', [UserController::class, 'updateNotificationPreferences'])->name('settings.notifications');
// History & Liked
Route::get('/history', [UserController::class, 'history'])->name('history');
Route::delete('/history', [UserController::class, 'clearHistory'])->name('history.clear');
Route::get('/liked', [UserController::class, 'liked'])->name('liked');
Route::get('/feed/subscriptions', [UserController::class, 'subscriptionsFeed'])->name('subscriptions.feed');
// Logout all other devices
Route::post('/settings/logout-all-devices', [UserController::class, 'logoutAllDevices'])->name('settings.logoutAllDevices');
// Image upload (cropper)
Route::post('/image-upload', [ImageUploadController::class, 'upload'])->name('image.upload');
Route::post('/profile/update-avatar', [UserController::class, 'updateAvatar'])->name('profile.updateAvatar');
Route::post('/profile/update-banner', [UserController::class, 'updateBanner'])->name('profile.updateBanner');
});
// 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.
// The {handle} segment must be url-encoded (e.g. /%40alice) OR plain `@alice`
// — Laravel's router accepts both because `@` is allowed in URI path segments.
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');
Route::delete('/posts/{post}', [PostController::class, 'destroy'])->name('posts.destroy');
Route::post('/posts/{post}/react', [PostController::class, 'react'])->name('posts.react');
});
// Playlist routes
use App\Http\Controllers\PlaylistController;
// Get user playlists (for dropdown) - MUST be before /playlists/{playlist} route
Route::get('/user/playlists', [PlaylistController::class, 'userPlaylists'])->name('playlists.userPlaylists');
// Public playlist routes
Route::get('/playlists', [PlaylistController::class, 'index'])->name('playlists.index');
Route::get('/playlists/share/{token}', [PlaylistController::class, 'showByToken'])->name('playlists.showByToken');
Route::get('/playlists/{playlist}/og-image', [PlaylistController::class, 'ogImage'])->name('playlists.ogImage');
Route::get('/playlists/{playlist}', [PlaylistController::class, 'show'])->name('playlists.show');
Route::post('/playlists/{playlist}/share', [PlaylistController::class, 'recordShare'])->name('playlists.recordShare');
Route::get('/ps/{token}', [PlaylistController::class, 'accessShare'])->name('playlists.accessShare');
// Authenticated playlist routes
Route::middleware(['auth', 'verified'])->group(function () {
Route::get('/playlists/create', [PlaylistController::class, 'create'])->name('playlists.create');
Route::post('/playlists', [PlaylistController::class, 'store'])->name('playlists.store');
Route::get('/playlists/{playlist}/edit', [PlaylistController::class, 'edit'])->name('playlists.edit');
Route::put('/playlists/{playlist}', [PlaylistController::class, 'update'])->name('playlists.update');
Route::delete('/playlists/{playlist}', [PlaylistController::class, 'destroy'])->name('playlists.destroy');
// Playlist video management
Route::post('/playlists/{playlist}/videos', [PlaylistController::class, 'addVideo'])->name('playlists.addVideo');
Route::delete('/playlists/{playlist}/videos/{video}', [PlaylistController::class, 'removeVideo'])->name('playlists.removeVideo');
// Body-based remove (mirror of addVideo) — used by the add-to-playlist modal
// where the front-end only has the numeric video id, not the encoded route key.
Route::delete('/playlists/{playlist}/videos', [PlaylistController::class, 'removeVideoByBody'])->name('playlists.removeVideoByBody');
Route::put('/playlists/{playlist}/reorder', [PlaylistController::class, 'reorder'])->name('playlists.reorder');
// Playlist actions
Route::get('/playlists/{playlist}/play', [PlaylistController::class, 'playAll'])->name('playlists.playAll');
Route::get('/playlists/{playlist}/shuffle', [PlaylistController::class, 'shuffle'])->name('playlists.shuffle');
// Watch Later
Route::post('/videos/{video}/watch-later', [PlaylistController::class, 'watchLater'])->name('videos.watchLater');
});
// Two-Factor Authentication
use App\Http\Controllers\TwoFactorController;
Route::get('/2fa/challenge', [TwoFactorController::class, 'showChallenge'])->name('2fa.challenge');
Route::post('/2fa/challenge', [TwoFactorController::class, 'verifyChallenge'])->name('2fa.verify');
Route::middleware(['auth'])->group(function () {
Route::post('/2fa/setup', [TwoFactorController::class, 'setup'])->name('2fa.setup');
Route::post('/2fa/enable', [TwoFactorController::class, 'enable'])->name('2fa.enable');
Route::post('/2fa/disable', [TwoFactorController::class, 'disable'])->name('2fa.disable');
});
// Sports match records (progressive-disclosure creation flow)
Route::middleware(['auth'])->group(function () {
Route::post('/sports-matches', [SportsMatchController::class, 'store'])->name('sports-matches.store');
Route::get('/sports-matches/{sportsMatch}/edit', [SportsMatchController::class, 'edit'])->name('sports-matches.edit');
Route::put('/sports-matches/{sportsMatch}', [SportsMatchController::class, 'update'])->name('sports-matches.update');
});
// Signed confirmation link — no auth required (user may click from a different device/browser)
Route::get('/2fa/disable/confirm', [TwoFactorController::class, 'confirmDisable'])->name('2fa.disable.confirm');
// Authentication Routes
require __DIR__.'/auth.php';
// Super Admin Routes
Route::middleware(['auth', 'super_admin'])->prefix('admin')->name('admin.')->group(function () {
// Dashboard
Route::get('/dashboard', [SuperAdminController::class, 'dashboard'])->name('dashboard');
Route::get('/dashboard/modal', [SuperAdminController::class, 'modalData'])->name('dashboard.modal');
// User Management
Route::get('/users', [SuperAdminController::class, 'users'])->name('users');
Route::get('/users/{user}/edit', [SuperAdminController::class, 'editUser'])->name('users.edit');
Route::put('/users/{user}', [SuperAdminController::class, 'updateUser'])->name('users.update');
Route::delete('/users/{user}', [SuperAdminController::class, 'deleteUser'])->name('users.delete');
Route::post('/users/{user}/impersonate', [SuperAdminController::class, 'impersonate'])->name('users.impersonate');
Route::post('/users/{user}/verify', [SuperAdminController::class, 'verifyUser'])->name('users.verify');
// Video Management
Route::get('/videos', [SuperAdminController::class, 'videos'])->name('videos');
Route::get('/videos/{video}/analytics', [SuperAdminController::class, 'videoAnalytics'])->name('videos.analytics');
Route::get('/videos/{video}/edit', [SuperAdminController::class, 'editVideo'])->name('videos.edit');
Route::put('/videos/{video}', [SuperAdminController::class, 'updateVideo'])->name('videos.update');
Route::delete('/videos/{video}', [SuperAdminController::class, 'deleteVideo'])->name('videos.delete');
// Orphan cleanup
Route::post('/cleanup-orphans', [SuperAdminController::class, 'cleanupOrphanedVideos'])->name('admin.cleanup.orphans');
// Log viewer
Route::get('/logs', [SuperAdminController::class, 'logs'])->name('logs');
// Audit log center
Route::get('/audit', [SuperAdminController::class, 'auditLogs'])->name('audit');
// System settings
Route::get('/settings', [SuperAdminController::class, 'settings'])->name('settings');
Route::post('/settings', [SuperAdminController::class, 'updateSettings'])->name('settings.update');
Route::get('/settings/detect-gpu', [SuperAdminController::class, 'detectGpu'])->name('settings.detect-gpu');
Route::post('/settings/llm-test', [SuperAdminController::class, 'llmProviderTest'])->name('settings.llm-test');
// Lyrics Pipeline toggles (own page)
Route::get('/lyrics', [SuperAdminController::class, 'lyrics'])->name('lyrics');
// GPU Accelerator (own page)
Route::get('/gpu', [SuperAdminController::class, 'gpu'])->name('gpu');
// Backup & Restore (own page)
Route::get('/backup', [SuperAdminController::class, 'backup'])->name('backup');
// NAS Storage
Route::get('/nas-storage', [SuperAdminController::class, 'nasStorage'])->name('nas-storage');
Route::post('/nas-repair', [SuperAdminController::class, 'nasRepair'])->name('admin.nas.repair');
Route::post('/nas-delete', [SuperAdminController::class, 'nasDelete'])->name('nas.delete');
// NAS Disable Flow
Route::post('/nas/disable', [SuperAdminController::class, 'nasDisable'])->name('nas.disable');
Route::get('/nas/migrate-progress', [SuperAdminController::class, 'nasMigrateProgress'])->name('nas.migrate-progress');
// Backup & Restore
Route::get('/backup/users-settings', [SuperAdminController::class, 'backupUsersSettings'])->name('backup.users-settings');
Route::post('/backup/restore', [SuperAdminController::class, 'restoreUsersSettings'])->name('backup.restore');
});
// Exit impersonation — accessible by any authenticated user (the impersonated user hitting exit)
Route::post('/impersonate/exit', [SuperAdminController::class, 'exitImpersonation'])
->middleware('auth')
->name('impersonate.exit');
// Match Events Routes (removed auth requirement for demo purposes)
// In production, wrap with: Route::middleware('auth')->group(function () {
// Get match data - public for viewing
Route::get('/videos/{video}/match-data', [MatchEventController::class, 'getMatchData'])->name('match.getData');
// Round CRUD
Route::post('/videos/{video}/rounds', [MatchEventController::class, 'storeRound'])->name('match.storeRound');
Route::put('/rounds/{round}', [MatchEventController::class, 'updateRound'])->name('match.updateRound');
Route::delete('/rounds/{round}', [MatchEventController::class, 'destroyRound'])->name('match.destroyRound');
// Point CRUD
Route::post('/videos/{video}/points', [MatchEventController::class, 'storePoint'])->name('match.storePoint');
Route::put('/points/{point}', [MatchEventController::class, 'updatePoint'])->name('match.updatePoint');
Route::delete('/points/{point}', [MatchEventController::class, 'destroyPoint'])->name('match.destroyPoint');
// Coach Review CRUD
Route::post('/videos/{video}/reviews', [MatchEventController::class, 'storeReview'])->name('match.storeReview');
Route::put('/reviews/{review}', [MatchEventController::class, 'updateReview'])->name('match.updateReview');
Route::delete('/reviews/{review}', [MatchEventController::class, 'destroyReview'])->name('match.destroyReview');