Video: deep-link every sidebar page (URL parity with the music side)

Only the detail page produced a real URL before; top-level video pages (search,
library, discover, calendar, watchlist, wishlist, …) pushed nothing and even
cleared the URL to '/'. Now each page deep-links to '/' + pageId (e.g.
/video-search), mirroring music's '/<page>' and the existing /video-detail/ scheme:

- navigate() pushes/replaces { videoPage } history state; parsePagePath/buildPagePath
  added alongside parseDetailPath/buildDetailPath.
- popstate restores a page URL (and hands the side back to music when Back crosses
  out of /video-*); boot restores a page deep link, re-asserting the URL against
  music's boot clobber (same tactic the detail boot already uses).
- applySide() is now chrome-only; switchSide()/boot drive navigation explicitly.
- Nav anchors carry the real href=/video-<page> (was '#') and the click handler
  lets ⌘/Ctrl/middle-click open a new tab — full parity with the music nav + cards.

Server already serves /video-* via the SPA catch-all; music side untouched
(isolated IIFE). Reload / Back / Forward / new-tab now work for every page.
This commit is contained in:
BoulderBadgeDad 2026-06-17 16:52:58 -07:00
parent 4c16448ab3
commit 5012f44cfa
3 changed files with 119 additions and 33 deletions

View file

@ -376,6 +376,21 @@ def test_video_side_has_real_url_routing():
assert "'library'" in _JS
def test_video_top_level_pages_are_deep_linked():
# Every sidebar page (not just detail) deep-links to '/' + pageId, mirroring
# the music side — pushState on nav, parse + restore on reload/Back.
assert "parsePagePath" in _JS and "buildPagePath" in _JS
assert "videoPage" in _JS # history state tag for pages
# The nav anchors carry the real path (so ⌘/middle-click open a new tab) and
# no longer point at the dead '#'.
assert 'data-video-page="video-search" href="/video-search"' in _INDEX
assert 'data-video-page="video-library" href="/video-library"' in _INDEX
assert 'href="#"' not in _block( # none left in the video nav block
_INDEX, r'<nav class="sidebar-nav video-nav"', "</nav>")
# Modifier-clicks fall through to the href instead of being swallowed.
assert "metaKey" in _JS and "ctrlKey" in _JS
def test_video_detail_module_referenced_and_isolated():
assert "video/video-detail.js" in _INDEX
src = (_ROOT / "webui" / "static" / "video" / "video-detail.js").read_text(encoding="utf-8")

View file

@ -325,57 +325,57 @@
<!-- Video Navigation (shell-only; shown when body[data-side="video"], wired by video/video-side.js) -->
<nav class="sidebar-nav video-nav" aria-label="Video navigation">
<a class="nav-button" data-video-page="video-dashboard" href="#">
<a class="nav-button" data-video-page="video-dashboard" href="/video-dashboard">
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="4" rx="1"/><rect x="3" y="14" width="7" height="4" rx="1"/><rect x="14" y="11" width="7" height="7" rx="1"/></svg></span>
<span class="nav-text">Dashboard</span>
</a>
<a class="nav-button" data-video-page="video-search" href="#">
<a class="nav-button" data-video-page="video-search" href="/video-search">
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg></span>
<span class="nav-text">Search</span>
</a>
<a class="nav-button" data-video-page="video-discover" href="#">
<a class="nav-button" data-video-page="video-discover" href="/video-discover">
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polygon points="16.24 7.76 14.12 14.12 7.76 16.24 9.88 9.88 16.24 7.76" fill="currentColor" opacity="0.2" stroke="currentColor"/></svg></span>
<span class="nav-text">Discover</span>
</a>
<a class="nav-button" data-video-page="video-library" href="#">
<a class="nav-button" data-video-page="video-library" href="/video-library">
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/></svg></span>
<span class="nav-text">Library</span>
</a>
<a class="nav-button" data-video-page="video-watchlist" href="#">
<a class="nav-button" data-video-page="video-watchlist" href="/video-watchlist">
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></span>
<span class="nav-text">Watchlist</span>
<span class="dl-nav-badge hidden" data-video-watchlist-badge>0</span>
</a>
<a class="nav-button" data-video-page="video-wishlist" href="#">
<a class="nav-button" data-video-page="video-wishlist" href="/video-wishlist">
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg></span>
<span class="nav-text">Wishlist</span>
<span class="dl-nav-badge hidden" data-video-wishlist-badge>0</span>
</a>
<a class="nav-button" data-video-page="video-downloads" href="#">
<a class="nav-button" data-video-page="video-downloads" href="/video-downloads">
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg></span>
<span class="nav-text">Downloads</span>
</a>
<a class="nav-button" data-video-page="video-calendar" href="#">
<a class="nav-button" data-video-page="video-calendar" href="/video-calendar">
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg></span>
<span class="nav-text">Calendar</span>
</a>
<a class="nav-button" data-video-page="video-tools" href="#">
<a class="nav-button" data-video-page="video-tools" href="/video-tools">
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a4 4 0 0 0-5.4 5.4L3 18v3h3l6.3-6.3a4 4 0 0 0 5.4-5.4l-2.6 2.6-2.1-.5-.5-2.1 2.7-2.4z"/></svg></span>
<span class="nav-text">Tools</span>
</a>
<a class="nav-button" data-video-page="video-import" href="#">
<a class="nav-button" data-video-page="video-import" href="/video-import">
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2"/><polyline points="7 9 12 4 17 9"/><line x1="12" y1="4" x2="12" y2="16"/></svg></span>
<span class="nav-text">Import</span>
</a>
<a class="nav-button" data-video-page="video-settings" href="#">
<a class="nav-button" data-video-page="video-settings" href="/video-settings">
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></span>
<span class="nav-text">Settings</span>
</a>
<a class="nav-button" data-video-page="video-issues" href="#">
<a class="nav-button" data-video-page="video-issues" href="/video-issues">
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg></span>
<span class="nav-text">Issues</span>
</a>
<a class="nav-button" data-video-page="video-help" href="#">
<a class="nav-button" data-video-page="video-help" href="/video-help">
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></span>
<span class="nav-text">Help & Docs</span>
</a>

View file

@ -55,6 +55,26 @@
{ detail: { kind: r.kind, id: r.id, source: r.source, _restore: true } }));
}
// ── Top-level pages get a real URL too: '/' + pageId (e.g. /video-search) ──
// Mirrors music's '/<page>' and our /video-detail/ scheme so every sidebar
// option is deep-linkable (reload / Back / Forward / new-tab). Only a single
// segment whose id is a real (non-detail) video page counts; detail URLs are
// matched by parseDetailPath above and never collide.
function isPageId(pageId) {
if (!pageId || DETAIL_PAGES[pageId]) return false;
for (var i = 0; i < VIDEO_PAGES.length; i++) {
if (VIDEO_PAGES[i].id === pageId) return true;
}
return false;
}
function buildPagePath(pageId) { return '/' + pageId; }
function parsePagePath(pathname) {
if (!pathname) return null;
var seg = pathname.replace(/^\/+|\/+$/g, '');
if (!seg || seg.indexOf('/') >= 0) return null; // single segment only
return isPageId(seg) ? seg : null;
}
// ── Smart back button (mirrors music's artist-detail) ─────────────────────
// Browser history does the real (multi-layer) navigation; this stack only
// tracks WHERE EACH detail layer was opened FROM, so the button can label
@ -98,7 +118,8 @@
}
function onPopState() {
var r = parseDetailPath(window.location.pathname);
var path = window.location.pathname;
var r = parseDetailPath(path);
if (r) {
// Synced to the layer depth stamped on the history entry (handles both
// our back button and the browser's Back).
@ -109,13 +130,22 @@
updateBackLabels();
return;
}
// Left the detail URL — return to where the chain started, not always library.
if (document.body.getAttribute('data-side') === 'video' &&
DETAIL_PAGES[document.body.getAttribute('data-video-page')]) {
var dest = (_backStack[0] && _backStack[0].type === 'page') ? _backStack[0].pageId : 'video-library';
var pg = parsePagePath(path);
if (pg) {
// Back/Forward landed on a video page URL → ensure the video side, show
// the page; the URL is already correct so don't re-push it.
if (readSide() !== 'video') { persistSide('video'); applySide('video'); }
navigate(pg, 'restore');
_backStack = [];
navigate(dest);
updateBackLabels();
return;
}
// The URL is no longer a video path (Back crossed back into music). Hand the
// side back so music's own router can show the page for this URL.
if (document.body.getAttribute('data-side') === 'video') {
persistSide('music');
applySide('music');
_backStack = [];
}
}
@ -225,16 +255,27 @@
document.dispatchEvent(new CustomEvent('soulsync:video-page-shown', { detail: meta.id }));
}
function navigate(pageId) {
// Show a page + keep the URL in sync. Detail pages own their /video-detail/
// URL (pushed by the open-detail handler); every other page deep-links to
// '/' + pageId. mode: undefined = push a new history entry (user nav),
// 'replace' = swap the current entry, 'restore' = URL already correct (popstate
// / boot) so don't touch history.
function navigate(pageId, mode) {
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 */ }
}
if (DETAIL_PAGES[pageId] || mode === 'restore') return;
var path = buildPagePath(pageId);
try {
if (mode === 'replace' || window.location.pathname === path) {
history.replaceState({ videoPage: pageId }, '', path);
} else {
history.pushState({ videoPage: pageId }, '', path);
}
} catch (e) { /* ignore */ }
}
// Flip the shell chrome to a side (data-side drives the CSS). Does NOT navigate
// — callers decide which page to show and whether to push a URL.
function applySide(side) {
document.body.setAttribute('data-side', side);
var subtitle = document.querySelector('.sidebar-header .app-subtitle');
@ -244,25 +285,27 @@
toggleButtons[i].classList.toggle(
'active', toggleButtons[i].getAttribute('data-side-target') === side);
}
if (side === 'video') {
var active = document.querySelector('.video-nav .nav-button.active');
navigate(active ? active.getAttribute('data-video-page') : DEFAULT_VIDEO_PAGE);
}
}
function switchSide(side) {
if (side !== 'music' && side !== 'video') return;
persistSide(side);
applySide(side);
if (side === 'music' && parseDetailPath(window.location.pathname)) {
if (side === 'video') {
// Land on the last-active video page and give it a real URL.
var active = document.querySelector('.video-nav .nav-button.active');
navigate(active ? active.getAttribute('data-video-page') : DEFAULT_VIDEO_PAGE);
} else if (parseDetailPath(window.location.pathname) || parsePagePath(window.location.pathname)) {
// Back to music from a video URL → drop it so a reload stays on music.
try { history.replaceState(null, '', '/'); } catch (e) { /* ignore */ }
}
}
function init() {
// Deep-linked detail path captured at eval time (music may already have
// rewritten window.location to /dashboard by now).
// Deep-linked detail OR page path captured at eval time (music may already
// have rewritten window.location to /dashboard by now).
var bootDetail = parseDetailPath(BOOT_PATH);
var bootPage = bootDetail ? null : parsePagePath(BOOT_PATH);
var toggleButtons = document.querySelectorAll('.side-toggle-btn');
for (var i = 0; i < toggleButtons.length; i++) {
@ -277,6 +320,9 @@
for (var j = 0; j < navButtons.length; j++) {
(function (btn) {
btn.addEventListener('click', function (e) {
// Let ⌘/Ctrl/middle-click open the real href (/video-<page>) in a
// new tab, exactly like the music nav + the video cards.
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return;
e.preventDefault();
_backStack = []; // sidebar nav is a fresh entry point
navigate(btn.getAttribute('data-video-page'));
@ -347,7 +393,32 @@
'.video-nav .nav-button[data-video-page="' + DEFAULT_VIDEO_PAGE + '"]');
if (defaultNav) defaultNav.classList.add('active');
applySide(bootDetail ? 'video' : readSide());
var bootSide = (bootDetail || bootPage) ? 'video' : readSide();
applySide(bootSide);
// On the video side without a detail deep link, show the initial page (the
// page-path deep link if any, else the last-active/default nav). Deferred to
// a macrotask so every page module's listeners are registered first. Only a
// genuine page deep link re-asserts its URL against music's boot clobber;
// the plain last-side-was-video case leaves the URL untouched (as before).
if (bootSide === 'video' && !bootDetail) {
var activeBtn = document.querySelector('.video-nav .nav-button.active');
var initialPage = bootPage || (activeBtn && activeBtn.getAttribute('data-video-page')) || DEFAULT_VIDEO_PAGE;
setActiveNav(initialPage);
var pagePath = buildPagePath(initialPage);
var reassertPage = function () {
if (bootPage && document.body.getAttribute('data-side') === 'video' &&
!DETAIL_PAGES[document.body.getAttribute('data-video-page')] &&
window.location.pathname !== pagePath) {
try { history.replaceState({ videoPage: initialPage }, '', pagePath); } catch (e) { /* ignore */ }
}
};
setTimeout(function () {
reassertPage();
navigate(initialPage, 'restore');
if (bootPage) [120, 350, 700].forEach(function (ms) { setTimeout(reassertPage, ms); });
}, 0);
}
// Deep link / reload straight to a detail URL → restore it. Deferred to a
// macrotask so EVERY script's DOMContentLoaded handler has registered