diff --git a/tests/test_video_person_watchlist.py b/tests/test_video_person_watchlist.py new file mode 100644 index 00000000..45871483 --- /dev/null +++ b/tests/test_video_person_watchlist.py @@ -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] diff --git a/webui/index.html b/webui/index.html index dafca2e7..1f3e2fbf 100644 --- a/webui/index.html +++ b/webui/index.html @@ -1621,6 +1621,7 @@

+
diff --git a/webui/static/video/video-person.js b/webui/static/video/video-person.js index 27dfd971..1a41e2c9 100644 --- a/webui/static/video/video-person.js +++ b/webui/static/video/video-person.js @@ -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 = + ''; + // 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; diff --git a/webui/static/video/video-side.css b/webui/static/video/video-side.css index 98a1e21e..ccd8e5fb 100644 --- a/webui/static/video/video-side.css +++ b/webui/static/video/video-side.css @@ -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; }