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:
parent
4c16448ab3
commit
5012f44cfa
3 changed files with 119 additions and 33 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue