Match videos: full re-hydration on Up Next SPA navigation

Fixes a raft of things breaking when jumping between match videos via the
Up Next / playlist sidebar without a page reload:

- Highlights toggle stopped working (button held a click handler pointing at
  the OLD, detached .events-sidebar node).
- Points / Coach review tabs went unclickable (new .tab-button elements had
  no bound handlers).
- Event-item clicks (jump-to-timestamp on point / review cards) stopped
  seeking / playing.
- window.matchRounds / window.matchReviews and const videoId were frozen at
  first page load, so edit/delete/save handlers routed to the wrong video.
- Blade point card leaked "{{ $fmtTime(...) }}" as literal text because of
  a misused @{{ }} escape directive.

Changes
- videoId + isOwner now live on window so they can be updated at runtime;
  `var videoId` at module scope keeps existing handlers working.
- Extracted tab-button + attachEventListeners wiring into
  window.initMatchTabsAndEvents (idempotent — _tabBound flag).
- Added window.reloadMatchVideoState(url): parses the new slug from the URL,
  updates window.videoId, calls loadMatchData (refreshes cache + re-renders
  sidebar), then re-runs the tabs/events/toggle/sidebar-height inits.
- recSwapContent and plSwapContent call reloadMatchVideoState whenever the
  target page has an .events-sidebar (falls back to just the toggle re-init
  for non-match targets).
- Fixed the Blade point-card time: {{ '@' . $fmtTime(...) }} instead of
  @{{ ... }} so the timestamp actually renders instead of showing braces.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ghassan 2026-08-08 07:20:50 +03:00
parent 716693a74f
commit cba8e01d56

View File

@ -2419,14 +2419,24 @@
its click listener and the Highlights pane can't be opened. --}} its click listener and the Highlights pane can't be opened. --}}
<script> <script>
// Match Highlights Toggle with localStorage persistence // Match Highlights Toggle with localStorage persistence
document.addEventListener('DOMContentLoaded', function() { // Extracted into a globally-callable initializer so SPA navigation
// (recSwapContent / plSwapContent) can re-bind the click handler
// against the FRESH .events-sidebar node after the sidebar is
// swapped in from the target page.
window.initMatchHighlightsToggle = function () {
const toggleBtn = document.getElementById('matchHighlightsToggle'); const toggleBtn = document.getElementById('matchHighlightsToggle');
const sidebar = document.querySelector('.events-sidebar'); const sidebar = document.querySelector('.events-sidebar');
// NOTE: emit videoId with the json Blade directive (raw), never with // Prefer the freshest videoId in case SPA nav updated it
// escaped echo braces — those HTML-escape the quotes and crash the script. const videoId = window.videoId || @json(isset($video) ? $video->getRouteKey() : '');
const videoId = @json(isset($video) ? $video->getRouteKey() : ''); if (toggleBtn && toggleBtn._hlBound && sidebar && toggleBtn._hlSidebar === sidebar) {
return; // Already bound to this exact sidebar node — nothing to do
}
if (toggleBtn && sidebar) { if (toggleBtn && sidebar) {
// Wipe any prior click listener bound to a stale sidebar
if (toggleBtn._hlHandler) toggleBtn.removeEventListener('click', toggleBtn._hlHandler);
toggleBtn._hlBound = true;
toggleBtn._hlSidebar = sidebar;
const backdrop = document.getElementById('hlSheetBackdrop'); const backdrop = document.getElementById('hlSheetBackdrop');
const grab = sidebar.querySelector('.hl-sheet-grab'); const grab = sidebar.querySelector('.hl-sheet-grab');
const mainScroll = document.getElementById('main'); const mainScroll = document.getElementById('main');
@ -2471,7 +2481,8 @@
setOpen(true); setOpen(true);
} }
toggleBtn.addEventListener('click', () => setOpen(!sidebar.classList.contains('show'))); toggleBtn._hlHandler = () => setOpen(!sidebar.classList.contains('show'));
toggleBtn.addEventListener('click', toggleBtn._hlHandler);
if (backdrop) backdrop.addEventListener('click', () => setOpen(false)); if (backdrop) backdrop.addEventListener('click', () => setOpen(false));
if (grab) grab.addEventListener('click', () => setOpen(false)); if (grab) grab.addEventListener('click', () => setOpen(false));
@ -2508,7 +2519,13 @@
document.addEventListener('webkitfullscreenchange', onFsChange); document.addEventListener('webkitfullscreenchange', onFsChange);
} }
} }
}); };
// First run on initial page load
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', window.initMatchHighlightsToggle);
} else {
window.initMatchHighlightsToggle();
}
</script> </script>
@ -2573,7 +2590,7 @@
@endphp @endphp
<div class="event-item" data-time-start="{{ (int) $point->timestamp_seconds }}" <div class="event-item" data-time-start="{{ (int) $point->timestamp_seconds }}"
data-round="{{ $round->round_number }}" data-id="pt{{ $point->id }}"> data-round="{{ $round->round_number }}" data-id="pt{{ $point->id }}">
<div class="event-time">@{{ $fmtTime($point->timestamp_seconds) }}</div> <div class="event-time">{{ '@' . $fmtTime($point->timestamp_seconds) }}</div>
<div> <div>
<div class="event-label"> <div class="event-label">
{{ $label }} {{ $label }}
@ -2810,6 +2827,14 @@
document.body.removeChild(ns); document.body.removeChild(ns);
} }
} }
// Full re-hydration for match videos when navigating
// between them from a playlist
if (document.querySelector('.events-sidebar') &&
typeof window.reloadMatchVideoState === 'function') {
window.reloadMatchVideoState(url);
} else if (typeof window.initMatchHighlightsToggle === 'function') {
window.initMatchHighlightsToggle();
}
} catch(e){ console.warn('plSwapContent',e); } } catch(e){ console.warn('plSwapContent',e); }
} }
@ -3078,6 +3103,14 @@
var ns=document.createElement('script'); ns.textContent=s.textContent; document.body.appendChild(ns); document.body.removeChild(ns); var ns=document.createElement('script'); ns.textContent=s.textContent; document.body.appendChild(ns); document.body.removeChild(ns);
}); });
} }
// Full re-hydration for match videos: updates videoId,
// reloads match data, rebinds tabs / clicks / toggle.
if (document.querySelector('.events-sidebar') &&
typeof window.reloadMatchVideoState === 'function') {
window.reloadMatchVideoState(url);
} else if (typeof window.initMatchHighlightsToggle === 'function') {
window.initMatchHighlightsToggle();
}
} catch(e){ console.warn('recSwapContent', e); } } catch(e){ console.warn('recSwapContent', e); }
} }
@ -3657,11 +3690,14 @@
}); });
} }
// NOTE: emit these with the json Blade directive (raw output). Escaped echo // These live on `window` so SPA navigation (recSwapContent) can update
// braces HTML-escape the quotes (videoId becomes an entity-encoded string) and // them for the newly-loaded match without a page refresh. Downstream
// crash this whole block — which loads match data, switches tabs, drives CRUD. // code references `videoId` and `isOwner` as free identifiers, which
const videoId = @json(isset($video) ? $video->getRouteKey() : ''); // resolve to window.videoId / window.isOwner via the global scope.
const isOwner = @json(Auth::check() && isset($video) && Auth::id() === $video->user_id); window.videoId = @json(isset($video) ? $video->getRouteKey() : '');
window.isOwner = @json(Auth::check() && isset($video) && Auth::id() === $video->user_id);
var videoId = window.videoId;
var isOwner = window.isOwner;
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
loadMatchData(); loadMatchData();
@ -3903,19 +3939,28 @@
initSidebarHeightSync(); initSidebarHeightSync();
} }
document.addEventListener('DOMContentLoaded', function() { // Extracted so SPA nav can rebind against the fresh .tab-button / .event-item
// elements after the sidebar is swapped in.
window.initMatchTabsAndEvents = function () {
const tabButtons = document.querySelectorAll('.tab-button'); const tabButtons = document.querySelectorAll('.tab-button');
const tabPanels = document.querySelectorAll('.tab-panel'); const tabPanels = document.querySelectorAll('.tab-panel');
tabButtons.forEach(button => { tabButtons.forEach(button => {
if (button._tabBound) return;
button._tabBound = true;
button.addEventListener('click', function() { button.addEventListener('click', function() {
const targetTab = this.getAttribute('data-tab'); const targetTab = this.getAttribute('data-tab');
tabButtons.forEach(btn => btn.classList.remove('active')); // Re-query on every click — the sidebar may have been re-rendered
tabPanels.forEach(panel => panel.classList.remove('active')); document.querySelectorAll('.tab-button').forEach(btn => btn.classList.remove('active'));
document.querySelectorAll('.tab-panel').forEach(panel => panel.classList.remove('active'));
this.classList.add('active'); this.classList.add('active');
document.getElementById('tab-' + targetTab).classList.add('active'); const target = document.getElementById('tab-' + targetTab);
if (target) target.classList.add('active');
}); });
}); });
attachEventListeners(); attachEventListeners();
};
document.addEventListener('DOMContentLoaded', function() {
window.initMatchTabsAndEvents();
document.getElementById('addRoundForm')?.addEventListener('submit', function(e) { document.getElementById('addRoundForm')?.addEventListener('submit', function(e) {
e.preventDefault(); e.preventDefault();
storeRound( storeRound(
@ -3925,14 +3970,31 @@
document.getElementById('roundStartTime').value document.getElementById('roundStartTime').value
); );
}); });
// pointForm removed — inline capture strip handles submit
document.getElementById('editRoundForm')?.addEventListener('submit', function(e) { document.getElementById('editRoundForm')?.addEventListener('submit', function(e) {
e.preventDefault(); e.preventDefault();
updateRound(); updateRound();
}); });
// reviewForm removed — inline review-capture strip handles submit
}); });
// Called from recSwapContent / plSwapContent when SPA navigation lands
// on a different match video. Updates window.videoId from the URL, then
// reloads the match data and re-binds all UI handlers.
window.reloadMatchVideoState = function (url) {
try {
const path = new URL(url, window.location.origin).pathname;
const parts = path.split('/').filter(Boolean);
const idx = parts.indexOf('videos');
if (idx !== -1 && parts[idx + 1]) window.videoId = parts[idx + 1];
} catch (_) {}
// Keep the local `videoId` reference in sync for handlers that
// captured the free identifier at module scope
try { videoId = window.videoId; } catch (_) {}
if (typeof loadMatchData === 'function') loadMatchData();
if (typeof window.initMatchTabsAndEvents === 'function') window.initMatchTabsAndEvents();
if (typeof window.initMatchHighlightsToggle === 'function') window.initMatchHighlightsToggle();
if (typeof initSidebarHeightSync === 'function') initSidebarHeightSync();
};
let reviewPlaybackStopHandler = null; let reviewPlaybackStopHandler = null;
let reviewPlaybackEndTime = null; let reviewPlaybackEndTime = null;