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
@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();