Video Calendar: EPG-style guide with a shared time axis + premium polish
The 7 independent day-stacks had no shared time axis — you couldn't scan 'what's on in prime time' across the week. Restructured into a real guide: - Rows = time-of-day bands (Morning / Afternoon / Prime Time / Late Night / Anytime) down a frozen left time-rail; 7 day columns; frozen header row + sticky corner. Untimed streaming drops land in Anytime; each card keeps its exact time. Empty bands (none all week) are hidden; empty cells show a dot. - Live 'now' cue lights the today-column × current-time-band intersection. - Prime Time rail glints gold. - Keyboard nav (←/→ weeks, T = today), scoped to the visible page + no modal. - Week-change crossfade; staggered card entrance runs only on load/week-change (not on filter re-renders); keyboard focus rings on cards. - Subheader now shows owned/missing split. Kept all existing interactions: tilt cards, breathing glows, skeleton shimmer, parallax billboard, rich episode modal. Vanilla static files, no build step.
This commit is contained in:
parent
57c5148207
commit
6d72b7ca11
2 changed files with 177 additions and 47 deletions
|
|
@ -62,8 +62,16 @@
|
|||
var el = $('[data-video-cal-sub]'); if (!el) return;
|
||||
var a = parseISO(d.start), b = parseISO(d.end);
|
||||
var range = MO[a.getMonth()] + ' ' + a.getDate() + ' – ' + MO[b.getMonth()] + ' ' + b.getDate();
|
||||
var n = d.total || 0;
|
||||
el.textContent = range + (n ? ' · ' + n + (n === 1 ? ' episode' : ' episodes') : ' · nothing scheduled');
|
||||
var eps = d.episodes || [], n = eps.length;
|
||||
var owned = 0; for (var i = 0; i < n; i++) if (eps[i].has_file) owned++;
|
||||
var miss = n - owned;
|
||||
var parts = [range];
|
||||
if (n) {
|
||||
parts.push(n + (n === 1 ? ' episode' : ' episodes'));
|
||||
if (owned) parts.push(owned + ' in library');
|
||||
if (miss) parts.push(miss + ' missing');
|
||||
} else { parts.push('nothing scheduled'); }
|
||||
el.textContent = parts.join(' · ');
|
||||
}
|
||||
|
||||
function epCell(ep, idx) {
|
||||
|
|
@ -96,35 +104,93 @@
|
|||
'</a>';
|
||||
}
|
||||
|
||||
// Time-of-day bands — the rows of the guide. A real shared time axis so
|
||||
// you can scan ACROSS the week ("what's on in Prime Time") instead of
|
||||
// reading seven disconnected stacks. Untimed streaming drops land in
|
||||
// "Anytime". Each card still carries its exact time.
|
||||
var BANDS = [
|
||||
{ key: 'morning', label: 'Morning', range: '5a–12p', lo: 300, hi: 720 },
|
||||
{ key: 'afternoon', label: 'Afternoon', range: '12–5p', lo: 720, hi: 1020 },
|
||||
{ key: 'prime', label: 'Prime Time', range: '5–9p', lo: 1020, hi: 1260 },
|
||||
{ key: 'late', label: 'Late Night', range: '9p–5a', lo: 1260, hi: 1740 }, // wraps past midnight
|
||||
{ key: 'anytime', label: 'Anytime', range: 'Streaming', lo: null, hi: null }
|
||||
];
|
||||
function bandKeyFor(mins) {
|
||||
if (mins == null) return 'anytime';
|
||||
// Late night wraps: 21:00–04:59 (treat early-AM as +24h for the range test).
|
||||
var m = mins < 300 ? mins + 1440 : mins;
|
||||
for (var i = 0; i < BANDS.length; i++) {
|
||||
var b = BANDS[i];
|
||||
if (b.lo == null) continue;
|
||||
if (m >= b.lo && m < b.hi) return b.key;
|
||||
}
|
||||
return 'anytime';
|
||||
}
|
||||
|
||||
function renderGrid(d) {
|
||||
var cols = $('[data-video-cal-cols]'); if (!cols) return;
|
||||
var byDate = {};
|
||||
(d.episodes || []).forEach(function (ep) { (byDate[ep.air_date] = byDate[ep.air_date] || []).push(ep); });
|
||||
|
||||
var start = parseISO(d.start);
|
||||
var html = '';
|
||||
var days = [];
|
||||
for (var i = 0; i < COLS; i++) { var dt = new Date(start); dt.setDate(start.getDate() + i); days.push(dt); }
|
||||
|
||||
// grid[bandKey][dayIndex] = [episodes]
|
||||
var grid = {};
|
||||
BANDS.forEach(function (b) { grid[b.key] = []; for (var i = 0; i < COLS; i++) grid[b.key].push([]); });
|
||||
var dayCount = [];
|
||||
for (var i = 0; i < COLS; i++) dayCount.push(0);
|
||||
(d.episodes || []).forEach(function (ep) {
|
||||
for (var i = 0; i < COLS; i++) {
|
||||
if (ep.air_date === isoOf(days[i])) {
|
||||
grid[bandKeyFor(airMins(ep.airs_time))][i].push(ep);
|
||||
dayCount[i]++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
var byTime = function (a, b) {
|
||||
var ma = airMins(a.airs_time), mb = airMins(b.airs_time);
|
||||
if (ma == null && mb == null) return 0;
|
||||
if (ma == null) return 1; if (mb == null) return -1;
|
||||
return ma - mb;
|
||||
};
|
||||
|
||||
// header row: corner + 7 day heads
|
||||
var html = '<div class="vcal-guide-corner"></div>';
|
||||
for (var i = 0; i < COLS; i++) {
|
||||
var dt = new Date(start); dt.setDate(start.getDate() + i);
|
||||
var eps = (byDate[isoOf(dt)] || []).slice();
|
||||
eps.sort(function (a, b) {
|
||||
var ma = airMins(a.airs_time), mb = airMins(b.airs_time);
|
||||
if (ma == null && mb == null) return 0;
|
||||
if (ma == null) return 1; if (mb == null) return -1;
|
||||
return ma - mb;
|
||||
});
|
||||
var today = isoOf(dt) === d.today;
|
||||
var cells = eps.length
|
||||
? eps.map(epCell).join('')
|
||||
: '<div class="vcal-col-empty">No episodes</div>';
|
||||
html += '<section class="vcal-col' + (today ? ' vcal-col--today' : '') + '">' +
|
||||
'<header class="vcal-col-head">' +
|
||||
'<span class="vcal-col-wd">' + (today ? 'Today' : WD_FULL[dt.getDay()]) + '</span>' +
|
||||
'<span class="vcal-col-date">' + WD[dt.getDay()] + ' ' + dt.getDate() + '</span>' +
|
||||
(eps.length ? '<span class="vcal-col-n">' + eps.length + '</span>' : '') +
|
||||
'</header>' +
|
||||
'<div class="vcal-col-stack">' + cells + '</div>' +
|
||||
'</section>';
|
||||
var dt = days[i], today = isoOf(dt) === d.today;
|
||||
html += '<div class="vcal-dayhead' + (today ? ' vcal-dayhead--today' : '') + '">' +
|
||||
'<span class="vcal-dayhead-wd">' + (today ? 'Today' : WD_FULL[dt.getDay()]) + '</span>' +
|
||||
'<span class="vcal-dayhead-date">' + WD[dt.getDay()] + ' ' + dt.getDate() + '</span>' +
|
||||
(dayCount[i] ? '<span class="vcal-dayhead-n">' + dayCount[i] + '</span>' : '') +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
// one row per active band (skip bands empty across the whole week)
|
||||
var stagger = 0;
|
||||
// "Now" cue — the band the wall-clock is currently in. Only the today
|
||||
// column carries it (other weeks have no today column), so it lights up
|
||||
// the live time-of-day intersection like a TV guide.
|
||||
var nowD = new Date();
|
||||
var nowBand = bandKeyFor(nowD.getHours() * 60 + nowD.getMinutes());
|
||||
BANDS.forEach(function (b) {
|
||||
var anyEps = false;
|
||||
for (var i = 0; i < COLS; i++) if (grid[b.key][i].length) { anyEps = true; break; }
|
||||
if (!anyEps) return;
|
||||
html += '<div class="vcal-rail vcal-rail--' + b.key + '">' +
|
||||
'<span class="vcal-rail-label">' + b.label + '</span>' +
|
||||
'<small>' + b.range + '</small></div>';
|
||||
for (var i = 0; i < COLS; i++) {
|
||||
var cell = grid[b.key][i].slice().sort(byTime);
|
||||
var today = isoOf(days[i]) === d.today;
|
||||
var isNow = today && b.key === nowBand;
|
||||
var inner = cell.length
|
||||
? cell.map(function (ep) { return epCell(ep, stagger++); }).join('')
|
||||
: '<span class="vcal-slot-dot" aria-hidden="true"></span>';
|
||||
html += '<div class="vcal-slot' + (today ? ' vcal-slot--today' : '') +
|
||||
(isNow ? ' vcal-slot--now' : '') +
|
||||
(cell.length ? '' : ' vcal-slot--empty') + '">' + inner + '</div>';
|
||||
}
|
||||
});
|
||||
cols.innerHTML = html;
|
||||
}
|
||||
|
||||
|
|
@ -196,12 +262,24 @@
|
|||
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 = ''; }
|
||||
if (has) {
|
||||
renderHero(view); renderGrid(view);
|
||||
// Card entrance only on a fresh load/week-change — NOT on filter
|
||||
// re-renders (those would re-stagger every click and feel busy).
|
||||
if (state.fresh && cols) {
|
||||
cols.classList.add('vcal-animate-in');
|
||||
setTimeout(function () { cols.classList.remove('vcal-animate-in'); }, 900);
|
||||
}
|
||||
} else { if (hero) hero.innerHTML = ''; if (cols) cols.innerHTML = ''; }
|
||||
state.fresh = false;
|
||||
var grid = $('[data-video-cal-grid]'); if (grid) grid.classList.remove('vcal-fading');
|
||||
setSub(view);
|
||||
}
|
||||
function load() {
|
||||
state.loaded = true;
|
||||
state.loaded = true; state.fresh = true;
|
||||
// Crossfade the grid out while the next week loads (only when we already
|
||||
// have something on screen — first load uses the entrance instead).
|
||||
var grid = $('[data-video-cal-grid]'); if (grid && state.data) grid.classList.add('vcal-fading');
|
||||
showEmpty(false); showLoading(true);
|
||||
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' } })
|
||||
|
|
@ -266,6 +344,20 @@
|
|||
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]);
|
||||
|
||||
// Keyboard nav for a page users live in: ← / → step weeks, T jumps to
|
||||
// today. Only when the calendar is the visible page, no modal is open,
|
||||
// and the user isn't typing in a field.
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (modalEl || e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
var tag = (e.target && e.target.tagName) || '';
|
||||
if (/^(INPUT|TEXTAREA|SELECT)$/.test(tag) || (e.target && e.target.isContentEditable)) return;
|
||||
var g = $('[data-video-cal-cols]');
|
||||
if (!g || g.offsetParent === null) return; // calendar page not visible
|
||||
if (e.key === 'ArrowLeft') { e.preventDefault(); state.offset--; load(); }
|
||||
else if (e.key === 'ArrowRight') { e.preventDefault(); state.offset++; load(); }
|
||||
else if (e.key === 't' || e.key === 'T') { if (state.offset !== 0) { e.preventDefault(); state.offset = 0; load(); } }
|
||||
});
|
||||
}
|
||||
|
||||
// ── episode modal ─────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -1532,27 +1532,65 @@ body[data-side="video"] #soulsync-toggle { display: none; }
|
|||
.vcal-grid { border-radius: 20px; overflow: hidden; border: 1px solid rgba(255, 255, 255, 0.07);
|
||||
background: linear-gradient(180deg, #0d0d12, #0a0a0e); box-shadow: 0 30px 80px rgba(0, 0, 0, 0.45); }
|
||||
.vcal-grid.hidden { display: none; }
|
||||
.vcal-cols { display: grid; grid-template-columns: repeat(7, minmax(168px, 1fr)); align-items: start; }
|
||||
.vcal-col { border-left: 1px solid rgba(255, 255, 255, 0.06); min-width: 0; }
|
||||
.vcal-col:first-child { border-left: 0; }
|
||||
.vcal-col--today { background: linear-gradient(180deg, rgba(var(--vcal-accent), 0.10), rgba(var(--vcal-accent), 0.02) 30%, transparent 60%); }
|
||||
/* EPG guide — a frozen time-rail (rows = time-of-day) × 7 day columns with a
|
||||
frozen header row, so the whole week reads on ONE shared time axis. */
|
||||
.vcal-cols { display: grid; grid-template-columns: 84px repeat(7, minmax(150px, 1fr)); align-items: stretch; }
|
||||
|
||||
/* Day header — sticky */
|
||||
.vcal-col-head { position: sticky; top: 0; z-index: 4; padding: 15px 15px 13px;
|
||||
background: linear-gradient(180deg, rgba(15, 15, 20, 0.96), rgba(13, 13, 18, 0.85));
|
||||
/* sticky top-left corner */
|
||||
.vcal-guide-corner { position: sticky; top: 0; left: 0; z-index: 6;
|
||||
background: linear-gradient(180deg, rgba(15, 15, 20, 0.97), rgba(13, 13, 18, 0.88));
|
||||
backdrop-filter: blur(12px); border-bottom: 1px solid rgba(255, 255, 255, 0.07); }
|
||||
.vcal-col-wd { display: block; font-size: 14px; font-weight: 800; letter-spacing: -0.01em; color: #fff; }
|
||||
.vcal-col-date { display: block; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em;
|
||||
color: rgba(255, 255, 255, 0.4); margin-top: 2px; }
|
||||
.vcal-col-n { position: absolute; top: 14px; right: 14px; font-size: 11px; font-weight: 800; color: rgba(255, 255, 255, 0.6);
|
||||
background: rgba(255, 255, 255, 0.08); padding: 3px 9px; border-radius: 999px; }
|
||||
.vcal-col--today .vcal-col-wd { color: hsl(232, 100%, 82%); }
|
||||
.vcal-col--today .vcal-col-head { border-bottom-color: rgba(var(--vcal-accent), 0.5);
|
||||
box-shadow: 0 1px 0 rgba(var(--vcal-accent), 0.5), 0 10px 26px -12px rgba(var(--vcal-accent), 0.5); }
|
||||
.vcal-col--today .vcal-col-n { background: rgba(var(--vcal-accent), 0.88); color: #fff; }
|
||||
|
||||
.vcal-col-stack { display: flex; flex-direction: column; gap: 16px; padding: 16px 14px 20px; }
|
||||
.vcal-col-empty { padding: 26px 8px; text-align: center; font-size: 12px; font-weight: 600; color: rgba(255, 255, 255, 0.22); }
|
||||
/* day header — sticky top */
|
||||
.vcal-dayhead { position: sticky; top: 0; z-index: 4; padding: 15px 14px 13px;
|
||||
background: linear-gradient(180deg, rgba(15, 15, 20, 0.96), rgba(13, 13, 18, 0.85));
|
||||
backdrop-filter: blur(12px); border-bottom: 1px solid rgba(255, 255, 255, 0.07);
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.05); }
|
||||
.vcal-dayhead-wd { display: block; font-size: 14px; font-weight: 800; letter-spacing: -0.01em; color: #fff; }
|
||||
.vcal-dayhead-date { display: block; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em;
|
||||
color: rgba(255, 255, 255, 0.4); margin-top: 2px; }
|
||||
.vcal-dayhead-n { position: absolute; top: 14px; right: 13px; font-size: 11px; font-weight: 800; color: rgba(255, 255, 255, 0.6);
|
||||
background: rgba(255, 255, 255, 0.08); padding: 3px 9px; border-radius: 999px; }
|
||||
.vcal-dayhead--today { background: linear-gradient(180deg, rgba(var(--vcal-accent), 0.24), rgba(13, 13, 18, 0.86));
|
||||
border-bottom-color: rgba(var(--vcal-accent), 0.55);
|
||||
box-shadow: 0 1px 0 rgba(var(--vcal-accent), 0.55), 0 12px 28px -14px rgba(var(--vcal-accent), 0.6); }
|
||||
.vcal-dayhead--today .vcal-dayhead-wd { color: hsl(232, 100%, 84%); }
|
||||
.vcal-dayhead--today .vcal-dayhead-n { background: rgba(var(--vcal-accent), 0.9); color: #fff; }
|
||||
|
||||
/* time rail — sticky left, labels the band */
|
||||
.vcal-rail { position: sticky; left: 0; z-index: 2; display: flex; flex-direction: column; gap: 2px;
|
||||
padding: 17px 12px; border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||
background: linear-gradient(90deg, rgba(255, 255, 255, 0.025), rgba(255, 255, 255, 0.004)); }
|
||||
.vcal-rail-label { font-size: 12.5px; font-weight: 800; letter-spacing: -0.01em; color: rgba(255, 255, 255, 0.8); }
|
||||
.vcal-rail small { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: rgba(255, 255, 255, 0.3); }
|
||||
.vcal-rail--prime .vcal-rail-label { color: hsl(44, 100%, 72%); }
|
||||
.vcal-rail--prime small { color: hsla(44, 90%, 60%, 0.6); }
|
||||
|
||||
/* one day×band cell */
|
||||
.vcal-slot { display: flex; flex-direction: column; gap: 15px; padding: 17px 13px; min-width: 0;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.05); border-left: 1px solid rgba(255, 255, 255, 0.05); }
|
||||
.vcal-slot--today { background: linear-gradient(180deg, rgba(var(--vcal-accent), 0.07), rgba(var(--vcal-accent), 0.015)); }
|
||||
.vcal-slot--empty { align-items: center; justify-content: center; padding: 0; }
|
||||
.vcal-slot-dot { width: 4px; height: 4px; border-radius: 50%; background: rgba(255, 255, 255, 0.07); }
|
||||
/* live "now" intersection — today column × current time-of-day band */
|
||||
.vcal-slot--now { box-shadow: inset 3px 0 0 rgba(var(--vcal-accent), 0.95), inset 0 0 34px -12px rgba(var(--vcal-accent), 0.5);
|
||||
background: linear-gradient(180deg, rgba(var(--vcal-accent), 0.12), rgba(var(--vcal-accent), 0.03)); }
|
||||
|
||||
/* staggered entrance — only on a fresh load/week-change (class added by JS),
|
||||
keyed off the per-card --i (cycles every 24). Filter re-renders don't get it. */
|
||||
.vcal-cols.vcal-animate-in .vcal-card { animation: vcalCardIn 0.5s cubic-bezier(0.2, 0.7, 0.2, 1) both;
|
||||
animation-delay: calc(var(--i, 0) * 0.022s); }
|
||||
@keyframes vcalCardIn { from { opacity: 0; transform: translateY(10px) scale(0.985); } to { opacity: 1; transform: none; } }
|
||||
@media (prefers-reduced-motion: reduce) { .vcal-cols.vcal-animate-in .vcal-card { animation: none; } }
|
||||
|
||||
/* week-change crossfade */
|
||||
.vcal-grid { transition: opacity 0.22s ease; }
|
||||
.vcal-grid.vcal-fading { opacity: 0.32; }
|
||||
|
||||
/* keyboard focus — mirrors hover + a clear ring (cards are real <a>s) */
|
||||
.vcal-cell:focus-visible { outline: none; --ty: -6px; --sc: 1.04; z-index: 3; }
|
||||
.vcal-cell:focus-visible .vcal-card { border-color: hsla(var(--vcal-show), 80%, 64%, 0.85);
|
||||
box-shadow: 0 0 0 2px hsla(var(--vcal-show), 82%, 62%, 0.9), 0 18px 40px rgba(0, 0, 0, 0.5); }
|
||||
|
||||
/* Episode cell — one clean card (art + info), breathing colour halo behind it */
|
||||
.vcal-cell { --vcal-show: var(--vcal-h, 230); position: relative; display: block; text-decoration: none; color: inherit;
|
||||
|
|
|
|||
Loading…
Reference in a new issue