video: real-link routing for detail pages (parity with music artist-detail)

Mirrors the music side instead of a custom scheme: library cards are genuine
<a href='/video-detail/<source>/<kind>/<id>'> links, so reload / new-tab / Back /
Forward all work. Left-clicks are intercepted into SPA nav + history.pushState;
modifier-clicks fall through to the real URL.

- popstate restores the detail from the URL; the '← Library' back button is real
  history.back(); deep-link / reload to a /video-detail/... URL is restored on load
  (path captured before applySide can clear it). Server already serves the SPA for
  these paths (permissive catch-all) — no backend change.
- The path carries a SOURCE segment ('library' = a video.db id today; 'tmdb' /
  search results not yet in the library come later) — your library-vs-search split.
- Coexists with music's pathname router (only touches /video-detail/* and its own
  popstate); music's link/popstate handlers ignore these paths.

Tests: real-link cards, pushState/popstate routing, source segment.
This commit is contained in:
BoulderBadgeDad 2026-06-14 22:13:51 -07:00
parent c565fec59d
commit 0598f5fbda
4 changed files with 114 additions and 19 deletions

View file

@ -341,8 +341,8 @@ def test_show_detail_subpage_present():
'data-vd-season-nav', 'data-vd-episodes', 'data-vd-cast', 'data-vd-crew',
'data-vd-logo', 'data-vd-providers', 'data-vd-similar'):
assert hook in block, hook
# Back button reuses the shared data-video-goto nav (no inline handler).
assert 'data-video-goto="video-library"' in block
# Back button is real browser Back (history), not a hardcoded nav.
assert 'data-video-detail-back' in block
assert "onclick" not in block
@ -352,13 +352,27 @@ def test_movie_detail_subpage_present():
assert 'data-video-detail="movie"' in block
for hook in ('data-vd-backdrop', 'data-vd-title', 'data-vd-details', 'data-vd-cast'):
assert hook in block, hook
assert 'data-video-goto="video-library"' in block and "onclick" not in block
assert 'data-video-detail-back' in block and "onclick" not in block
def test_library_movie_cards_are_clickable():
# Both kinds drill in now (no show-only gate).
def test_library_cards_are_real_detail_links():
# Cards are genuine <a href="/video-detail/library/<kind>/<id>"> (parity with
# the music artist cards) so reload / new-tab / Back work.
assert 'data-video-card-open="' in _LIB_JS
assert "/video-detail/library/" in _LIB_JS
assert "kind === 'show' ?" not in _LIB_JS # the old show-only gate is gone
# Modifier-clicks fall through to the real href (open in new tab).
assert "metaKey" in _LIB_JS and "ctrlKey" in _LIB_JS
def test_video_side_has_real_url_routing():
# Mirrors music: real /video-detail/<source>/<kind>/<id> URLs with pushState +
# popstate so Back/Forward/reload restore the same item.
assert "/video-detail/" in _JS
assert "history.pushState" in _JS and "popstate" in _JS
assert "parseDetailPath" in _JS and "buildDetailPath" in _JS
# Source segment for library-vs-search (search lands later).
assert "'library'" in _JS
def test_video_detail_module_referenced_and_isolated():

View file

@ -820,7 +820,7 @@
Isolated .vd-*; --vd-accent-rgb is extracted from the poster
at load for a per-show glow. Built by video/video-detail.js. -->
<div class="vd-page" data-video-detail="show">
<button class="vd-back" type="button" data-video-goto="video-library">
<button class="vd-back" type="button" data-video-detail-back>
<span aria-hidden="true">&larr;</span> Library
</button>
<!-- Billboard: full-bleed backdrop, content anchored bottom-left. -->
@ -876,7 +876,7 @@
show page — the renderer targets the active page by kind). -->
<section class="video-subpage" data-video-subpage="video-movie-detail" hidden>
<div class="vd-page" data-video-detail="movie">
<button class="vd-back" type="button" data-video-goto="video-library">
<button class="vd-back" type="button" data-video-detail-back>
<span aria-hidden="true">&larr;</span> Library
</button>
<div class="vd-billboard">

View file

@ -76,13 +76,15 @@
if (kind === 'movie') meta.push(it.has_file ? 'Owned' : 'Wanted');
else meta.push((it.owned_count || 0) + '/' + (it.episode_count || 0) + ' eps');
// Both movies and shows drill into their detail page.
var hook = ' data-video-card-open="' + kind + '" data-video-card-id="' + it.id + '"';
return '<div class="library-artist-card video-card--clickable"' + hook + '>' + img + badge +
// A REAL link (like the music artist cards) so reload / new-tab / Back all
// work; the click handler intercepts plain left-clicks into SPA nav.
var href = '/video-detail/library/' + kind + '/' + it.id;
var hook = ' href="' + href + '" data-video-card-open="' + kind + '" data-video-card-id="' + it.id + '"';
return '<a class="library-artist-card video-card--clickable"' + hook + '>' + img + badge +
'<div class="library-artist-info">' +
'<h3 class="library-artist-name" title="' + esc(it.title) + '">' + esc(it.title) + '</h3>' +
'<div class="library-artist-stats"><span class="library-artist-stat">' +
esc(meta.join(' · ')) + '</span></div></div></div>';
esc(meta.join(' · ')) + '</span></div></div></a>';
}
function showLoading(on) {
@ -144,11 +146,14 @@
var grid = $('[data-video-lib-grid]');
if (grid) {
grid.addEventListener('click', function (e) {
// Let modified clicks (new tab / window) use the real href.
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
var card = e.target.closest('[data-video-card-open]');
if (!card || !grid.contains(card)) return;
e.preventDefault();
document.dispatchEvent(new CustomEvent('soulsync:video-open-detail', {
detail: { kind: card.getAttribute('data-video-card-open'),
id: parseInt(card.getAttribute('data-video-card-id'), 10) },
id: parseInt(card.getAttribute('data-video-card-id'), 10), source: 'library' },
}));
});
}

View file

@ -21,6 +21,41 @@
var VIDEO_SUBTITLE = 'Movies, TV & YouTube';
var DEFAULT_VIDEO_PAGE = 'video-dashboard';
// ── URL routing — REAL links, mirroring music's /artist-detail/<source>/<id>.
// A drill-in is a genuine <a href="/video-detail/<source>/<kind>/<id>"> so
// reload / Back / Forward / open-in-new-tab all work. ``source`` is 'library'
// (a video.db id) today; 'tmdb' (a search result not yet in the library) later.
var DETAIL_BASE = '/video-detail/';
var DETAIL_PAGES = { 'video-show-detail': 1, 'video-movie-detail': 1 };
function buildDetailPath(source, kind, id) {
return DETAIL_BASE + encodeURIComponent(source || 'library') + '/' + kind + '/' + id;
}
function parseDetailPath(pathname) {
if (!pathname || pathname.indexOf(DETAIL_BASE) !== 0) return null;
var p = pathname.slice(DETAIL_BASE.length).split('/').filter(Boolean);
if (p.length < 3) return null;
var kind = p[1], id = parseInt(p[2], 10);
if ((kind !== 'movie' && kind !== 'show') || isNaN(id)) return null;
return { source: decodeURIComponent(p[0]), kind: kind, id: id };
}
// Restore a detail from the URL (popstate / initial load) WITHOUT re-pushing.
function restoreDetail(r) {
if (readSide() !== 'video') { persistSide('video'); applySide('video'); }
document.dispatchEvent(new CustomEvent('soulsync:video-open-detail',
{ detail: { kind: r.kind, id: r.id, source: r.source, _restore: true } }));
}
function onPopState() {
var r = parseDetailPath(window.location.pathname);
if (r) { restoreDetail(r); return; }
// Left the detail URL — if we're still on the video side showing a detail,
// fall back to the library (Back out of the detail).
if (document.body.getAttribute('data-side') === 'video' &&
DETAIL_PAGES[document.body.getAttribute('data-video-page')]) {
navigate('video-library');
}
}
// The video sidebar pages. Pages flagged shared: true are "same as music"
// (Import / Issues / Help) — wired to reuse the music pages in a later step;
// for now every page renders the placeholder.
@ -129,6 +164,11 @@
function navigate(pageId) {
setActiveNav(pageId);
showPage(pageId);
// Moving off a detail via the sidebar/nav drops the detail URL so a reload
// doesn't re-open it (detail pages keep their own URL via pushState).
if (!DETAIL_PAGES[pageId] && parseDetailPath(window.location.pathname)) {
try { history.replaceState(null, '', '/'); } catch (e) { /* ignore */ }
}
}
function applySide(side) {
@ -150,9 +190,15 @@
if (side !== 'music' && side !== 'video') return;
persistSide(side);
applySide(side);
if (side === 'music' && parseDetailPath(window.location.pathname)) {
try { history.replaceState(null, '', '/'); } catch (e) { /* ignore */ }
}
}
function init() {
// Capture a deep-linked detail path BEFORE applySide can clear it.
var bootDetail = parseDetailPath(window.location.pathname);
var toggleButtons = document.querySelectorAll('.side-toggle-btn');
for (var i = 0; i < toggleButtons.length; i++) {
(function (btn) {
@ -184,20 +230,50 @@
})(gotos[k]);
}
// Drill-in: a card fires soulsync:video-open-detail {kind, id}. We just
// navigate to the matching detail subpage; video-detail.js (listening to
// the same event) loads the data. Keeps the two concerns decoupled.
// Drill-in: a card fires soulsync:video-open-detail {kind, id, source}. We
// navigate to the matching detail subpage (video-detail.js loads the data)
// and push a real URL — unless we're restoring from the URL (_restore).
document.addEventListener('soulsync:video-open-detail', function (e) {
var kind = e && e.detail && e.detail.kind;
if (kind === 'movie') navigate('video-movie-detail');
else if (kind === 'show') navigate('video-show-detail');
var d = e && e.detail; if (!d) return;
if (d.kind === 'movie') navigate('video-movie-detail');
else if (d.kind === 'show') navigate('video-show-detail');
else return;
if (!d._restore) {
var path = buildDetailPath(d.source, d.kind, d.id);
if (window.location.pathname !== path) {
history.pushState({ videoDetail: { kind: d.kind, id: d.id, source: d.source || 'library' } },
'', path);
}
}
});
// The detail "← Library" back button is real Back (returns to wherever you
// drilled in from); falls back to the library if there's no in-app history.
document.addEventListener('click', function (e) {
var back = e.target.closest('[data-video-detail-back]');
if (!back) return;
e.preventDefault();
if (history.state && history.state.videoDetail) history.back();
else navigate('video-library');
});
window.addEventListener('popstate', onPopState);
var defaultNav = document.querySelector(
'.video-nav .nav-button[data-video-page="' + DEFAULT_VIDEO_PAGE + '"]');
if (defaultNav) defaultNav.classList.add('active');
applySide(readSide());
applySide(bootDetail ? 'video' : readSide());
// Deep link / reload straight to a detail URL → restore it (applySide above
// may have cleared the URL, so re-assert it first).
if (bootDetail) {
var path = buildDetailPath(bootDetail.source, bootDetail.kind, bootDetail.id);
try {
history.replaceState({ videoDetail: bootDetail }, '', path);
} catch (e) { /* ignore */ }
restoreDetail(bootDetail);
}
}
if (document.readyState === 'loading') {