video: add 'Add to Watchlist' button to person detail pages
Person follows are already supported (video_watchlist kind='person', add/remove/check API, and the button shows on person CARDS) — but the person DETAIL page had no way to follow or see if a person is followed. Added the standard watchlist button to the person hero: renderWatchlist() builds it + lazily checks the followed state, toggleWatch() adds/removes via the person-kind watchlist API, wired through the page's delegated click handler. Same chrome as the movie/show pages. 4 wiring tests; node --check clean.
This commit is contained in:
parent
b23a3e91d8
commit
7061001f66
4 changed files with 101 additions and 0 deletions
45
tests/test_video_person_watchlist.py
Normal file
45
tests/test_video_person_watchlist.py
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
"""Person detail page gets the standard 'Add to Watchlist' button (the one already
|
||||
on movie/show pages and person CARDS). Person follows are stored kind='person' in
|
||||
video_watchlist, and the API supports add/remove/check for persons — the page just
|
||||
needed the button wired.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
_ROOT = Path(__file__).resolve().parent.parent
|
||||
_JS = (_ROOT / "webui" / "static" / "video" / "video-person.js").read_text(encoding="utf-8")
|
||||
_INDEX = (_ROOT / "webui" / "index.html").read_text(encoding="utf-8")
|
||||
_CSS = (_ROOT / "webui" / "static" / "video" / "video-side.css").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_person_hero_has_actions_slot():
|
||||
# a dedicated slot in the person hero (managed by JS, like the movie/show pages)
|
||||
assert "data-vp-actions" in _INDEX
|
||||
assert ".vp-actions" in _CSS
|
||||
|
||||
|
||||
def test_person_page_renders_and_toggles_watchlist():
|
||||
assert "function renderWatchlist(" in _JS
|
||||
assert "function toggleWatch(" in _JS
|
||||
# reuses the shared watchlist button chrome
|
||||
assert "library-artist-watchlist-btn" in _JS
|
||||
assert "data-vp-watch" in _JS
|
||||
# follows the person via the person-kind watchlist API
|
||||
assert "kind: 'person'" in _JS
|
||||
assert "/api/video/watchlist/add" in _JS
|
||||
assert "/api/video/watchlist/remove" in _JS
|
||||
assert "/api/video/watchlist/check" in _JS
|
||||
|
||||
|
||||
def test_watch_button_click_is_wired():
|
||||
assert "closest('[data-vp-watch]')" in _JS
|
||||
assert "toggleWatch()" in _JS
|
||||
|
||||
|
||||
def test_render_hydrates_the_watch_button():
|
||||
# render(d) must build the button so it shows on every person page
|
||||
i = _JS.index("function render(")
|
||||
j = _JS.index("function load(", i)
|
||||
assert "renderWatchlist(d)" in _JS[i:j]
|
||||
|
|
@ -1621,6 +1621,7 @@
|
|||
<div class="vp-role" data-vp-role hidden></div>
|
||||
<h1 class="vp-name" data-vp-name></h1>
|
||||
<div class="vp-meta" data-vp-meta></div>
|
||||
<div class="vp-actions" data-vp-actions></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="vp-body">
|
||||
|
|
|
|||
|
|
@ -301,6 +301,7 @@
|
|||
var bio = q('[data-vp-bio]'), more = q('[data-vp-bio-more]');
|
||||
if (bio) { bio.textContent = d.biography || ''; bio.hidden = !d.biography; bio.classList.remove('vp-bio--open'); }
|
||||
if (more) { more.hidden = !((d.biography || '').length > 320); more.textContent = 'Read more'; }
|
||||
renderWatchlist(d);
|
||||
renderPhotos(d.photos);
|
||||
|
||||
applyFilters();
|
||||
|
|
@ -308,6 +309,54 @@
|
|||
if (sub) sub.scrollTop = 0;
|
||||
}
|
||||
|
||||
// ── watchlist (follow a person — same button as the movie/show pages) ──────
|
||||
// persons are tmdb-only, so currentId (the navigation id) IS the tmdb person id.
|
||||
function pid() { return currentId || (data && data.id); }
|
||||
function renderWatchlist(d) {
|
||||
var host = q('[data-vp-actions]'); if (!host) return;
|
||||
var on = !!d._vw_watched;
|
||||
host.innerHTML =
|
||||
'<button class="library-artist-watchlist-btn' + (on ? ' watching' : '') + '" type="button" data-vp-watch>' +
|
||||
'<span class="watchlist-icon">' + (on ? '✓' : '+') + '</span>' +
|
||||
'<span class="watchlist-text">' + (on ? 'In Watchlist' : 'Watchlist') + '</span></button>';
|
||||
// Resolve the real watched state once (lazy), then re-render the button.
|
||||
var id = pid();
|
||||
if (!d._vw_checked && id) {
|
||||
d._vw_checked = true;
|
||||
fetch('/api/video/watchlist/check', { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ kind: 'person', tmdb_ids: [id] }) })
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (res) {
|
||||
if (res && res.results && data === d) {
|
||||
d._vw_watched = !!res.results[String(id)];
|
||||
renderWatchlist(d);
|
||||
}
|
||||
}).catch(function () { /* keep default (off) */ });
|
||||
}
|
||||
}
|
||||
function toggleWatch() {
|
||||
var d = data; if (!d) return;
|
||||
var id = pid(); if (!id) return;
|
||||
var on = !!d._vw_watched;
|
||||
var url = on ? '/api/video/watchlist/remove' : '/api/video/watchlist/add';
|
||||
var body = on ? { kind: 'person', tmdb_id: id }
|
||||
: { kind: 'person', tmdb_id: id, title: d.name, poster_url: d.photo || null };
|
||||
fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (res) {
|
||||
if (!res || res.success === false) {
|
||||
if (typeof showToast === 'function') showToast('Watchlist update failed', 'error');
|
||||
return;
|
||||
}
|
||||
d._vw_watched = !on;
|
||||
renderWatchlist(d);
|
||||
if (typeof showToast === 'function')
|
||||
showToast(!on ? 'Added to watchlist' : 'Removed from watchlist', !on ? 'success' : 'info');
|
||||
document.dispatchEvent(new CustomEvent('soulsync:video-watchlist-changed',
|
||||
{ detail: { kind: 'person', id: String(id), watched: !on } }));
|
||||
}).catch(function () { if (typeof showToast === 'function') showToast('Watchlist update failed', 'error'); });
|
||||
}
|
||||
|
||||
function load(id) {
|
||||
if (!root()) return;
|
||||
currentId = id;
|
||||
|
|
@ -344,6 +393,8 @@
|
|||
|
||||
function onClick(e) {
|
||||
var r = root(); if (!r) return;
|
||||
var watchBtn = e.target.closest('[data-vp-watch]');
|
||||
if (watchBtn && r.contains(watchBtn)) { toggleWatch(); return; }
|
||||
var kindBtn = e.target.closest('[data-vp-tab]');
|
||||
if (kindBtn && r.contains(kindBtn)) {
|
||||
tab = kindBtn.getAttribute('data-vp-tab'); applyFilters(); return;
|
||||
|
|
|
|||
|
|
@ -1078,6 +1078,10 @@ a.vd-prov:hover img, a.vd-prov:hover .vd-prov-ph { border-color: rgba(var(--vd-a
|
|||
padding: 5px 12px; border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.08); border: 1px solid rgba(255, 255, 255, 0.14); backdrop-filter: blur(6px);
|
||||
}
|
||||
/* watchlist (follow person) button row — reuses the shared .library-artist-watchlist-btn */
|
||||
.vp-actions { margin-top: 14px; }
|
||||
.vp-actions:empty { display: none; }
|
||||
@media (max-width: 720px) { .vp-actions { display: flex; justify-content: center; } }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.vp-photo-wrap { animation: none; }
|
||||
.vp-photo-ring { animation: none; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue