Wishlist TV nebula: cinematic expand + season tags + rich tracks + sort
Next-level pass for the video nebula, ALL scoped under a video-only .vwsh-nebula class so the music wishlist's global wl-* styling is untouched (verified: no bare .wl-* rules in the video CSS). 1. Cinematic expand — an open orb bleeds the show's poster as a blurred, hue- tinted backdrop behind the season fan + glows the panel in the show's hue. 2. Season tags — each season tile stamps a bold 'S2' over its art so seasons read distinctly instead of identical posters. 3. Richer episode tracks — every episode line gets a colored status dot (wanted/searching/downloading/done/failed) + its air date. 4. Sort + count — a Recently added / Most wanted / A–Z sort (query_wishlist gains a sort param) and a live 'N shows · M episodes' subheader. Tests: +1 (sort ordering). Backend 101 passed. Movies tab + music side untouched.
This commit is contained in:
parent
d90ae493dd
commit
599f36fdf3
6 changed files with 109 additions and 21 deletions
|
|
@ -39,7 +39,7 @@ def register_routes(bp):
|
|||
kind = request.args.get("kind")
|
||||
if kind in _KINDS:
|
||||
res = db.query_wishlist(
|
||||
kind, search=request.args.get("search", ""),
|
||||
kind, search=request.args.get("search", ""), sort=request.args.get("sort", "added"),
|
||||
page=request.args.get("page", 1), limit=request.args.get("limit", 60))
|
||||
return jsonify({"success": True, "kind": kind, "counts": counts, **res})
|
||||
return jsonify({"success": True, "counts": counts})
|
||||
|
|
|
|||
|
|
@ -1578,9 +1578,10 @@ class VideoDatabase:
|
|||
finally:
|
||||
conn.close()
|
||||
|
||||
def query_wishlist(self, kind: str, *, search=None, page=1, limit=60) -> dict:
|
||||
def query_wishlist(self, kind: str, *, search=None, sort="added", page=1, limit=60) -> dict:
|
||||
"""One paged slice of the wishlist. kind='movie' → movie cards; kind='show'
|
||||
→ shows grouped show→season→episode with wanted/done roll-ups. {items,
|
||||
→ shows grouped show→season→episode with wanted/done roll-ups. ``sort`` ∈
|
||||
added | title | wanted (wanted = most episodes, shows only). {items,
|
||||
pagination} like the other paged queries."""
|
||||
try:
|
||||
page = max(1, int(page or 1))
|
||||
|
|
@ -1595,10 +1596,12 @@ class VideoDatabase:
|
|||
if s:
|
||||
where.append("title LIKE ? COLLATE NOCASE"); args.append("%" + s + "%")
|
||||
wsql = " WHERE " + " AND ".join(where)
|
||||
order = {"title": "title COLLATE NOCASE",
|
||||
"added": "date_added DESC, id DESC"}.get(sort, "date_added DESC, id DESC")
|
||||
total = conn.execute("SELECT COUNT(*) c FROM video_wishlist" + wsql, args).fetchone()["c"]
|
||||
rows = conn.execute(
|
||||
"SELECT tmdb_id, title, poster_url, year, status, library_id, date_added "
|
||||
"FROM video_wishlist" + wsql + " ORDER BY date_added DESC, id DESC LIMIT ? OFFSET ?",
|
||||
"FROM video_wishlist" + wsql + " ORDER BY " + order + " LIMIT ? OFFSET ?",
|
||||
args + [limit, (page - 1) * limit]).fetchall()
|
||||
items = [{"kind": "movie", "tmdb_id": r["tmdb_id"], "title": r["title"],
|
||||
"poster_url": r["poster_url"], "year": r["year"], "status": r["status"],
|
||||
|
|
@ -1610,13 +1613,15 @@ class VideoDatabase:
|
|||
wsql = " WHERE " + " AND ".join(where)
|
||||
total = conn.execute(
|
||||
"SELECT COUNT(DISTINCT tmdb_id) c FROM video_wishlist" + wsql, args).fetchone()["c"]
|
||||
order = {"title": "title COLLATE NOCASE", "wanted": "wanted DESC, last_added DESC",
|
||||
"added": "last_added DESC"}.get(sort, "last_added DESC")
|
||||
show_rows = conn.execute(
|
||||
"SELECT tmdb_id, MAX(title) AS title, MAX(poster_url) AS poster_url, "
|
||||
"MAX(library_id) AS library_id, COUNT(*) AS wanted, "
|
||||
"SUM(CASE WHEN status='downloaded' THEN 1 ELSE 0 END) AS done, "
|
||||
"MAX(date_added) AS last_added "
|
||||
"FROM video_wishlist" + wsql +
|
||||
" GROUP BY tmdb_id ORDER BY last_added DESC LIMIT ? OFFSET ?",
|
||||
" GROUP BY tmdb_id ORDER BY " + order + " LIMIT ? OFFSET ?",
|
||||
args + [limit, (page - 1) * limit]).fetchall()
|
||||
items = []
|
||||
for sr in show_rows:
|
||||
|
|
|
|||
|
|
@ -930,3 +930,18 @@ def test_wishlist_keys_for_shows(db):
|
|||
keys = db.wishlist_keys_for_shows([1396, 1399, 9999])
|
||||
assert keys[1396] == {"1_1", "2_3"} and keys[1399] == {"1_1"} and 9999 not in keys
|
||||
assert db.wishlist_keys_for_shows([]) == {}
|
||||
|
||||
|
||||
def test_wishlist_query_sort(db):
|
||||
db.add_episodes_to_wishlist(1, "Alpha", [{"season_number": 1, "episode_number": 1}]) # 1 ep
|
||||
db.add_episodes_to_wishlist(2, "Zeta", [{"season_number": 1, "episode_number": i} for i in range(5)]) # 5 eps
|
||||
# most-wanted first
|
||||
w = [s["title"] for s in db.query_wishlist("show", sort="wanted")["items"]]
|
||||
assert w[0] == "Zeta"
|
||||
# A–Z
|
||||
az = [s["title"] for s in db.query_wishlist("show", sort="title")["items"]]
|
||||
assert az == ["Alpha", "Zeta"]
|
||||
# movies A–Z
|
||||
db.add_movie_to_wishlist(10, "Banana"); db.add_movie_to_wishlist(11, "Apple")
|
||||
m = [x["title"] for x in db.query_wishlist("movie", sort="title")["items"]]
|
||||
assert m == ["Apple", "Banana"]
|
||||
|
|
|
|||
|
|
@ -1054,7 +1054,7 @@
|
|||
<div class="vwsh-head-row">
|
||||
<div class="vwsh-head-titles">
|
||||
<h1 class="vwsh-title">Wishlist</h1>
|
||||
<p class="vwsh-sub">Movies & episodes you want to grab</p>
|
||||
<p class="vwsh-sub" data-vwsh-sub>Movies & episodes you want to grab</p>
|
||||
</div>
|
||||
<div class="vwsh-tabs" role="tablist">
|
||||
<button class="vwsh-tab vwsh-tab--on" type="button" role="tab" data-vwsh-tab="movie">
|
||||
|
|
@ -1071,6 +1071,11 @@
|
|||
<svg class="vwsh-search-ic" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
|
||||
<input type="text" class="vwsh-search-input" data-vwsh-search placeholder="Search wishlist…" autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
<select class="vwsh-sort" data-vwsh-sort aria-label="Sort">
|
||||
<option value="added">Recently added</option>
|
||||
<option value="wanted">Most wanted</option>
|
||||
<option value="title">A–Z</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="vwsh-body">
|
||||
<div class="vwsh-loading hidden" data-vwsh-loading>
|
||||
|
|
|
|||
|
|
@ -2485,15 +2485,50 @@ body[data-side="video"] #soulsync-toggle { display: none; }
|
|||
.vwsh-movie-title { display: block; font-size: 13.5px; font-weight: 700; color: #fff; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.vwsh-movie-meta { display: block; font-size: 11.5px; color: rgba(255, 255, 255, 0.45); margin-top: 2px; }
|
||||
|
||||
/* shows = the "Nebula" (reuses the music wl-* orb/album/track design).
|
||||
Only the show-remove button is video-specific. */
|
||||
.wl-nebula-field .wl-orb-remove { position: absolute; top: 0; right: 0; z-index: 6; width: 22px; height: 22px;
|
||||
/* shows = the "Nebula" (reuses the music wl-* orb/album/track design). EVERY
|
||||
video enhancement is scoped under .vwsh-nebula (a class only the video field
|
||||
gets) so the music wishlist's wl-* styling is never affected. */
|
||||
.vwsh-nebula .wl-orb-remove { position: absolute; top: 0; right: 0; z-index: 6; width: 22px; height: 22px;
|
||||
border-radius: 50%; border: 1px solid rgba(255, 255, 255, 0.15); background: rgba(0, 0, 0, 0.62);
|
||||
color: rgba(255, 255, 255, 0.7); font-size: 11px; line-height: 1; cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center; opacity: 0; transition: all 0.15s ease; }
|
||||
.wl-nebula-field .wl-orb-group:hover .wl-orb-remove,
|
||||
.wl-nebula-field .wl-orb-group.expanded .wl-orb-remove { opacity: 1; }
|
||||
.wl-nebula-field .wl-orb-remove:hover { background: rgba(239, 68, 68, 0.85); border-color: transparent; color: #fff; }
|
||||
.vwsh-nebula .wl-orb-group:hover .wl-orb-remove,
|
||||
.vwsh-nebula .wl-orb-group.expanded .wl-orb-remove { opacity: 1; }
|
||||
.vwsh-nebula .wl-orb-remove:hover { background: rgba(239, 68, 68, 0.85); border-color: transparent; color: #fff; }
|
||||
|
||||
/* #1 cinematic expand — blurred poster backdrop + hue glow behind the season fan */
|
||||
.vwsh-nebula .wl-orb-group.expanded { overflow: hidden;
|
||||
box-shadow: 0 14px 50px -12px hsla(var(--orb-hue, 230), 70%, 45%, 0.45);
|
||||
border: 1px solid hsla(var(--orb-hue, 230), 70%, 55%, 0.25); }
|
||||
.vwsh-nebula .wl-orb-group.expanded::before { content: ''; position: absolute; inset: 0; z-index: 0; pointer-events: none;
|
||||
background-image: var(--vwsh-poster, none); background-size: cover; background-position: center;
|
||||
filter: blur(28px) saturate(1.25); opacity: 0.4; transform: scale(1.25); }
|
||||
.vwsh-nebula .wl-orb-group.expanded::after { content: ''; position: absolute; inset: 0; z-index: 0; pointer-events: none;
|
||||
background: linear-gradient(180deg, hsla(var(--orb-hue, 230), 55%, 12%, 0.55), rgba(12, 12, 16, 0.92)); }
|
||||
.vwsh-nebula .wl-orb-group.expanded > * { position: relative; z-index: 1; }
|
||||
|
||||
/* #2 season number stamped on the tile art (so seasons read as distinct) */
|
||||
.vwsh-nebula .wl-album-tile-art { position: relative; }
|
||||
.vwsh-nebula .vwsh-season-tag { position: absolute; top: 5px; left: 5px; z-index: 2; font-size: 12px; font-weight: 900;
|
||||
color: #fff; padding: 2px 7px; border-radius: 6px; letter-spacing: 0.02em; backdrop-filter: blur(4px);
|
||||
background: rgba(0, 0, 0, 0.58); border: 1px solid rgba(255, 255, 255, 0.18); text-shadow: 0 1px 3px rgba(0, 0, 0, 0.7); }
|
||||
|
||||
/* #3 richer episode tracks — status dot + air date */
|
||||
.vwsh-nebula .wl-tile-track { gap: 8px; }
|
||||
.vwsh-nebula .vwsh-ep-dot { flex-shrink: 0; width: 7px; height: 7px; border-radius: 50%;
|
||||
background: rgb(var(--accent-rgb, 29, 185, 84)); box-shadow: 0 0 6px rgba(var(--accent-rgb, 29, 185, 84), 0.6); }
|
||||
.vwsh-nebula .vwsh-ep-dot--searching { background: #f59e0b; box-shadow: 0 0 6px rgba(245, 158, 11, 0.6); }
|
||||
.vwsh-nebula .vwsh-ep-dot--downloading { background: #8ab0ff; box-shadow: 0 0 6px rgba(100, 149, 237, 0.6); }
|
||||
.vwsh-nebula .vwsh-ep-dot--downloaded { background: #4ade80; box-shadow: 0 0 6px rgba(34, 197, 94, 0.6); }
|
||||
.vwsh-nebula .vwsh-ep-dot--failed { background: #f87171; box-shadow: 0 0 6px rgba(248, 113, 113, 0.6); }
|
||||
.vwsh-nebula .vwsh-ep-date { flex-shrink: 0; font-size: 9.5px; font-weight: 600; color: rgba(255, 255, 255, 0.35); }
|
||||
|
||||
/* #5 sort select (toolbar) */
|
||||
.vwsh-sort { padding: 11px 14px; border-radius: 12px; background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1); color: #fff; font-size: 13.5px; font-weight: 600;
|
||||
cursor: pointer; outline: none; transition: border-color 0.15s ease; }
|
||||
.vwsh-sort:focus { border-color: rgba(var(--accent-rgb, 29, 185, 84), 0.5); }
|
||||
.vwsh-sort option { background: #16161d; color: #fff; }
|
||||
|
||||
/* status pills + remove button */
|
||||
.vwsh-st { font-size: 10px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.03em; padding: 3px 8px; border-radius: 6px; flex-shrink: 0; }
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@
|
|||
|
||||
var PAGE_ID = 'video-wishlist';
|
||||
var LIMIT = 60;
|
||||
var state = { loaded: false, tab: 'movie', search: '', page: 1, counts: { movie: 0, show: 0 } };
|
||||
var state = { loaded: false, tab: 'movie', search: '', sort: 'added', page: 1,
|
||||
counts: { movie: 0, show: 0, episode: 0 } };
|
||||
var searchTimer = null;
|
||||
|
||||
function $(s, r) { return (r || document).querySelector(s); }
|
||||
|
|
@ -21,6 +22,12 @@
|
|||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
function hueOf(s) { var h = 0, t = String(s || ''); for (var i = 0; i < t.length; i++) h = (h * 31 + t.charCodeAt(i)) >>> 0; return h % 360; }
|
||||
var MO = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
function fmtDate(iso) {
|
||||
var p = String(iso || '').split('-');
|
||||
if (p.length < 3) return '';
|
||||
return MO[(+p[1] || 1) - 1] + ' ' + (+p[2] || 1);
|
||||
}
|
||||
|
||||
var STATUS = {
|
||||
wanted: ['Wanted', 'vwsh-st--wanted'], searching: ['Searching', 'vwsh-st--searching'],
|
||||
|
|
@ -72,13 +79,18 @@
|
|||
: '<div class="wl-orb-initials">' + esc(initials(sh.title)) + '</div>';
|
||||
var tiles = (sh.seasons || []).map(function (se) {
|
||||
var n = se.episodes.length;
|
||||
var art = sh.poster_url
|
||||
? '<div class="wl-album-tile-art"><img src="' + esc(sh.poster_url) + '" alt=""></div>'
|
||||
: '<div class="wl-album-tile-art"><div class="wl-album-tile-fallback">S' + se.season_number + '</div></div>';
|
||||
// #2: stamp the season number over the art so tiles read as distinct seasons
|
||||
var inner = sh.poster_url ? '<img src="' + esc(sh.poster_url) + '" alt="">' : '<div class="wl-album-tile-fallback">📺</div>';
|
||||
var art = '<div class="wl-album-tile-art">' + inner + '<span class="vwsh-season-tag">S' + se.season_number + '</span></div>';
|
||||
var tracks = (se.episodes || []).map(function (e) {
|
||||
var t = e.title || ('Episode ' + e.episode_number);
|
||||
var st = STATUS[e.status] ? e.status : 'wanted';
|
||||
var date = fmtDate(e.air_date);
|
||||
// #3: status dot + air date make the episode line actually informative
|
||||
return '<div class="wl-tile-track">' +
|
||||
'<span class="vwsh-ep-dot vwsh-ep-dot--' + st + '" title="' + STATUS[st][0] + '"></span>' +
|
||||
'<span class="wl-tile-track-name">E' + e.episode_number + ' · ' + esc(t) + '</span>' +
|
||||
(date ? '<span class="vwsh-ep-date">' + esc(date) + '</span>' : '') +
|
||||
'<button class="wl-tile-track-remove" type="button" data-vwsh-rm="episode" ' +
|
||||
'data-tmdb="' + esc(sh.tmdb_id) + '" data-s="' + se.season_number + '" data-e="' + e.episode_number + '" title="Remove">✕</button>' +
|
||||
'</div>';
|
||||
|
|
@ -95,10 +107,14 @@
|
|||
'</div>';
|
||||
}).join('');
|
||||
var eps = total + ' episode' + (total === 1 ? '' : 's');
|
||||
return '<div class="wl-orb-group" data-vwsh-group style="animation-delay:' + Math.min(idx * 45, 700) + 'ms">' +
|
||||
// --orb-hue on the GROUP so the music orb styles + my cinematic-expand
|
||||
// backdrop (--vwsh-poster) both resolve; poster bleeds in only when expanded.
|
||||
var gstyle = 'animation-delay:' + Math.min(idx * 45, 700) + 'ms;--orb-hue:' + hue +
|
||||
(sh.poster_url ? ";--vwsh-poster:url('" + esc(sh.poster_url) + "')" : '');
|
||||
return '<div class="wl-orb-group" data-vwsh-group style="' + gstyle + '">' +
|
||||
'<button class="wl-orb-remove" type="button" data-vwsh-rm="show" data-tmdb="' + esc(sh.tmdb_id) + '" title="Remove show">✕</button>' +
|
||||
'<div class="wl-orb-tooltip">' + esc(sh.title) + '<br><span>' + eps + '</span></div>' +
|
||||
'<div class="wl-orb ' + orbSize(total) + '" style="--orb-hue:' + hue + '" data-vwsh-orb>' +
|
||||
'<div class="wl-orb ' + orbSize(total) + '" data-vwsh-orb>' +
|
||||
'<div class="wl-orb-glow"></div>' + img + '<div class="wl-orb-ring"></div>' +
|
||||
'</div>' +
|
||||
'<div class="wl-orb-label" data-vwsh-open-show data-vwsh-src="' + src + '" data-vwsh-id="' + esc(openId) + '" title="' + esc(sh.title) + '">' + esc(sh.title) + '</div>' +
|
||||
|
|
@ -111,6 +127,7 @@
|
|||
var grid = $('[data-vwsh-grid]'); if (!grid) return;
|
||||
var shows = state.tab === 'show';
|
||||
grid.classList.toggle('wl-nebula-field', shows);
|
||||
grid.classList.toggle('vwsh-nebula', shows); // video-only scope so music wl-* is untouched
|
||||
grid.classList.toggle('vwsh-grid--movies', !shows);
|
||||
grid.innerHTML = shows
|
||||
? items.map(function (sh, i) { return nebulaOrb(sh, i); }).join('')
|
||||
|
|
@ -119,10 +136,19 @@
|
|||
|
||||
// ── counts / badges / pager ───────────────────────────────────────────────
|
||||
function setCounts(counts) {
|
||||
state.counts = { movie: (counts && counts.movie) || 0, show: (counts && counts.show) || 0 };
|
||||
state.counts = { movie: (counts && counts.movie) || 0, show: (counts && counts.show) || 0,
|
||||
episode: (counts && counts.episode) || 0 };
|
||||
var cm = $('[data-vwsh-count-movie]'); if (cm) cm.textContent = state.counts.movie;
|
||||
var cs = $('[data-vwsh-count-show]'); if (cs) cs.textContent = state.counts.show;
|
||||
updateBadges(counts && counts.total != null ? counts.total : (state.counts.movie + state.counts.show));
|
||||
updateBadges(counts && counts.total != null ? counts.total : (state.counts.movie + state.counts.episode));
|
||||
updateSub();
|
||||
}
|
||||
function updateSub() {
|
||||
var el = $('[data-vwsh-sub]'); if (!el) return;
|
||||
var c = state.counts;
|
||||
el.textContent = state.tab === 'show'
|
||||
? c.show + ' show' + (c.show === 1 ? '' : 's') + ' · ' + c.episode + ' episode' + (c.episode === 1 ? '' : 's')
|
||||
: c.movie + ' movie' + (c.movie === 1 ? '' : 's');
|
||||
}
|
||||
function updateBadges(total) {
|
||||
var n = total || 0;
|
||||
|
|
@ -160,7 +186,7 @@
|
|||
function load() {
|
||||
state.loaded = true;
|
||||
var ld = $('[data-vwsh-loading]'); if (ld) ld.classList.remove('hidden');
|
||||
var params = new URLSearchParams({ kind: state.tab, search: state.search, page: state.page, limit: LIMIT });
|
||||
var params = new URLSearchParams({ kind: state.tab, search: state.search, sort: state.sort, page: state.page, limit: LIMIT });
|
||||
fetch('/api/video/wishlist?' + params.toString(), { headers: { Accept: 'application/json' } })
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (d) {
|
||||
|
|
@ -234,6 +260,8 @@
|
|||
if (searchTimer) clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(function () { state.search = search.value.trim(); state.page = 1; load(); }, 250);
|
||||
});
|
||||
var sortSel = $('[data-vwsh-sort]');
|
||||
if (sortSel) sortSel.addEventListener('change', function () { state.sort = sortSel.value; state.page = 1; load(); });
|
||||
var prev = $('[data-vwsh-prev]');
|
||||
if (prev) prev.addEventListener('click', function () { if (state.page > 1) { state.page--; load(); } });
|
||||
var next = $('[data-vwsh-next]');
|
||||
|
|
|
|||
Loading…
Reference in a new issue