Files
takeone-youtube-clone/routes/web.php
T
ghassanandClaude Opus 4.7 a25af30700 fix(channel): grid, scoreboard thumb, sort hash, visitor privacy
Audit fixes against /channel/{slug}:

1. Fluid video grid — repeat(auto-fill, minmax(260px, 1fr)) replaces
   the three fixed breakpoints (3/2/4 cols at 1101/1500/… px) that
   produced different card widths and could overflow 390 / 1280 /
   1920. min-width:0 on every grid child so a long unbroken title
   (CJK, "SPARTAWORRIOR") can't widen its track past 1fr.
2. Scoreboard thumbnail — stop including vs-mini + scorebar-mini on
   match cards. Those partials leaked fighter names, "AKA/AO",
   "ROUND 1", "0:00" into the card DOM, showing stale 0-0 for
   finished matches and breaking title layout at narrow widths. The
   regular <img> above is the thumbnail now; VS intro + live scorebar
   still render on the full match player page where they belong.
3. Sort pills — Latest/Popular/Oldest now append #videos so the page
   stays on the Videos tab after reload instead of jumping to Wall.
4. Shorts tab — always render for the owner (so the "My Shorts"
   sidebar link always lands somewhere), with an empty-state card
   when no shorts exist. Visitors still see it only when shorts > 0.
5. Subscriptions sidebar link — href="#" replaced by a real
   /feed/subscriptions route backed by UserController@subscriptionsFeed
   that reuses the videos/index view with the subscription feed.
6. Header view count — totalViews now sums video_views over public
   videos for visitors (owner still sees the full total). Previously
   leaked unlisted and private video view counts.
7. Card redundancy — new hideChannelInfo prop on <x-video-card/>
   suppresses the per-card avatar + channel-name row. The channel
   page passes it; everywhere else keeps the row.
9. Decorative logo — the second <img alt="Play"> in the responsive
   header pair now has alt="" so a screen reader doesn't announce
   "Play Play". ⋮ menu button gets aria-label="More actions".
10. Privacy — age, gender, location and horoscope strip now only
    render to the owner on the channel header (horoscope strip is
    also allowed when the compatibility match-up is being shown to
    a signed-in visitor). Joined-month stays public.
11. Notifications dropdown — initial state is "No notifications"
    not an indefinite "Loading…". Opening the panel now shows a
    spinner while fetching, surfaces a Retry button on error, and
    bails out of the "Loading…" state on non-ok HTTP responses.

Title clamp — .yt-video-title is now a 2-line box with
overflow-wrap:anywhere so CJK titles and long words wrap mid-word
and can't widen the card past its track.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-10-21 10:03:39 +03:00

330 lines
22 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');
Route::get('/videos/search', [VideoController::class, 'search'])->name('videos.search');
Route::get('/trending', [VideoController::class, 'trending'])->name('videos.trending');
Route::get('/shorts', [VideoController::class, 'shorts'])->name('videos.shorts');
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');
});
// 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');
});
// 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');
// 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');