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:
parent
c565fec59d
commit
0598f5fbda
4 changed files with 114 additions and 19 deletions
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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">←</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">←</span> Library
|
||||
</button>
|
||||
<div class="vd-billboard">
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
Loading…
Reference in a new issue