diff --git a/.claude/component-usage.md b/.claude/component-usage.md index bdc30f8..b855cf7 100644 --- a/.claude/component-usage.md +++ b/.claude/component-usage.md @@ -271,6 +271,19 @@ Stored value: IANA timezone string (e.g. `"Asia/Bahrain"`). --- +## `` + +**File:** `resources/views/components/save-to-playlist-button.blade.php`. +**Rule:** Canonical "Save to playlist" trigger. Opens the singleton `layouts/partials/add-to-playlist-modal.blade.php` (already included once via `layouts/app.blade.php`) by calling `openAddToPlaylistModal($video->id)`. **Always pass `$video->id`** — the backend (`PlaylistController::addVideo` / `removeVideoByBody`) validates `video_id` against `exists:videos,id`. Never pass `$video->getRouteKey()`. Props: `video` (required), `tag` (`button`|`a`); extra attributes forwarded; slot overrides the default label. + +| View file | Usage | Notes | +|---|---|---| +| `resources/views/components/video-card.blade.php` | `` | Card 3-dot menu item | +| `resources/views/components/video-actions.blade.php` | `` + dropdown-item | Watch-page actions (desktop + dropdown) | +| `resources/views/videos/show.blade.php` | `` (×2) | Watch page mobile + desktop save buttons | + +--- + ## Modification checklist When you modify any of these components, work through this list: diff --git a/app/Http/Controllers/PlaylistController.php b/app/Http/Controllers/PlaylistController.php index e035875..770248b 100644 --- a/app/Http/Controllers/PlaylistController.php +++ b/app/Http/Controllers/PlaylistController.php @@ -257,23 +257,19 @@ class PlaylistController extends Controller 'visibility' => $request->visibility ?? 'private', ]; - // Handle thumbnail upload + // Thumbnail: an uploaded file always wins over remove_thumbnail. The + // modal sometimes sends both when a user clears the preview and then + // picks a new file — without this guard the new file would be silently + // discarded. if ($request->hasFile('thumbnail')) { - // Delete old thumbnail from NAS if exists if ($playlist->thumbnail) { self::deletePlaylistThumbnailFromNas($playlist->thumbnail); } - $file = $request->file('thumbnail'); $updateData['thumbnail'] = self::pushPlaylistThumbnailToNas($file, $playlist); - } - - // Handle thumbnail removal - if ($request->input('remove_thumbnail') == '1') { - if ($playlist->thumbnail) { - self::deletePlaylistThumbnailFromNas($playlist->thumbnail); - $updateData['thumbnail'] = null; - } + } elseif ($request->input('remove_thumbnail') == '1' && $playlist->thumbnail) { + self::deletePlaylistThumbnailFromNas($playlist->thumbnail); + $updateData['thumbnail'] = null; } $playlist->update($updateData); @@ -519,16 +515,32 @@ class PlaylistController extends Controller private static function pushPlaylistThumbnailToNas(\Illuminate\Http\UploadedFile $file, Playlist $playlist): string { - $nas = app(\App\Services\NasSyncService::class); - $ext = $file->getClientOriginalExtension() ?: 'jpg'; - $tmpName = self::generateFilename($ext); - $file->storeAs('public/thumbnails', $tmpName); - $tempAbs = storage_path('app/public/thumbnails/' . $tmpName); - $nasPath = self::nasPlaylistThumbPath($playlist, $ext); - $dir = dirname($nasPath); - $nas->mkdirp($dir); - $nas->putFile($tempAbs, $nasPath); - @unlink($tempAbs); + $nas = app(\App\Services\NasSyncService::class); + $ext = $file->getClientOriginalExtension() ?: 'jpg'; + $nasPath = self::nasPlaylistThumbPath($playlist, $ext); + $localAbs = storage_path('app/' . $nasPath); + + // When NAS is reachable, push directly and avoid the slow timeout path. + if ($nas->isEnabled()) { + $tmpName = self::generateFilename($ext); + $file->storeAs('public/thumbnails', $tmpName); + $tempAbs = storage_path('app/public/thumbnails/' . $tmpName); + $nas->mkdirp(dirname($nasPath)); + if ($nas->putFile($tempAbs, $nasPath)) { + @unlink($tempAbs); + return $nasPath; + } + // NAS push failed mid-flight — fall through to local fallback so the + // file isn't lost. nas:auto-sync will retry when NAS comes back. + @mkdir(dirname($localAbs), 0755, true); + @rename($tempAbs, $localAbs); + return $nasPath; + } + + // NAS disabled / unreachable — keep on local disk at the same path + // MediaController expects, so it serves directly without a NAS round-trip. + @mkdir(dirname($localAbs), 0755, true); + $file->move(dirname($localAbs), basename($localAbs)); return $nasPath; } diff --git a/app/Http/Controllers/VideoController.php b/app/Http/Controllers/VideoController.php index cc0446d..32ea95b 100644 --- a/app/Http/Controllers/VideoController.php +++ b/app/Http/Controllers/VideoController.php @@ -997,12 +997,11 @@ class VideoController extends Controller $data['thumbnail'] = "{$nasDir}/thumb.{$ext}"; } } else { - // NAS disabled — keep on local disk - $localDir = $nas->localVideoDir($video); + // NAS disabled — keep on local disk, mirroring the NAS path + $nasDir = $this->nasVideoDir($video, $nas); + $localDir = storage_path('app/' . $nasDir); @mkdir($localDir, 0755, true); $request->file('thumbnail')->move($localDir, "thumb.{$ext}"); - $userSlug = $nas->userSlug($video->user); - $nasDir = $this->nasVideoDir($video, $nas); $data['thumbnail'] = "{$nasDir}/thumb.{$ext}"; } } @@ -1041,13 +1040,13 @@ class VideoController extends Controller $nasForSlides = app(\App\Services\NasSyncService::class); $nasEnabled = $nasForSlides->isEnabled(); - // Derive NAS dir without scanning when possible - $nasDir = $nasEnabled ? $this->nasVideoDir($video, $nasForSlides) : null; - - // Local dir used as fallback or when NAS is off - $localDir = $nasForSlides->localVideoDir($video); - $userSlug = $nasForSlides->userSlug($video->user); + // Derive NAS dir without scanning — works regardless of NAS state $relBase = $this->nasVideoDir($video, $nasForSlides); + $nasDir = $nasEnabled ? $relBase : null; + + // Local dir mirrors the NAS path so MediaController can find files + // whether NAS is reachable or not. + $localDir = storage_path('app/' . $relBase); if ($nasEnabled) { $nasForSlides->mkdirp("{$nasDir}/slides"); diff --git a/app/Services/NasSyncService.php b/app/Services/NasSyncService.php index 2463296..1ee9229 100644 --- a/app/Services/NasSyncService.php +++ b/app/Services/NasSyncService.php @@ -490,6 +490,12 @@ class NasSyncService { if (file_exists($localPath)) return true; + // Don't shell out to smbclient when we already know NAS is unreachable + // — smbclient hangs ~30 sec on the SMB timeout, multiplied by every + // thumbnail/avatar on the page. isEnabled() uses a 2-sec TCP probe + // cached for 120 sec, so this fails fast during an outage. + if (! $this->isEnabled()) return false; + $dir = dirname($localPath); if (! is_dir($dir)) @mkdir($dir, 0755, true); @@ -1015,6 +1021,7 @@ class NasSyncService public function mkdirp(string $path): void { + if (! $this->isEnabled()) return; // fail-fast when NAS unreachable $cfg = $this->cfg(); $target = escapeshellarg($this->smbTarget($cfg)); $cred = escapeshellarg($this->smbCredential($cfg)); @@ -1033,6 +1040,7 @@ class NasSyncService public function putFile(string $localAbsPath, string $nasRelPath): bool { + if (! $this->isEnabled()) return false; // fail-fast when NAS unreachable $cfg = $this->cfg(); $target = escapeshellarg($this->smbTarget($cfg)); $cred = escapeshellarg($this->smbCredential($cfg)); @@ -1058,6 +1066,7 @@ class NasSyncService public function getContent(string $nasRelPath): ?string { + if (! $this->isEnabled()) return null; // fail-fast when NAS unreachable $cfg = $this->cfg(); $target = escapeshellarg($this->smbTarget($cfg)); $cred = escapeshellarg($this->smbCredential($cfg)); @@ -1202,6 +1211,7 @@ class NasSyncService public function deleteFile(string $nasRelPath): void { + if (! $this->isEnabled()) return; // fail-fast when NAS unreachable $cfg = $this->cfg(); $target = escapeshellarg($this->smbTarget($cfg)); $cred = escapeshellarg($this->smbCredential($cfg)); diff --git a/resources/views/components/save-to-playlist-button.blade.php b/resources/views/components/save-to-playlist-button.blade.php new file mode 100644 index 0000000..2484317 --- /dev/null +++ b/resources/views/components/save-to-playlist-button.blade.php @@ -0,0 +1,30 @@ +@props([ + 'video', + 'tag' => 'button', // 'button' or 'a' +]) + +{{-- Canonical "Save to playlist" trigger. Opens the single + (included once in layouts/app.blade.php) via + openAddToPlaylistModal(numericId). Always passes $video->id, never the + route key — the backend validates against videos.id. --}} +@php + $onclick = "openAddToPlaylistModal({$video->id})"; +@endphp + +@if($tag === 'a') + + @if($slot->isEmpty()) + Save to playlist + @else + {{ $slot }} + @endif + +@else + +@endif diff --git a/resources/views/components/video-actions.blade.php b/resources/views/components/video-actions.blade.php index 17752aa..30002a8 100644 --- a/resources/views/components/video-actions.blade.php +++ b/resources/views/components/video-actions.blade.php @@ -177,10 +177,10 @@ @endif - + @php $dlAccess = $video->download_access ?? 'disabled'; @@ -280,9 +280,9 @@ @endif - + + Save + @if($showDl) @if($isAudioDl)
  • - + Save to playlist - +
  • @if($video->allow_download)
  • @@ -957,39 +957,6 @@ function saveToWatchLater(videoId) { .catch(error => console.error('Error:', error)); } -// Global function to open playlist modal -function openPlaylistModal(videoId) { - // Set the current video ID for the modal as global variable - window.currentVideoIdForModal = videoId; - - // Close any open dropdown menus first - const activeDropdowns = document.querySelectorAll('.dropdown-menu.show'); - activeDropdowns.forEach(function(dropdown) { - dropdown.classList.remove('show'); - }); - - // Also close Bootstrap dropdowns by clicking the toggle - const dropdownToggles = document.querySelectorAll('.dropdown-toggle[aria-expanded="true"]'); - dropdownToggles.forEach(function(toggle) { - toggle.click(); - }); - - // Try to open the add to playlist modal - if (typeof openAddToPlaylistModal === 'function') { - openAddToPlaylistModal(videoId); - } else { - // Modal might not be loaded, try to find and show it directly - const modal = document.getElementById('addToPlaylistModal'); - if (modal) { - modal.style.display = 'flex'; - modal.style.opacity = '1'; - } else { - // Fallback - redirect to login - window.location.href = '{{ route("login") }}?redirect=' + encodeURIComponent(window.location.href); - } - } -} - // Global function to add to queue function addToQueue(videoId) { showToast('Queue feature coming soon!', 'info'); diff --git a/resources/views/components/video-player.blade.php b/resources/views/components/video-player.blade.php index 132b535..fcf1b59 100644 --- a/resources/views/components/video-player.blade.php +++ b/resources/views/components/video-player.blade.php @@ -397,7 +397,6 @@ .ytp-scrubber-container { position: absolute; top: 50%; - transform: translateY(-50%); width: 0; pointer-events: none; } @@ -406,12 +405,13 @@ height: 13px; border-radius: 50%; background: #f00; - transform: translate(-50%, 0) scale(0); + /* Center the knob on the track: -50% X sits it on the play head, -50% Y keeps it + vertically centered on the bar's mid-line (container top:50%) at any bar height. */ + transform: translate(-50%, -50%) scale(0); transition: transform .1s; - margin-top: -4px; } .ytp-progress-bar-container:hover .ytp-scrubber-button, -.ytp-progress-bar.dragging .ytp-scrubber-button { transform: translate(-50%, 0) scale(1); } +.ytp-progress-bar.dragging .ytp-scrubber-button { transform: translate(-50%, -50%) scale(1); } .ytp-hover-time { position: absolute; diff --git a/resources/views/playlists/show.blade.php b/resources/views/playlists/show.blade.php index 81208eb..66b4fe3 100644 --- a/resources/views/playlists/show.blade.php +++ b/resources/views/playlists/show.blade.php @@ -631,7 +631,15 @@ document.addEventListener('DOMContentLoaded', function () { document.addEventListener('keydown', e => { if (e.key === 'Escape') closeEditPlaylistModal(); }); }); -function openEditPlaylistModal() { const m = document.getElementById('editPlaylistModal'); if (m) m.style.display = 'flex'; } +function openEditPlaylistModal() { + // Reset transient state every time the modal opens so flags from a previous + // open (e.g. the user clicked X then closed without saving) don't leak. + window._removePlThumb = false; + const fi = document.getElementById('playlistThumbnailInput'); + if (fi) fi.value = ''; + const m = document.getElementById('editPlaylistModal'); + if (m) m.style.display = 'flex'; +} // Auto-open the edit modal when arriving with #edit (e.g. from playlist-card menu). @if($canEdit ?? false) if (window.location.hash === '#edit') { @@ -645,6 +653,9 @@ function handleThumbUpload(input) { const file = input.files[0]; if (!file.type.startsWith('image/')) { showToast('Please select an image file', 'error'); return; } if (file.size > 20 * 1024 * 1024) { showToast('Image must be under 20 MB', 'error'); return; } + // Picking a new file cancels any pending "remove" intent — otherwise the + // controller would receive both `thumbnail` and `remove_thumbnail=1`. + window._removePlThumb = false; const reader = new FileReader(); reader.onload = e => { document.getElementById('playlistThumbnailPreview').src = e.target.result; diff --git a/resources/views/user/channel.blade.php b/resources/views/user/channel.blade.php index 70ecca2..41b76f1 100644 --- a/resources/views/user/channel.blade.php +++ b/resources/views/user/channel.blade.php @@ -1860,18 +1860,7 @@ function openLightbox(src) { lb.style.display = 'flex'; } -/* ── Video hover-preview ── */ -document.querySelectorAll('.yt-video-card').forEach(card => { - card.addEventListener('mouseenter', () => { - if ('ontouchstart' in window) return; - const v = card.querySelector('video'); - if (v) { v.currentTime = 0; v.volume = 0.1; v.play().catch(()=>{}); v.classList.add('active'); } - }); - card.addEventListener('mouseleave', () => { - const v = card.querySelector('video'); - if (v) { v.pause(); v.currentTime = 0; v.classList.remove('active'); } - }); -}); +/* Video hover-preview is wired by the video-card component's inline onmouseenter/onmouseleave (playVideo/stopVideo). Those handlers are audio-aware and keep the thumbnail visible for music tracks. Don't add a second listener here that overrides them. */ /* ── Subscribe toggle ── */ (function () { diff --git a/resources/views/videos/partials/audio-player.blade.php b/resources/views/videos/partials/audio-player.blade.php index dea5ae3..100e742 100644 --- a/resources/views/videos/partials/audio-player.blade.php +++ b/resources/views/videos/partials/audio-player.blade.php @@ -384,15 +384,17 @@ background: #f00; border-radius: 2px; width: 0; pointer-events: none; } .ytp-scrubber-container { - position: absolute; top: 50%; transform: translateY(-50%); + position: absolute; top: 50%; width: 0; pointer-events: none; } .ytp-scrubber-button { width: 13px; height: 13px; border-radius: 50%; background: #f00; - transform: translate(-50%, 0) scale(0); transition: transform .1s; margin-top: -4px; + /* Center the knob on the play head: -50% X on the track position, -50% Y so the + track line passes exactly through the knob's center at any bar height. */ + transform: translate(-50%, -50%) scale(0); transition: transform .1s; } .ytp-progress-bar-container:hover .ytp-scrubber-button, -.ytp-progress-bar.dragging .ytp-scrubber-button { transform: translate(-50%, 0) scale(1); } +.ytp-progress-bar.dragging .ytp-scrubber-button { transform: translate(-50%, -50%) scale(1); } .ytp-hover-time { position: absolute; bottom: 16px; background: rgba(28,28,28,.9); color: #fff; @@ -422,6 +424,9 @@ .ytp-volume-slider-wrap { overflow: hidden; width: 0; transition: width .2s; display: flex; align-items: center; + /* Height must clear the 13px thumb — the 3px track alone leaves the wrap ~3px + tall, and overflow:hidden (kept for the width collapse) would crop the knob. */ + height: 16px; } .ytp-volume-area:hover .ytp-volume-slider-wrap, .ytp-volume-area:focus-within .ytp-volume-slider-wrap { width: 60px; } @@ -431,8 +436,8 @@ background: linear-gradient(to right, #fff var(--vol,50%), rgba(255,255,255,.3) var(--vol,50%)); outline: none; cursor: pointer; margin: 0 4px; } -.ytp-volume-range::-webkit-slider-thumb { -webkit-appearance: none; width: 13px; height: 13px; border-radius: 50%; background: #fff; cursor: pointer; } -.ytp-volume-range::-moz-range-thumb { width: 13px; height: 13px; border-radius: 50%; background: #fff; border: none; cursor: pointer; } +.ytp-volume-range::-webkit-slider-thumb { -webkit-appearance: none; width: 13px; height: 13px; border-radius: 50%; background: #fff; cursor: pointer; box-shadow: 0 0 2px rgba(0,0,0,.6); } +.ytp-volume-range::-moz-range-thumb { width: 13px; height: 13px; border-radius: 50%; background: #fff; border: none; cursor: pointer; box-shadow: 0 0 2px rgba(0,0,0,.6); } .ytp-time-display { font-size: 13px; color: #fff; white-space: nowrap; padding: 0 6px; line-height: 36px; } .ytp-time-sep { opacity: .6; margin: 0 2px; } @@ -444,11 +449,14 @@ .ytp-lang-popup { display: none; position: absolute; bottom: 48px; right: 0; background: rgba(28,28,28,.97); border-radius: 12px; - min-width: 180px; overflow: hidden; + min-width: 180px; overflow-x: hidden; overflow-y: auto; + max-height: 320px; /* JS narrows this on open to fit above the header/player top */ + -webkit-overflow-scrolling: touch; overscroll-behavior: contain; box-shadow: 0 4px 24px rgba(0,0,0,.7); z-index: 100; } .ytp-lang-popup.open { display: block; } .ytp-lang-popup-hdr { + position: sticky; top: 0; z-index: 1; background: rgba(28,28,28,.97); padding: 10px 14px 8px; font-size: 11px; font-weight: 700; letter-spacing: .5px; text-transform: uppercase; color: rgba(255,255,255,.45); border-bottom: 1px solid rgba(255,255,255,.1); @@ -682,10 +690,15 @@ function updateProgress() { } // ── Controls visibility ────────────────────────────────────── +// True while any control menu is open — the chrome must not auto-hide underneath +// an open menu (it would fade the language/settings popup out mid-selection). +function isMenuOpen() { + return document.querySelector('.ytp-lang-popup.open, .ytp-settings-panel.open') !== null; +} function showControls() { player.classList.remove('controls-hidden'); clearTimeout(hideTimer); - if (!audio.paused) hideTimer = setTimeout(() => player.classList.add('controls-hidden'), 3000); + if (!audio.paused && !isMenuOpen()) hideTimer = setTimeout(() => player.classList.add('controls-hidden'), 3000); } // ── Play / Pause ───────────────────────────────────────────── @@ -797,14 +810,34 @@ const langOpts = document.querySelectorAll('.ytp-lang-option'); window._ytpTrackId = 0; if (langBtn && langPopup) { + // Cap the popup so it can never grow past the top of the video player or under + // the fixed site header — whichever edge is lower. It opens upward from the + // control bar, so without this a long track list slides its first rows off-screen. + const sizeLangPopup = () => { + const btnRect = langBtn.getBoundingClientRect(); + // 48px = the popup's `bottom` offset above the control-bar button row. + const popupBottomY = btnRect.top - 8; + // Never rise above the player's own top edge, nor above the fixed header (~56px). + const playerTop = wrap.getBoundingClientRect().top; + const topBoundary = Math.max(playerTop, 56) + 8; + const avail = Math.max(120, popupBottomY - topBoundary); + langPopup.style.maxHeight = avail + 'px'; + }; langBtn.addEventListener('click', e => { e.stopPropagation(); // Only open popup when there are multiple language options if (langPopup.querySelectorAll('.ytp-lang-option').length < 2) return; settingsPanel.classList.remove('open'); + const willOpen = !langPopup.classList.contains('open'); + if (willOpen) { sizeLangPopup(); clearTimeout(hideTimer); } langPopup.classList.toggle('open'); + if (!willOpen) showControls(); // menu closed → resume normal auto-hide + }); + document.addEventListener('click', () => { + if (!langPopup.classList.contains('open')) return; + langPopup.classList.remove('open'); + showControls(); // menu closed → resume normal auto-hide }); - document.addEventListener('click', () => langPopup.classList.remove('open')); langPopup.addEventListener('click', e => e.stopPropagation()); langOpts.forEach(opt => { @@ -830,6 +863,7 @@ if (langBtn && langPopup) { ? `` : langBtn.innerHTML; langPopup.classList.remove('open'); + showControls(); // menu closed → resume normal auto-hide // Update title flag const titleFlagEl = document.getElementById('videoTitleFlag'); @@ -1401,6 +1435,7 @@ window._audioPlayerUpdate = function(d) { langPopup.querySelectorAll('.ytp-lang-option').forEach(function(o) { o.classList.remove('active'); }); opt.classList.add('active'); langPopup.classList.remove('open'); + showControls(); // menu closed → resume normal auto-hide var _vol = audio.volume, _muted = audio.muted; audio.src = url; audio.load(); audio.volume = _vol; audio.muted = _muted; diff --git a/resources/views/videos/partials/description-box.blade.php b/resources/views/videos/partials/description-box.blade.php index fb964ce..fe70426 100644 --- a/resources/views/videos/partials/description-box.blade.php +++ b/resources/views/videos/partials/description-box.blade.php @@ -95,6 +95,9 @@ function switchVdbTab(panelId, btn) { document.querySelectorAll('.vdb-panel').forEach(p => p.classList.remove('active')); btn.classList.add('active'); document.getElementById(panelId).classList.add('active'); + // About just became visible — re-measure now that its panel is laid out, since + // a measurement taken while it was display:none would have read 0. + if (panelId === 'vdb-about' && window._vdbScheduleOverflowCheck) window._vdbScheduleOverflowCheck(); if (panelId === 'vdb-insights') { const panel = document.getElementById('vdb-insights'); const currentUrl = panel && panel.dataset.insightsBase; @@ -128,7 +131,13 @@ function _vdbApplyActiveTab() { (function _vdbWatchSwaps() { const wrap = document.getElementById('vdbWrap'); if (!wrap || wrap._vdbTabObserver) return; - const obs = new MutationObserver(() => _vdbApplyActiveTab()); + const obs = new MutationObserver(() => { + _vdbApplyActiveTab(); + // The swap replaced the description markup — re-evaluate the "Show more" + // button once layout/fonts/images settle. Central here so every SPA swap + // path is covered, not just the ones that remember to call it themselves. + if (window._vdbScheduleOverflowCheck) window._vdbScheduleOverflowCheck(); + }); obs.observe(wrap, { childList: true, subtree: false }); wrap._vdbTabObserver = obs; })(); @@ -147,9 +156,25 @@ function _vdbCheckOverflow() { const d = document.getElementById('vdbDescShort'), b = document.getElementById('vdbShowMore'); if (!d || !b) return; if (d.classList.contains('vdb-expanded')) { b.style.display = 'flex'; return; } + // If the About panel isn't laid out yet (e.g. Insights tab active, or mid-swap), + // scrollHeight reads 0 — don't hide the button on a bogus zero measurement. + if (d.scrollHeight === 0) return; b.style.display = (d.scrollHeight > 138) ? 'flex' : 'none'; } -document.addEventListener('DOMContentLoaded', _vdbCheckOverflow); -window.addEventListener('load', _vdbCheckOverflow); +// A single measurement right after an SPA swap is unreliable: web-fonts, images +// inside the description, and panel layout can all change the height a beat later, +// each of which flips whether the clamp overflows. Re-check at every settling point. +window._vdbScheduleOverflowCheck = window._vdbScheduleOverflowCheck || function () { + requestAnimationFrame(_vdbCheckOverflow); + setTimeout(_vdbCheckOverflow, 120); + setTimeout(_vdbCheckOverflow, 400); + if (document.fonts && document.fonts.ready) document.fonts.ready.then(_vdbCheckOverflow); + const d = document.getElementById('vdbDescShort'); + if (d) d.querySelectorAll('img').forEach(function (img) { + if (!img.complete) img.addEventListener('load', _vdbCheckOverflow, { once: true }); + }); +}; +document.addEventListener('DOMContentLoaded', window._vdbScheduleOverflowCheck); +window.addEventListener('load', window._vdbScheduleOverflowCheck); @endif diff --git a/resources/views/videos/show.blade.php b/resources/views/videos/show.blade.php index a4da1ce..395e405 100644 --- a/resources/views/videos/show.blade.php +++ b/resources/views/videos/show.blade.php @@ -552,9 +552,9 @@ @endif - + @auth
    @endif - + @auth @if (Auth::id() === $video->user_id) diff --git a/resources/views/videos/types/match.blade.php b/resources/views/videos/types/match.blade.php index 45b1f79..dc5d347 100644 --- a/resources/views/videos/types/match.blade.php +++ b/resources/views/videos/types/match.blade.php @@ -2207,39 +2207,45 @@ - @endif + {{-- Match Highlights toggle handler. MUST live OUTSIDE the disabled + @if(false ...) legacy panel above — otherwise the button never gets + its click listener and the Highlights pane can't be opened. --}} + + @@ -3203,8 +3209,11 @@ }); } - const videoId = {{ isset($video) ? json_encode($video->getRouteKey()) : "''" }}; - const isOwner = {{ json_encode(Auth::check() && isset($video) && Auth::id() === $video->user_id) }}; + // NOTE: emit these with the json Blade directive (raw output). Escaped echo + // braces HTML-escape the quotes (videoId becomes an entity-encoded string) and + // crash this whole block — which loads match data, switches tabs, drives CRUD. + const videoId = @json(isset($video) ? $video->getRouteKey() : ''); + const isOwner = @json(Auth::check() && isset($video) && Auth::id() === $video->user_id); document.addEventListener('DOMContentLoaded', function() { loadMatchData();