video: TV-show detail page (hero + season/episode tree), isolated
Drill-in from a show card: full-bleed backdrop + poster + title/badges/overview + stat tiles, then seasons->episodes as collapsible accordions with owned/missing state and per-season coverage bars (season = album, episode = track — inspired by the music artist page, premium vibe). - video-detail.js (isolated IIFE) renders from /api/video/detail/show/<id>; backdrop/poster via the proxy. - Show cards dispatch soulsync:video-open-detail; video-side.js navigates to the (non-nav) detail subpage; back button reuses data-video-goto. - Movies stay non-clickable until the movie-detail page lands next. - .vd-* CSS scoped to the detail page; music untouched. Shell + isolation tests.
This commit is contained in:
parent
50632f6392
commit
5a2113bd03
6 changed files with 382 additions and 1 deletions
|
|
@ -327,6 +327,39 @@ def test_video_worker_orbs_referenced_and_isolated():
|
|||
assert "/api/enrichment/" not in src
|
||||
|
||||
|
||||
def test_show_detail_subpage_present():
|
||||
block = _block(
|
||||
_INDEX, r'<section class="video-subpage" data-video-subpage="video-show-detail"', "</section>")
|
||||
# Hero + season tree containers the renderer fills.
|
||||
for hook in ('data-vd-backdrop', 'data-vd-poster', 'data-vd-title', 'data-vd-badges',
|
||||
'data-vd-overview', 'data-vd-stats', 'data-vd-seasons'):
|
||||
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
|
||||
assert "onclick" not in block
|
||||
|
||||
|
||||
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")
|
||||
assert "(function" in src and "})();" in src
|
||||
assert "window." not in src # declares no globals
|
||||
assert "/api/video/detail/" in src # video API only
|
||||
assert "/api/enrichment/" not in src and "artist-detail" not in src
|
||||
assert "soulsync:video-open-detail" in src # opened via the shared event
|
||||
|
||||
|
||||
def test_library_cards_open_detail():
|
||||
src = _LIB_JS
|
||||
assert "soulsync:video-open-detail" in src # show cards drill in
|
||||
assert "data-video-card-open" in src
|
||||
|
||||
|
||||
def test_video_side_registers_detail_pages_and_open_event():
|
||||
assert "video-show-detail" in _JS and "video-movie-detail" in _JS
|
||||
assert "soulsync:video-open-detail" in _JS # navigates on the event
|
||||
|
||||
|
||||
def test_music_worker_orbs_untouched_by_video():
|
||||
# The video orbs are a separate file; the music orbs must not learn about
|
||||
# the video side (one-way isolation — music never depends on video).
|
||||
|
|
|
|||
|
|
@ -812,6 +812,34 @@
|
|||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- TV-show detail (drill-in from a show card; not a nav page).
|
||||
Isolated: built by video/video-detail.js, styled by .vd-* in
|
||||
video-side.css. Inspired by the music artist-detail vibe. -->
|
||||
<section class="video-subpage" data-video-subpage="video-show-detail" hidden>
|
||||
<div class="vd-page" data-video-detail="show">
|
||||
<div class="vd-backdrop" data-vd-backdrop aria-hidden="true"></div>
|
||||
<div class="vd-scrim" aria-hidden="true"></div>
|
||||
<div class="vd-content">
|
||||
<button class="vd-back" type="button" data-video-goto="video-library">
|
||||
<span aria-hidden="true">←</span> Library
|
||||
</button>
|
||||
<div class="vd-hero">
|
||||
<div class="vd-poster">
|
||||
<img data-vd-poster alt="" />
|
||||
<div class="vd-poster-fallback" data-vd-poster-fallback>📺</div>
|
||||
</div>
|
||||
<div class="vd-hero-main">
|
||||
<h1 class="vd-title" data-vd-title>—</h1>
|
||||
<div class="vd-badges" data-vd-badges></div>
|
||||
<p class="vd-overview" data-vd-overview></p>
|
||||
<div class="vd-stats" data-vd-stats></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="vd-loading" data-vd-loading hidden>Loading…</div>
|
||||
<div class="vd-seasons" data-vd-seasons></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="video-subpage" data-video-subpage="video-tools" hidden>
|
||||
<div class="page-shell tools-page-container">
|
||||
<div class="dashboard-header"><div class="dashboard-header-sweep" aria-hidden="true"><span></span></div>
|
||||
|
|
@ -9067,6 +9095,8 @@
|
|||
<script src="{{ url_for('static', filename='video/video-dashboard.js', v=static_v) }}"></script>
|
||||
<!-- Video library page (isolated; lists movies/shows + scan trigger) -->
|
||||
<script src="{{ url_for('static', filename='video/video-library.js', v=static_v) }}"></script>
|
||||
<!-- Video detail page (isolated; show drill-in: hero + season/episode tree) -->
|
||||
<script src="{{ url_for('static', filename='video/video-detail.js', v=static_v) }}"></script>
|
||||
<!-- Video worker orbs (isolated copy of the music orbs; idles into the
|
||||
floating-orb animation around the SoulSync logo on the video dashboard) -->
|
||||
<script src="{{ url_for('static', filename='video/video-worker-orbs.js', v=static_v) }}"></script>
|
||||
|
|
|
|||
183
webui/static/video/video-detail.js
Normal file
183
webui/static/video/video-detail.js
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
/*
|
||||
* SoulSync — Video detail page (isolated).
|
||||
*
|
||||
* Drill-in TV-show detail: hero (backdrop + poster + title + badges + stats) and
|
||||
* the seasons → episodes tree (season = album, episode = track — inspired by the
|
||||
* music artist page). Opened by a card via the soulsync:video-open-detail event;
|
||||
* video-side.js handles the navigation, this loads + renders the data.
|
||||
*
|
||||
* Self-contained IIFE, no globals, event-delegated, no inline handlers. Talks
|
||||
* only to /api/video/* — the music side is never touched.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var DETAIL_URL = '/api/video/detail/';
|
||||
var loaded = { show: null }; // remember the last show id we rendered
|
||||
|
||||
function esc(s) {
|
||||
return String(s == null ? '' : s)
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function root() { return document.querySelector('[data-video-detail="show"]'); }
|
||||
function q(sel) { var r = root(); return r ? r.querySelector(sel) : null; }
|
||||
|
||||
function setText(sel, text) { var n = q(sel); if (n) n.textContent = text || ''; }
|
||||
|
||||
function pill(label, cls) {
|
||||
return '<span class="vd-pill' + (cls ? ' ' + cls : '') + '">' + esc(label) + '</span>';
|
||||
}
|
||||
|
||||
function runtimeLabel(mins) {
|
||||
if (!mins) return '';
|
||||
var h = Math.floor(mins / 60), m = mins % 60;
|
||||
return h ? (h + 'h' + (m ? ' ' + m + 'm' : '')) : (m + 'm');
|
||||
}
|
||||
|
||||
// ── hero ────────────────────────────────────────────────────────────────
|
||||
function renderHero(d) {
|
||||
setText('[data-vd-title]', d.title);
|
||||
setText('[data-vd-overview]', d.overview);
|
||||
|
||||
var backdrop = q('[data-vd-backdrop]');
|
||||
if (backdrop) {
|
||||
backdrop.style.backgroundImage = d.has_backdrop
|
||||
? "url('/api/video/backdrop/show/" + d.id + "')"
|
||||
: (d.has_poster ? "url('/api/video/poster/show/" + d.id + "')" : '');
|
||||
backdrop.classList.toggle('vd-backdrop--empty', !d.has_backdrop && !d.has_poster);
|
||||
}
|
||||
|
||||
var poster = q('[data-vd-poster]');
|
||||
var fallback = q('[data-vd-poster-fallback]');
|
||||
if (poster && fallback) {
|
||||
if (d.has_poster) {
|
||||
poster.src = '/api/video/poster/show/' + d.id;
|
||||
poster.style.display = '';
|
||||
fallback.style.display = 'none';
|
||||
poster.onerror = function () { poster.style.display = 'none'; fallback.style.display = ''; };
|
||||
} else {
|
||||
poster.style.display = 'none';
|
||||
fallback.style.display = '';
|
||||
}
|
||||
}
|
||||
|
||||
var badges = [];
|
||||
if (d.year) badges.push(pill(d.year));
|
||||
if (d.content_rating) badges.push(pill(d.content_rating, 'vd-pill--rating'));
|
||||
if (d.status) badges.push(pill(d.status === 'continuing' ? 'Continuing'
|
||||
: d.status === 'ended' ? 'Ended' : d.status));
|
||||
if (d.network) badges.push(pill(d.network));
|
||||
var rt = runtimeLabel(d.runtime_minutes);
|
||||
if (rt) badges.push(pill(rt));
|
||||
var b = q('[data-vd-badges]');
|
||||
if (b) b.innerHTML = badges.join('');
|
||||
|
||||
// Stat tiles — seasons / episodes / owned coverage.
|
||||
var ownedPct = d.episode_total ? Math.round(d.episode_owned / d.episode_total * 100) : 0;
|
||||
var stats = [
|
||||
['Seasons', d.season_count],
|
||||
['Episodes', d.episode_total],
|
||||
['Owned', d.episode_owned + ' / ' + d.episode_total],
|
||||
['Collected', ownedPct + '%'],
|
||||
];
|
||||
var s = q('[data-vd-stats]');
|
||||
if (s) {
|
||||
s.innerHTML = stats.map(function (st) {
|
||||
return '<div class="vd-stat"><span class="vd-stat-num">' + esc(st[1]) +
|
||||
'</span><span class="vd-stat-label">' + esc(st[0]) + '</span></div>';
|
||||
}).join('');
|
||||
}
|
||||
}
|
||||
|
||||
// ── seasons → episodes tree ───────────────────────────────────────────────
|
||||
function episodeRow(ep) {
|
||||
var num = ep.episode_number != null ? ('E' + ep.episode_number) : '';
|
||||
var owned = ep.owned ? 'vd-ep--owned' : 'vd-ep--missing';
|
||||
var meta = [];
|
||||
if (ep.air_date) meta.push(ep.air_date);
|
||||
var rt = runtimeLabel(ep.runtime_minutes);
|
||||
if (rt) meta.push(rt);
|
||||
return '<div class="vd-ep ' + owned + '">' +
|
||||
'<span class="vd-ep-num">' + esc(num) + '</span>' +
|
||||
'<span class="vd-ep-body"><span class="vd-ep-title">' + esc(ep.title || 'Episode ' + ep.episode_number) + '</span>' +
|
||||
(meta.length ? '<span class="vd-ep-meta">' + esc(meta.join(' · ')) + '</span>' : '') +
|
||||
'</span>' +
|
||||
'<span class="vd-ep-state">' + (ep.owned ? 'Owned' : 'Missing') + '</span>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function seasonBlock(season, idx) {
|
||||
var pct = season.episode_total ? Math.round(season.episode_owned / season.episode_total * 100) : 0;
|
||||
var open = idx === 0 ? ' vd-season--open' : '';
|
||||
return '<div class="vd-season' + open + '" data-vd-season="' + season.season_number + '">' +
|
||||
'<button class="vd-season-head" type="button" data-vd-season-toggle>' +
|
||||
'<span class="vd-season-name">' + esc(season.title) + '</span>' +
|
||||
'<span class="vd-season-meta">' + season.episode_owned + ' / ' + season.episode_total + ' · ' + pct + '%</span>' +
|
||||
'<span class="vd-season-bar"><span class="vd-season-bar-fill" style="width:' + pct + '%"></span></span>' +
|
||||
'<span class="vd-season-caret" aria-hidden="true">▾</span>' +
|
||||
'</button>' +
|
||||
'<div class="vd-season-eps">' + season.episodes.map(episodeRow).join('') + '</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function renderSeasons(d) {
|
||||
var host = q('[data-vd-seasons]');
|
||||
if (!host) return;
|
||||
if (!d.seasons || !d.seasons.length) {
|
||||
host.innerHTML = '<div class="vd-empty">No seasons found for this show yet.</div>';
|
||||
return;
|
||||
}
|
||||
host.innerHTML = d.seasons.map(seasonBlock).join('');
|
||||
}
|
||||
|
||||
function showLoading(on) {
|
||||
var l = q('[data-vd-loading]');
|
||||
if (l) l.hidden = !on;
|
||||
}
|
||||
|
||||
function loadShow(id) {
|
||||
if (!root()) return;
|
||||
loaded.show = id;
|
||||
showLoading(true);
|
||||
var seasons = q('[data-vd-seasons]');
|
||||
if (seasons) seasons.innerHTML = '';
|
||||
fetch(DETAIL_URL + 'show/' + id, { headers: { 'Accept': 'application/json' } })
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (d) {
|
||||
showLoading(false);
|
||||
if (!d || d.error) { setText('[data-vd-title]', 'Not found'); return; }
|
||||
renderHero(d);
|
||||
renderSeasons(d);
|
||||
})
|
||||
.catch(function () { showLoading(false); setText('[data-vd-title]', 'Could not load show'); });
|
||||
}
|
||||
|
||||
// ── events ────────────────────────────────────────────────────────────────
|
||||
function onOpen(e) {
|
||||
if (!e || !e.detail || e.detail.kind !== 'show') return;
|
||||
loadShow(e.detail.id);
|
||||
}
|
||||
|
||||
function onClick(e) {
|
||||
var r = root();
|
||||
if (!r) return;
|
||||
var toggle = e.target.closest('[data-vd-season-toggle]');
|
||||
if (toggle && r.contains(toggle)) {
|
||||
var block = toggle.closest('.vd-season');
|
||||
if (block) block.classList.toggle('vd-season--open');
|
||||
}
|
||||
}
|
||||
|
||||
function init() {
|
||||
document.addEventListener('soulsync:video-open-detail', onOpen);
|
||||
document.addEventListener('click', onClick);
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
|
|
@ -76,7 +76,12 @@
|
|||
if (kind === 'movie') meta.push(it.has_file ? 'Owned' : 'Wanted');
|
||||
else meta.push((it.owned_count || 0) + '/' + (it.episode_count || 0) + ' eps');
|
||||
|
||||
return '<div class="library-artist-card">' + img + badge +
|
||||
// Shows drill into the detail page; movies aren't clickable until the
|
||||
// movie-detail page lands (then this opens kind === 'movie' too).
|
||||
var clickable = kind === 'show' ? ' video-card--clickable' : '';
|
||||
var hook = kind === 'show'
|
||||
? ' data-video-card-open="show" data-video-card-id="' + it.id + '"' : '';
|
||||
return '<div class="library-artist-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">' +
|
||||
|
|
@ -138,6 +143,18 @@
|
|||
function reload() { state.page = 1; load(); }
|
||||
|
||||
function wire() {
|
||||
// Card click → drill into the detail page (event-delegated on the grid).
|
||||
var grid = $('[data-video-lib-grid]');
|
||||
if (grid) {
|
||||
grid.addEventListener('click', function (e) {
|
||||
var card = e.target.closest('[data-video-card-open]');
|
||||
if (!card || !grid.contains(card)) return;
|
||||
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) },
|
||||
}));
|
||||
});
|
||||
}
|
||||
var tabs = document.querySelectorAll('[data-video-lib-tab]');
|
||||
for (var i = 0; i < tabs.length; i++) {
|
||||
(function (tab) {
|
||||
|
|
|
|||
|
|
@ -437,3 +437,109 @@ body[data-side="video"] .dashboard-header-sweep {
|
|||
border-radius: 5px;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
/* ── Video detail page (TV-show drill-in; isolated .vd-* — inspired by the
|
||||
music artist-detail vibe: full-bleed backdrop, glass panels, accent). ─────── */
|
||||
.vd-page { position: relative; min-height: 100%; }
|
||||
|
||||
/* Full-bleed hero artwork, gently blurred, fading into the page. */
|
||||
.vd-backdrop {
|
||||
position: absolute; top: 0; left: 0; right: 0; height: 460px;
|
||||
background-size: cover; background-position: center 18%;
|
||||
filter: saturate(1.15);
|
||||
opacity: 0.55;
|
||||
transform: scale(1.04);
|
||||
}
|
||||
.vd-backdrop--empty { background: radial-gradient(120% 80% at 50% 0%, rgba(var(--accent-rgb, 88, 101, 242), 0.25), transparent 60%); }
|
||||
.vd-scrim {
|
||||
position: absolute; top: 0; left: 0; right: 0; height: 480px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(10,10,14,0.35) 0%, rgba(10,10,14,0.75) 55%, var(--bg-primary, #0c0c10) 100%),
|
||||
linear-gradient(90deg, rgba(10,10,14,0.7) 0%, transparent 55%);
|
||||
}
|
||||
.vd-content { position: relative; z-index: 1; padding: 22px 30px 60px; max-width: 1180px; margin: 0 auto; }
|
||||
|
||||
.vd-back {
|
||||
display: inline-flex; align-items: center; gap: 7px;
|
||||
padding: 8px 15px; margin-bottom: 26px;
|
||||
background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.12);
|
||||
border-radius: 999px; color: var(--text-primary, #e8e8ea);
|
||||
font-size: 13px; font-weight: 600; cursor: pointer;
|
||||
backdrop-filter: blur(8px); transition: all 0.2s ease;
|
||||
}
|
||||
.vd-back:hover { background: rgba(var(--accent-rgb, 88,101,242), 0.18); border-color: rgba(var(--accent-rgb, 88,101,242), 0.5); transform: translateX(-2px); }
|
||||
|
||||
/* Hero */
|
||||
.vd-hero { display: flex; gap: 28px; align-items: flex-end; }
|
||||
.vd-poster {
|
||||
position: relative; flex: 0 0 210px; width: 210px; aspect-ratio: 2 / 3;
|
||||
border-radius: 14px; overflow: hidden;
|
||||
box-shadow: 0 18px 50px rgba(0,0,0,0.6), 0 0 0 1px rgba(255,255,255,0.08);
|
||||
background: rgba(255,255,255,0.05);
|
||||
}
|
||||
.vd-poster img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.vd-poster-fallback { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 54px; opacity: 0.5; }
|
||||
|
||||
.vd-hero-main { flex: 1; min-width: 0; padding-bottom: 6px; }
|
||||
.vd-title { font-size: 40px; line-height: 1.05; font-weight: 800; margin: 0 0 12px; letter-spacing: -0.5px;
|
||||
text-shadow: 0 2px 18px rgba(0,0,0,0.5); }
|
||||
.vd-badges { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 14px; }
|
||||
.vd-pill {
|
||||
padding: 4px 11px; border-radius: 999px; font-size: 12px; font-weight: 600;
|
||||
background: rgba(255,255,255,0.10); border: 1px solid rgba(255,255,255,0.14);
|
||||
color: var(--text-primary, #e8e8ea); backdrop-filter: blur(6px);
|
||||
}
|
||||
.vd-pill--rating { border-color: rgba(var(--accent-rgb, 88,101,242), 0.55); color: #fff; }
|
||||
.vd-overview { max-width: 720px; color: rgba(255,255,255,0.78); font-size: 14.5px; line-height: 1.6; margin: 0 0 18px;
|
||||
display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
|
||||
.vd-stats { display: flex; flex-wrap: wrap; gap: 12px; }
|
||||
.vd-stat {
|
||||
min-width: 92px; padding: 12px 16px; border-radius: 12px;
|
||||
background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.10);
|
||||
backdrop-filter: blur(10px) saturate(1.2);
|
||||
display: flex; flex-direction: column; gap: 2px;
|
||||
}
|
||||
.vd-stat-num { font-size: 20px; font-weight: 800; color: #fff; }
|
||||
.vd-stat-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.6px; color: rgba(255,255,255,0.5); }
|
||||
|
||||
.vd-loading, .vd-empty { padding: 40px 0; text-align: center; color: rgba(255,255,255,0.55); font-size: 14px; }
|
||||
|
||||
/* Seasons → episodes tree */
|
||||
.vd-seasons { margin-top: 34px; display: flex; flex-direction: column; gap: 12px; }
|
||||
.vd-season {
|
||||
border-radius: 14px; overflow: hidden;
|
||||
background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08);
|
||||
}
|
||||
.vd-season-head {
|
||||
width: 100%; display: grid; grid-template-columns: 1fr auto 180px auto; align-items: center; gap: 16px;
|
||||
padding: 15px 18px; background: transparent; border: 0; cursor: pointer;
|
||||
color: var(--text-primary, #e8e8ea); text-align: left; transition: background 0.2s ease;
|
||||
}
|
||||
.vd-season-head:hover { background: rgba(255,255,255,0.04); }
|
||||
.vd-season-name { font-size: 16px; font-weight: 700; }
|
||||
.vd-season-meta { font-size: 12.5px; color: rgba(255,255,255,0.6); white-space: nowrap; }
|
||||
.vd-season-bar { height: 6px; border-radius: 999px; background: rgba(255,255,255,0.10); overflow: hidden; }
|
||||
.vd-season-bar-fill { display: block; height: 100%; border-radius: 999px;
|
||||
background: linear-gradient(90deg, rgba(var(--accent-rgb, 88,101,242), 0.9), rgba(var(--accent-rgb, 88,101,242), 0.55)); }
|
||||
.vd-season-caret { transition: transform 0.25s ease; opacity: 0.6; font-size: 12px; }
|
||||
.vd-season--open .vd-season-caret { transform: rotate(180deg); }
|
||||
|
||||
.vd-season-eps { display: none; padding: 4px 10px 10px; }
|
||||
.vd-season--open .vd-season-eps { display: block; }
|
||||
.vd-ep {
|
||||
display: grid; grid-template-columns: 44px 1fr auto; align-items: center; gap: 12px;
|
||||
padding: 10px 12px; border-radius: 10px; transition: background 0.15s ease;
|
||||
}
|
||||
.vd-ep:hover { background: rgba(255,255,255,0.04); }
|
||||
.vd-ep-num { font-size: 13px; font-weight: 700; color: rgba(255,255,255,0.45); text-align: center; }
|
||||
.vd-ep-body { min-width: 0; display: flex; flex-direction: column; gap: 2px; }
|
||||
.vd-ep-title { font-size: 14px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.vd-ep-meta { font-size: 11.5px; color: rgba(255,255,255,0.5); }
|
||||
.vd-ep-state { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; padding: 3px 9px; border-radius: 999px; }
|
||||
.vd-ep--owned .vd-ep-state { color: #4ade80; background: rgba(74,222,128,0.12); border: 1px solid rgba(74,222,128,0.3); }
|
||||
.vd-ep--missing .vd-ep-state { color: rgba(255,255,255,0.45); background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); }
|
||||
.vd-ep--missing .vd-ep-num, .vd-ep--missing .vd-ep-title { opacity: 0.7; }
|
||||
|
||||
/* Make show cards feel clickable. */
|
||||
.video-card--clickable { cursor: pointer; }
|
||||
|
|
|
|||
|
|
@ -38,6 +38,9 @@
|
|||
{ id: 'video-settings', label: 'Settings' },
|
||||
{ id: 'video-issues', label: 'Issues', shared: true },
|
||||
{ id: 'video-help', label: 'Help & Docs', shared: true },
|
||||
// Drill-in detail pages — reachable from cards, not the sidebar nav.
|
||||
{ id: 'video-show-detail', label: 'Show' },
|
||||
{ id: 'video-movie-detail', label: 'Movie' },
|
||||
];
|
||||
|
||||
// "Shared" video pages reuse the REAL music page (shown identically on the
|
||||
|
|
@ -181,6 +184,15 @@
|
|||
})(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.
|
||||
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 defaultNav = document.querySelector(
|
||||
'.video-nav .nav-button[data-video-page="' + DEFAULT_VIDEO_PAGE + '"]');
|
||||
if (defaultNav) defaultNav.classList.add('active');
|
||||
|
|
|
|||
Loading…
Reference in a new issue