Video Calendar: week navigation + owned/missing filter
Finish the TV calendar as a planning tool. - Week nav: ‹ prev / Today / next › steps the 7-day window (endpoint takes ?start=; each window starts on today's weekday so "current day first" holds). Title reflects it (This Week / Next Week / In N weeks); Today highlight only shows when the real today is in view. - Filter: All / In library / Missing, applied client-side (no refetch) — the hero + grid + count all respect it. - Hero eyebrow is context-aware (NEXT UP this week, FEATURED on other weeks).
This commit is contained in:
parent
5856eefd7c
commit
57c5148207
4 changed files with 103 additions and 16 deletions
|
|
@ -7,7 +7,7 @@ via VideoDatabase, writes nothing, never touches the music side.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, timedelta
|
||||
from datetime import date, datetime, timedelta
|
||||
|
||||
from flask import jsonify, request
|
||||
|
||||
|
|
@ -22,8 +22,18 @@ def register_routes(bp):
|
|||
from . import get_video_db
|
||||
try:
|
||||
days = request.args.get("days", default=7, type=int) or 7
|
||||
days = max(1, min(days, 90)) # clamp: today .. +90d
|
||||
start = date.today()
|
||||
days = max(1, min(days, 31)) # one week (or a few) per view
|
||||
today = date.today()
|
||||
# Optional ?start=YYYY-MM-DD for week navigation; default = today.
|
||||
start = today
|
||||
start_s = request.args.get("start")
|
||||
if start_s:
|
||||
try:
|
||||
start = datetime.strptime(start_s, "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
start = today
|
||||
if abs((start - today).days) > 400: # sane bound around today
|
||||
start = today
|
||||
end = start + timedelta(days=days - 1)
|
||||
db = get_video_db()
|
||||
|
||||
|
|
@ -50,8 +60,8 @@ def register_routes(bp):
|
|||
if e.get("has_file"):
|
||||
owned += 1
|
||||
return jsonify({
|
||||
"today": start.isoformat(),
|
||||
"start": start.isoformat(),
|
||||
"today": today.isoformat(), # real today (for the highlight)
|
||||
"start": start.isoformat(), # window start (may be a future week)
|
||||
"end": end.isoformat(),
|
||||
"days": days,
|
||||
"counts_by_date": counts,
|
||||
|
|
|
|||
|
|
@ -869,8 +869,24 @@
|
|||
<div class="vcal-page">
|
||||
<div class="vcal-head">
|
||||
<div class="vcal-head-glow" aria-hidden="true"></div>
|
||||
<h1 class="vcal-title">This Week</h1>
|
||||
<p class="vcal-sub" data-video-cal-sub>Upcoming episodes from your shows</p>
|
||||
<div class="vcal-head-row">
|
||||
<div class="vcal-head-titles">
|
||||
<h1 class="vcal-title" data-video-cal-title>This Week</h1>
|
||||
<p class="vcal-sub" data-video-cal-sub>Upcoming episodes from your shows</p>
|
||||
</div>
|
||||
<div class="vcal-controls">
|
||||
<div class="vcal-nav" role="group" aria-label="Week">
|
||||
<button class="vcal-nav-btn" type="button" data-video-cal-prev aria-label="Previous week">‹</button>
|
||||
<button class="vcal-nav-today" type="button" data-video-cal-today>Today</button>
|
||||
<button class="vcal-nav-btn" type="button" data-video-cal-next aria-label="Next week">›</button>
|
||||
</div>
|
||||
<div class="vcal-filter" role="group" aria-label="Filter">
|
||||
<button class="vcal-filter-btn vcal-filter-btn--on" type="button" data-video-cal-filter="all">All</button>
|
||||
<button class="vcal-filter-btn" type="button" data-video-cal-filter="owned">In library</button>
|
||||
<button class="vcal-filter-btn" type="button" data-video-cal-filter="missing">Missing</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="vcal-body">
|
||||
<div class="vcal-loading hidden" data-video-cal-loading>
|
||||
|
|
|
|||
|
|
@ -11,14 +11,14 @@
|
|||
'use strict';
|
||||
|
||||
var PAGE_ID = 'video-calendar';
|
||||
var URL = '/api/video/calendar?days=7';
|
||||
var URL = '/api/video/calendar';
|
||||
var COLS = 7;
|
||||
var WD = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
var WD_FULL = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||
var MO = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
var MO_FULL = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August',
|
||||
'September', 'October', 'November', 'December'];
|
||||
var state = { loaded: false, eps: {} };
|
||||
var state = { loaded: false, eps: {}, data: null, offset: 0, filter: 'all' };
|
||||
|
||||
function $(s) { return document.querySelector(s); }
|
||||
function esc(s) {
|
||||
|
|
@ -161,7 +161,8 @@
|
|||
(bg ? '<div class="vcal-bb-bg" style="background-image:url(\'' + bg + '\')"></div>' : '') +
|
||||
'<div class="vcal-bb-scrim"></div>' +
|
||||
'<div class="vcal-bb-content">' +
|
||||
'<div class="vcal-bb-eyebrow"><span class="vcal-bb-dot"></span>NEXT UP · ' + esc(whenLabel(ep, d.today)) + '</div>' +
|
||||
'<div class="vcal-bb-eyebrow"><span class="vcal-bb-dot"></span>' +
|
||||
(state.offset === 0 ? 'NEXT UP' : 'FEATURED') + ' · ' + esc(whenLabel(ep, d.today)) + '</div>' +
|
||||
'<h2 class="vcal-bb-title">' + esc(ep.show_title) + '</h2>' +
|
||||
'<div class="vcal-bb-sub"><span class="vcal-bb-se">' + se + '</span>' + (epTitle ? ' · ' + esc(epTitle) : '') + '</div>' +
|
||||
'<div class="vcal-bb-actions"><span class="vcal-bb-btn">View details</span>' + owned + '</div>' +
|
||||
|
|
@ -169,20 +170,51 @@
|
|||
'</div>';
|
||||
}
|
||||
|
||||
function filterEps(eps) {
|
||||
if (state.filter === 'owned') return eps.filter(function (e) { return e.has_file; });
|
||||
if (state.filter === 'missing') return eps.filter(function (e) { return !e.has_file; });
|
||||
return eps;
|
||||
}
|
||||
function updateChrome() {
|
||||
var t = $('[data-video-cal-title]');
|
||||
if (t) t.textContent = state.offset === 0 ? 'This Week'
|
||||
: state.offset === 1 ? 'Next Week' : state.offset === -1 ? 'Last Week'
|
||||
: state.offset > 0 ? 'In ' + state.offset + ' weeks' : Math.abs(state.offset) + ' weeks ago';
|
||||
var today = $('[data-video-cal-today]');
|
||||
if (today) today.disabled = state.offset === 0;
|
||||
var fbs = document.querySelectorAll('[data-video-cal-filter]');
|
||||
for (var i = 0; i < fbs.length; i++)
|
||||
fbs[i].classList.toggle('vcal-filter-btn--on', fbs[i].getAttribute('data-video-cal-filter') === state.filter);
|
||||
}
|
||||
// Render from the cached payload + the active filter (no refetch).
|
||||
function render() {
|
||||
var d = state.data;
|
||||
updateChrome();
|
||||
var hero = $('[data-video-cal-hero]'), cols = $('[data-video-cal-cols]');
|
||||
if (!d) { showEmpty(true); if (hero) hero.innerHTML = ''; if (cols) cols.innerHTML = ''; return; }
|
||||
var eps = filterEps(d.episodes || []);
|
||||
var view = { episodes: eps, total: eps.length, today: d.today, start: d.start, end: d.end, days: d.days };
|
||||
var has = eps.length > 0;
|
||||
showEmpty(!has);
|
||||
if (has) { renderHero(view); renderGrid(view); }
|
||||
else { if (hero) hero.innerHTML = ''; if (cols) cols.innerHTML = ''; }
|
||||
setSub(view);
|
||||
}
|
||||
function load() {
|
||||
state.loaded = true;
|
||||
showEmpty(false); showLoading(true);
|
||||
fetch(URL, { headers: { 'Accept': 'application/json' } })
|
||||
var base = new Date(); base.setHours(0, 0, 0, 0); base.setDate(base.getDate() + state.offset * 7);
|
||||
fetch(URL + '?days=7&start=' + isoOf(base), { headers: { 'Accept': 'application/json' } })
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (d) {
|
||||
showLoading(false);
|
||||
if (!d || d.error) { showEmpty(true); return; }
|
||||
if (!(d.episodes && d.episodes.length)) { showEmpty(true); return; }
|
||||
if (!d || d.error) { state.data = null; render(); return; }
|
||||
state.data = d;
|
||||
state.eps = {};
|
||||
d.episodes.forEach(function (e) { state.eps[e.id] = e; });
|
||||
renderHero(d); renderGrid(d); setSub(d);
|
||||
(d.episodes || []).forEach(function (e) { state.eps[e.id] = e; });
|
||||
render();
|
||||
})
|
||||
.catch(function () { showLoading(false); showEmpty(true); });
|
||||
.catch(function () { showLoading(false); state.data = null; render(); });
|
||||
}
|
||||
|
||||
function openFrom(target) {
|
||||
|
|
@ -225,6 +257,15 @@
|
|||
wireTilt(hero, '.vcal-bb', 3);
|
||||
}
|
||||
wireTilt(cols, '.vcal-cell', 7);
|
||||
|
||||
var prev = $('[data-video-cal-prev]'); if (prev) prev.addEventListener('click', function () { state.offset--; load(); });
|
||||
var next = $('[data-video-cal-next]'); if (next) next.addEventListener('click', function () { state.offset++; load(); });
|
||||
var today = $('[data-video-cal-today]');
|
||||
if (today) today.addEventListener('click', function () { if (state.offset !== 0) { state.offset = 0; load(); } });
|
||||
var fbs = document.querySelectorAll('[data-video-cal-filter]');
|
||||
for (var i = 0; i < fbs.length; i++) (function (b) {
|
||||
b.addEventListener('click', function () { state.filter = b.getAttribute('data-video-cal-filter'); render(); });
|
||||
})(fbs[i]);
|
||||
}
|
||||
|
||||
// ── episode modal ─────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -1716,3 +1716,23 @@ body[data-side="video"] #soulsync-toggle { display: none; }
|
|||
.vcal-hero-wrap { padding-left: 18px; padding-right: 18px; }
|
||||
.vcal-bb-title { font-size: 30px; } .vcal-bb { min-height: 240px; }
|
||||
}
|
||||
|
||||
/* ── Calendar — header controls (week nav + filter) ───────────────────────── */
|
||||
.vcal-head-row { position: relative; z-index: 1; display: flex; align-items: flex-end; justify-content: space-between; gap: 20px; flex-wrap: wrap; }
|
||||
.vcal-controls { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||
.vcal-nav { display: inline-flex; align-items: center; gap: 2px; padding: 4px; border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.08); }
|
||||
.vcal-nav-btn { width: 34px; height: 32px; border: 0; background: transparent; color: rgba(255, 255, 255, 0.75);
|
||||
font-size: 20px; font-weight: 700; cursor: pointer; border-radius: 8px; line-height: 1; transition: all 0.15s ease; }
|
||||
.vcal-nav-btn:hover { background: rgba(255, 255, 255, 0.1); color: #fff; }
|
||||
.vcal-nav-today { border: 0; background: transparent; color: rgba(255, 255, 255, 0.82); font-size: 12.5px; font-weight: 800;
|
||||
padding: 7px 12px; border-radius: 8px; cursor: pointer; transition: all 0.15s ease; }
|
||||
.vcal-nav-today:hover:not(:disabled) { background: rgba(255, 255, 255, 0.1); color: #fff; }
|
||||
.vcal-nav-today:disabled { opacity: 0.34; cursor: default; }
|
||||
.vcal-filter { display: inline-flex; gap: 3px; padding: 4px; border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.08); }
|
||||
.vcal-filter-btn { border: 0; background: transparent; color: rgba(255, 255, 255, 0.7); font-size: 12.5px; font-weight: 700;
|
||||
padding: 7px 14px; border-radius: 8px; cursor: pointer; transition: all 0.15s ease; }
|
||||
.vcal-filter-btn:hover { color: #fff; background: rgba(255, 255, 255, 0.08); }
|
||||
.vcal-filter-btn--on { color: #fff; background: rgba(var(--vcal-accent), 0.9); box-shadow: 0 4px 14px rgba(var(--vcal-accent), 0.32); }
|
||||
@media (max-width: 720px) { .vcal-head-row { flex-direction: column; align-items: stretch; } .vcal-controls { justify-content: space-between; } }
|
||||
|
|
|
|||
Loading…
Reference in a new issue