video side: build the Dashboard page (mirrors music, isolated data)

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.
This commit is contained in:
BoulderBadgeDad 2026-06-13 19:49:09 -07:00
parent 9330d66fcd
commit c84231dd4f
5 changed files with 420 additions and 17 deletions

View file

@ -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'<section class="video-subpage" data-video-subpage="video-dashboard"', "</section>")
# 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

View file

@ -415,8 +415,244 @@
<canvas id="page-particles-canvas"></canvas>
<div id="webui-react-root" class="page" data-react-app="router"></div>
<!-- Video side content host (shell-only; filled by video/video-side.js) -->
<div class="page video-page" id="video-page-host"></div>
<!-- Video side content host. Real pages live here as .video-subpage
sections (shown one at a time by video/video-side.js); pages
without a built page fall back to #video-placeholder-slot.
ISOLATION: reuses music's .dash-grid/.dash-card CSS for an
identical look, but every value is driven by video/video-*.js
off the (future) video DB — no music code is referenced. No
inline onclick (data-attrs + addEventListener only). -->
<div class="page video-page" id="video-page-host">
<section class="video-subpage" data-video-subpage="video-dashboard">
<div class="page-shell dashboard-container">
<div class="dashboard-header">
<div class="header-text">
<h2 class="header-title"><span>Video Dashboard</span></h2>
<p class="header-subtitle">Monitor your video library, downloads and services</p>
</div>
</div>
<div class="dash-grid">
<!-- Service Status -->
<article class="dash-card" data-card="services">
<header class="dash-card__head">
<h3 class="dash-card__title">Service Status</h3>
<p class="dash-card__sub">Connection health for the video services.</p>
</header>
<div class="dash-card__body">
<div class="service-status-grid">
<div class="service-card" data-status-ready="false">
<div class="service-card-header">
<span class="service-card-title">Media Server</span>
<span class="service-card-indicator disconnected">&#9679;</span>
</div>
<p class="service-card-status-text">Not configured</p>
<p class="service-card-response-time">Response: --</p>
<div class="service-card-footer">
<button class="service-card-button" type="button" data-video-test="media-server">Test</button>
</div>
</div>
<div class="service-card" data-status-ready="false">
<div class="service-card-header">
<span class="service-card-title">Download Client</span>
<span class="service-card-indicator disconnected">&#9679;</span>
</div>
<p class="service-card-status-text">Not configured</p>
<p class="service-card-response-time">Response: --</p>
<div class="service-card-footer">
<button class="service-card-button" type="button" data-video-test="download-client">Test</button>
</div>
</div>
<div class="service-card" data-status-ready="false">
<div class="service-card-header">
<span class="service-card-title">Metadata Source</span>
<span class="service-card-indicator disconnected">&#9679;</span>
</div>
<p class="service-card-status-text">Not configured</p>
<p class="service-card-response-time">Response: --</p>
<div class="service-card-footer">
<button class="service-card-button" type="button" data-video-test="metadata-source">Test</button>
</div>
</div>
</div>
</div>
</article>
<!-- System Stats -->
<article class="dash-card" data-card="stats">
<header class="dash-card__head">
<h3 class="dash-card__title">System Stats</h3>
<p class="dash-card__sub">Live performance metrics.</p>
</header>
<div class="dash-card__body">
<div class="stats-grid-dashboard">
<div class="stat-card-dashboard">
<p class="stat-card-title">Active Downloads</p>
<p class="stat-card-value" data-video-stat="active-downloads">0</p>
<p class="stat-card-subtitle">Currently downloading</p>
</div>
<div class="stat-card-dashboard">
<p class="stat-card-title">Finished Downloads</p>
<p class="stat-card-value" data-video-stat="finished-downloads">0</p>
<p class="stat-card-subtitle">Completed this session</p>
</div>
<div class="stat-card-dashboard">
<p class="stat-card-title">Download Speed</p>
<p class="stat-card-value" data-video-stat="download-speed">0 KB/s</p>
<p class="stat-card-subtitle">Combined speed</p>
</div>
<div class="stat-card-dashboard">
<p class="stat-card-title">Disk Usage</p>
<p class="stat-card-value" data-video-stat="disk-usage">--</p>
<p class="stat-card-subtitle">Video library size</p>
</div>
<div class="stat-card-dashboard">
<p class="stat-card-title">System Uptime</p>
<p class="stat-card-value" data-video-stat="uptime">0m</p>
<p class="stat-card-subtitle">Application runtime</p>
</div>
<div class="stat-card-dashboard">
<p class="stat-card-title">Memory Usage</p>
<p class="stat-card-value" data-video-stat="memory">--</p>
<p class="stat-card-subtitle">Current usage</p>
</div>
</div>
</div>
</article>
<!-- Library -->
<article class="dash-card" data-card="library">
<header class="dash-card__head">
<h3 class="dash-card__title">Library</h3>
<p class="dash-card__sub">Your collection at a glance.</p>
</header>
<div class="dash-card__body">
<div class="library-status-card">
<div class="library-status-glow"></div>
<div class="library-status-header">
<div class="library-status-info">
<h4 class="library-status-title">Video Library</h4>
<p class="library-status-subtitle">Not scanned yet</p>
</div>
</div>
<div class="library-status-stats">
<div class="library-status-stat">
<div class="library-status-stat-text">
<span class="library-status-stat-value" data-video-stat="movies">0</span>
<span class="library-status-stat-label">Movies</span>
</div>
</div>
<div class="library-status-stat">
<div class="library-status-stat-text">
<span class="library-status-stat-value" data-video-stat="shows">0</span>
<span class="library-status-stat-label">Shows</span>
</div>
</div>
<div class="library-status-stat">
<div class="library-status-stat-text">
<span class="library-status-stat-value" data-video-stat="episodes">0</span>
<span class="library-status-stat-label">Episodes</span>
</div>
</div>
<div class="library-status-stat">
<div class="library-status-stat-text">
<span class="library-status-stat-value" data-video-stat="library-size">--</span>
<span class="library-status-stat-label">Disk Size</span>
</div>
</div>
</div>
</div>
</div>
</article>
<!-- Recent Downloads -->
<article class="dash-card" data-card="downloads">
<header class="dash-card__head">
<h3 class="dash-card__title">Recent Downloads</h3>
<p class="dash-card__sub">Your latest grabs.</p>
</header>
<div class="dash-card__body">
<div class="sync-history-cards" data-video-recent-downloads>
<p class="video-empty-note">No downloads yet.</p>
</div>
</div>
</article>
<!-- Quick Actions -->
<article class="dash-card dash-card--quick-actions" data-card="tools">
<header class="dash-card__head">
<h3 class="dash-card__title">Quick Actions</h3>
<p class="dash-card__sub">Jump straight into the video side.</p>
</header>
<div class="dash-card__body qa-bento">
<button class="qa-tile qa-tile--hero" type="button" data-video-goto="video-search" aria-label="Add a movie or show">
<div class="qa-tile__topline">
<span class="qa-tile__pulse" aria-hidden="true"></span>
<span class="qa-tile__kicker">Find &amp; add</span>
</div>
<div class="qa-tile__heading">
<strong class="qa-tile__title">Add Movie or Show</strong>
<p class="qa-tile__desc">Search across sources and start tracking something new.</p>
</div>
<div class="qa-tile__cta">
<span class="qa-tile__cta-label">Search</span>
<span class="qa-tile__cta-arrow" aria-hidden="true">&rarr;</span>
</div>
</button>
<button class="qa-tile qa-tile--minor" type="button" data-video-goto="video-watchlist" aria-label="Open Watchlist">
<div class="qa-tile__topline">
<span class="qa-tile__kicker">Following</span>
</div>
<div class="qa-tile__heading">
<strong class="qa-tile__title">Watchlist</strong>
<p class="qa-tile__desc">Shows &amp; channels you monitor.</p>
</div>
<div class="qa-tile__cta">
<span class="qa-tile__cta-label">Open</span>
<span class="qa-tile__cta-arrow" aria-hidden="true">&rarr;</span>
</div>
</button>
<button class="qa-tile qa-tile--minor" type="button" data-video-goto="video-downloads" aria-label="Open Downloads">
<div class="qa-tile__topline">
<span class="qa-tile__kicker">Activity</span>
</div>
<div class="qa-tile__heading">
<strong class="qa-tile__title">Downloads</strong>
<p class="qa-tile__desc">Queue, progress and history.</p>
</div>
<div class="qa-tile__cta">
<span class="qa-tile__cta-label">Open</span>
<span class="qa-tile__cta-arrow" aria-hidden="true">&rarr;</span>
</div>
</button>
</div>
</article>
<!-- Recent Activity -->
<article class="dash-card" data-card="activity">
<header class="dash-card__head">
<h3 class="dash-card__title">Recent Activity</h3>
<p class="dash-card__sub">What just happened.</p>
</header>
<div class="dash-card__body">
<div class="activity-feed-container" data-video-activity-feed>
<div class="activity-item">
<span class="activity-icon">&#127909;</span>
<div class="activity-text-content">
<p class="activity-title">Video side ready</p>
<p class="activity-subtitle">Dashboard initialized successfully</p>
</div>
<p class="activity-time">Now</p>
</div>
</div>
</div>
</article>
</div>
</div>
</section>
<div class="video-placeholder-slot" id="video-placeholder-slot" hidden></div>
</div>
<!-- Dashboard Page -->
<div class="page" id="dashboard-page">
@ -8543,6 +8779,8 @@
<script src="{{ url_for('static', filename='worker-orbs.js', v=static_v) }}"></script>
<!-- Video side shell controller (isolated; music never references it) -->
<script src="{{ url_for('static', filename='video/video-side.js', v=static_v) }}"></script>
<!-- Video dashboard data layer (isolated; populates the dashboard from the video DB) -->
<script src="{{ url_for('static', filename='video/video-dashboard.js', v=static_v) }}"></script>
</body>
</html>

View file

@ -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);
})();

View file

@ -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;
}

View file

@ -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');