From c84231dd4fd983a6b163147dd208efa4c8dc1dbf Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Sat, 13 Jun 2026 19:49:09 -0700 Subject: [PATCH] video side: build the Dashboard page (mirrors music, isolated data) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real first video page, reusing music's .dash-grid/.dash-card CSS for an identical look — but every value is driven by isolated video JS, no music code referenced. Sections mirror the music dashboard, adapted: - Service Status: Media Server / Download Client / Metadata Source - System Stats: swaps 'Active Syncs' -> 'Disk Usage'; keeps download/speed/ uptime/memory - Library: Movies / Shows / Episodes / Disk Size - Recent Syncs -> Recent Downloads (empty state for now) - Quick Actions: Add Movie/Show, Watchlist, Downloads (navigate via data-video-goto) - Recent Activity - No enrichment section, no header sweep animation (per plan) Mechanics: - #video-page-host now holds .video-subpage sections; controller toggles one at a time and falls back to #video-placeholder-slot for unbuilt pages. - video-side.js dispatches soulsync:video-page-shown; video-dashboard.js (new isolated IIFE) listens and applies a zeroed STUB until video.db exists. Single seam to swap for a real /api/video/dashboard fetch later. - All wiring via data-attrs + addEventListener; no inline onclick (keeps the script-split integrity contract intact). 73 tests green. --- tests/test_video_side_shell.py | 34 ++++ webui/index.html | 242 +++++++++++++++++++++++++- webui/static/video/video-dashboard.js | 76 ++++++++ webui/static/video/video-side.css | 23 ++- webui/static/video/video-side.js | 62 +++++-- 5 files changed, 420 insertions(+), 17 deletions(-) create mode 100644 webui/static/video/video-dashboard.js diff --git a/tests/test_video_side_shell.py b/tests/test_video_side_shell.py index 67fdef53..dad466bd 100644 --- a/tests/test_video_side_shell.py +++ b/tests/test_video_side_shell.py @@ -16,6 +16,7 @@ from pathlib import Path _ROOT = Path(__file__).resolve().parent.parent _INDEX = (_ROOT / "webui" / "index.html").read_text(encoding="utf-8", errors="replace") _JS = (_ROOT / "webui" / "static" / "video" / "video-side.js").read_text(encoding="utf-8") +_DASH_JS = (_ROOT / "webui" / "static" / "video" / "video-dashboard.js").read_text(encoding="utf-8") _CSS_PATH = _ROOT / "webui" / "static" / "video" / "video-side.css" EXPECTED_VIDEO_PAGES = { @@ -75,6 +76,39 @@ def test_music_sidebar_nav_still_intact(): assert "Music Sync & Manager" in _INDEX +# --- the video dashboard (first built page) ------------------------------- + +def test_video_dashboard_subpage_present_with_expected_cards(): + block = _block( + _INDEX, r'
") + # Mirrors the music dashboard's sections (minus enrichment), reusing its CSS. + for card in ("services", "stats", "library", "downloads", "tools", "activity"): + assert f'data-card="{card}"' in block, f"video dashboard missing the '{card}' card" + # Reuses music's dashboard classes so the look matches. + assert 'class="dash-grid"' in block + assert "stat-card-dashboard" in block + # Driven by data, not music code: stat values carry data-video-stat hooks. + assert "data-video-stat=" in block + # Isolation + integrity contract: no inline handlers anywhere in the page. + assert "onclick" not in block + + +def test_video_dashboard_has_placeholder_slot_for_unbuilt_pages(): + assert 'id="video-placeholder-slot"' in _INDEX + + +def test_video_dashboard_data_module_referenced_and_isolated(): + assert "video/video-dashboard.js" in _INDEX + stripped = _DASH_JS.strip() + assert stripped.startswith("/*") or stripped.startswith("(function") + assert "(function" in _DASH_JS and "})();" in _DASH_JS + # Decoupled from the controller: it listens for the page-shown event rather + # than being called directly, and adds no globals / inline handlers. + assert "soulsync:video-page-shown" in _DASH_JS + assert "addEventListener" in _DASH_JS + assert "window." not in _DASH_JS + + def test_controller_is_isolated_iife_with_no_globals(): stripped = _JS.strip() # Wrapped in an IIFE → declares no module-level globals that could collide diff --git a/webui/index.html b/webui/index.html index 57486bd6..d148b34d 100644 --- a/webui/index.html +++ b/webui/index.html @@ -415,8 +415,244 @@
- -
+ +
+
+
+
+
+

Video Dashboard

+

Monitor your video library, downloads and services

+
+
+
+ + +
+
+

Service Status

+

Connection health for the video services.

+
+
+
+
+
+ Media Server + +
+

Not configured

+

Response: --

+ +
+
+
+ Download Client + +
+

Not configured

+

Response: --

+ +
+
+
+ Metadata Source + +
+

Not configured

+

Response: --

+ +
+
+
+
+ + +
+
+

System Stats

+

Live performance metrics.

+
+
+
+
+

Active Downloads

+

0

+

Currently downloading

+
+
+

Finished Downloads

+

0

+

Completed this session

+
+
+

Download Speed

+

0 KB/s

+

Combined speed

+
+
+

Disk Usage

+

--

+

Video library size

+
+
+

System Uptime

+

0m

+

Application runtime

+
+
+

Memory Usage

+

--

+

Current usage

+
+
+
+
+ + +
+
+

Library

+

Your collection at a glance.

+
+
+
+
+
+
+

Video Library

+

Not scanned yet

+
+
+
+
+
+ 0 + Movies +
+
+
+
+ 0 + Shows +
+
+
+
+ 0 + Episodes +
+
+
+
+ -- + Disk Size +
+
+
+
+
+
+ + +
+
+

Recent Downloads

+

Your latest grabs.

+
+
+
+

No downloads yet.

+
+
+
+ + +
+
+

Quick Actions

+

Jump straight into the video side.

+
+
+ + + +
+
+ + +
+
+

Recent Activity

+

What just happened.

+
+
+
+
+ 🎥 +
+

Video side ready

+

Dashboard initialized successfully

+
+

Now

+
+
+
+
+ +
+
+
+ +
@@ -8543,6 +8779,8 @@ + + diff --git a/webui/static/video/video-dashboard.js b/webui/static/video/video-dashboard.js new file mode 100644 index 00000000..16642a76 --- /dev/null +++ b/webui/static/video/video-dashboard.js @@ -0,0 +1,76 @@ +/* + * SoulSync — Video dashboard data layer. + * + * ISOLATION CONTRACT: like video-side.js this is a self-contained IIFE (no + * globals, no inline handlers, lives under static/video/ which the script-split + * integrity scan does not touch). It NEVER references music code, and music + * never references it. + * + * It owns only the *data* of the video dashboard — the markup lives in + * index.html and reuses music's .dash-card CSS for an identical look. It learns + * when the dashboard becomes visible by listening for the + * 'soulsync:video-page-shown' event that video-side.js dispatches (so the two + * modules stay decoupled — no direct calls between them). + * + * TODO(video.db): STUB_STATS below is a placeholder. Once the video database + + * its /api/video/dashboard endpoint exist, replace loadStats() with a fetch and + * feed the same shape through applyStats(). Nothing else here needs to change. + */ +(function () { + 'use strict'; + + var DASHBOARD_ID = 'video-dashboard'; + + // Zeroed placeholder until the video DB is wired. Keys match the markup's + // data-video-stat attributes. + var STUB_STATS = { + 'active-downloads': '0', + 'finished-downloads': '0', + 'download-speed': '0 KB/s', + 'disk-usage': '--', + 'uptime': '0m', + 'memory': '--', + 'movies': '0', + 'shows': '0', + 'episodes': '0', + 'library-size': '--' + }; + + function applyStats(stats) { + var nodes = document.querySelectorAll('[data-video-stat]'); + for (var i = 0; i < nodes.length; i++) { + var key = nodes[i].getAttribute('data-video-stat'); + if (Object.prototype.hasOwnProperty.call(stats, key)) { + nodes[i].textContent = stats[key]; + } + } + } + + function loadStats() { + // TODO(video.db): replace with fetch('/api/video/dashboard') -> applyStats(json). + applyStats(STUB_STATS); + } + + // Service "Test" buttons are inert until the video services exist. Mark them + // honestly rather than firing a no-op that looks broken. + var testWired = false; + function wireTestButtons() { + if (testWired) return; + testWired = true; + var buttons = document.querySelectorAll('[data-video-test]'); + for (var i = 0; i < buttons.length; i++) { + (function (btn) { + btn.disabled = true; + btn.title = 'Coming soon — video services not configured yet'; + })(buttons[i]); + } + } + + function onPageShown(e) { + if (!e || e.detail !== DASHBOARD_ID) return; + wireTestButtons(); + loadStats(); + } + + document.addEventListener('soulsync:video-page-shown', onPageShown); +})(); diff --git a/webui/static/video/video-side.css b/webui/static/video/video-side.css index be889070..103006bd 100644 --- a/webui/static/video/video-side.css +++ b/webui/static/video/video-side.css @@ -108,11 +108,20 @@ body[data-side="video"] .status-section { display: none; } -/* ── Placeholder content (until the real video pages land) ─────────────── */ -.video-placeholder { +/* ── Subpages: one built video page shown at a time ────────────────────── */ +/* Each real page is a .video-subpage inside #video-page-host; the controller + toggles the [hidden] attribute. Be explicit so no reused .dash-* rule can + resurrect a hidden page. */ +.video-subpage[hidden], +.video-placeholder-slot[hidden] { + display: none !important; +} + +/* ── Placeholder content (for pages not built yet) ─────────────────────── */ +.video-placeholder-slot { padding: 48px 40px; } -.video-placeholder .header-title { +.video-placeholder-slot .header-title { font-size: 22px; font-weight: 700; } @@ -121,3 +130,11 @@ body[data-side="video"] .status-section { color: var(--text-secondary, #9aa0aa); font-size: 14px; } + +/* Empty-state line inside reused dashboard cards (e.g. Recent Downloads). */ +.video-empty-note { + margin: 0; + padding: 8px 2px; + color: var(--text-secondary, #9aa0aa); + font-size: 14px; +} diff --git a/webui/static/video/video-side.js b/webui/static/video/video-side.js index 0b3392ea..0f4f6cf0 100644 --- a/webui/static/video/video-side.js +++ b/webui/static/video/video-side.js @@ -58,18 +58,16 @@ return VIDEO_PAGES[0]; } - function showVideoPage(pageId) { - var meta = pageMeta(pageId); + function setActiveNav(pageId) { var navButtons = document.querySelectorAll('.video-nav .nav-button[data-video-page]'); for (var i = 0; i < navButtons.length; i++) { navButtons[i].classList.toggle( - 'active', navButtons[i].getAttribute('data-video-page') === meta.id); + 'active', navButtons[i].getAttribute('data-video-page') === pageId); } - var host = document.getElementById('video-page-host'); - if (!host) return; + } + + function renderPlaceholder(slot, meta) { // Built from our own static constants only — no user input. - var shell = document.createElement('div'); - shell.className = 'page-shell video-placeholder'; var h2 = document.createElement('h2'); h2.className = 'header-title'; var span = document.createElement('span'); @@ -78,9 +76,37 @@ var note = document.createElement('p'); note.className = 'video-placeholder-note'; note.textContent = 'The ' + meta.label + ' page for the video side is coming soon.'; - host.textContent = ''; - host.appendChild(h2); - host.appendChild(note); + slot.textContent = ''; + slot.appendChild(h2); + slot.appendChild(note); + } + + // Show one video page: reveal its built .video-subpage if one exists, else + // fall back to the placeholder slot. Then announce it so per-page data + // modules (e.g. video-dashboard.js) can populate themselves — they listen + // for this event instead of being called directly, keeping each isolated. + function showPage(pageId) { + var meta = pageMeta(pageId); + var host = document.getElementById('video-page-host'); + if (!host) return; + var matched = null; + var subpages = host.querySelectorAll('.video-subpage'); + for (var i = 0; i < subpages.length; i++) { + var isMatch = subpages[i].getAttribute('data-video-subpage') === meta.id; + subpages[i].hidden = !isMatch; + if (isMatch) matched = subpages[i]; + } + var slot = document.getElementById('video-placeholder-slot'); + if (slot) { + slot.hidden = !!matched; + if (!matched) renderPlaceholder(slot, meta); + } + document.dispatchEvent(new CustomEvent('soulsync:video-page-shown', { detail: meta.id })); + } + + function navigate(pageId) { + setActiveNav(pageId); + showPage(pageId); } function applySide(side) { @@ -94,7 +120,7 @@ } if (side === 'video') { var active = document.querySelector('.video-nav .nav-button.active'); - showVideoPage(active ? active.getAttribute('data-video-page') : DEFAULT_VIDEO_PAGE); + navigate(active ? active.getAttribute('data-video-page') : DEFAULT_VIDEO_PAGE); } } @@ -119,11 +145,23 @@ (function (btn) { btn.addEventListener('click', function (e) { e.preventDefault(); - showVideoPage(btn.getAttribute('data-video-page')); + navigate(btn.getAttribute('data-video-page')); }); })(navButtons[j]); } + // In-page jumps (e.g. dashboard Quick Action tiles) navigate the same + // way as the sidebar nav, via data-video-goto. No inline onclick. + var gotos = document.querySelectorAll('[data-video-goto]'); + for (var k = 0; k < gotos.length; k++) { + (function (el) { + el.addEventListener('click', function (e) { + e.preventDefault(); + navigate(el.getAttribute('data-video-goto')); + }); + })(gotos[k]); + } + var defaultNav = document.querySelector( '.video-nav .nav-button[data-video-page="' + DEFAULT_VIDEO_PAGE + '"]'); if (defaultNav) defaultNav.classList.add('active');