Video Calendar: featured billboard, tactile cards, faster art
Take the calendar visuals up a level + fix slow image loads. - Featured "Next up" billboard: the soonest upcoming episode as a cinematic hero (backdrop, pulsing accent, show title, S·E, air time, "View details" → modal). - Cards: cursor-following 3D tilt + hover lift/scale/glow bloom; ambient glow dialed way down so it's calm at rest and only blooms on interaction. - Faster art: poster proxy takes ?w= and asks the source for a thumbnail (Plex transcoder w/ original fallback, Jellyfin maxWidth, TMDB size bucket) — calendar requests ~500px instead of full backdrops. Skeleton shimmer → fade-in on load.
This commit is contained in:
parent
3a8c803a54
commit
5856eefd7c
4 changed files with 199 additions and 31 deletions
|
|
@ -7,13 +7,34 @@ browser). Falls back to 404 so the frontend shows its placeholder.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import Response, abort
|
||||
from flask import Response, abort, request
|
||||
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
logger = get_logger("video_api.poster")
|
||||
|
||||
|
||||
def _req_width():
|
||||
"""Optional ?w= thumbnail width (clamped) so the calendar/library don't pull
|
||||
full-size art into tiny cells. None = original."""
|
||||
try:
|
||||
w = request.args.get("w", type=int)
|
||||
except Exception:
|
||||
w = None
|
||||
if not w:
|
||||
return None
|
||||
return max(48, min(1600, w))
|
||||
|
||||
|
||||
def _tmdb_resize(url, w, backdrop):
|
||||
"""Rewrite a TMDB image URL's size segment (/t/p/<size>/...) to a bucket near
|
||||
``w`` so episode stills load small. Best-effort — unchanged if it doesn't match."""
|
||||
import re
|
||||
buckets = [300, 780, 1280] if backdrop else [185, 342, 500, 780]
|
||||
pick = next((b for b in buckets if w <= b), buckets[-1])
|
||||
return re.sub(r"/t/p/[^/]+/", "/t/p/w%d/" % pick, url, count=1)
|
||||
|
||||
|
||||
def register_routes(bp):
|
||||
def _stream_art(kind, item_id, art):
|
||||
from . import get_video_db
|
||||
|
|
@ -22,11 +43,14 @@ def register_routes(bp):
|
|||
abort(404)
|
||||
try:
|
||||
import requests
|
||||
from config.settings import config_manager
|
||||
w = _req_width()
|
||||
backdrop = art == "backdrop"
|
||||
path = ref["poster_url"]
|
||||
# Enrichment can store a full external URL (e.g. a TMDB season poster) —
|
||||
# stream it directly; otherwise it's a server path needing the token.
|
||||
if path.startswith("http://") or path.startswith("https://"):
|
||||
if w and "image.tmdb.org" in path:
|
||||
path = _tmdb_resize(path, w, backdrop)
|
||||
upstream = requests.get(path, timeout=15, stream=True)
|
||||
if upstream.status_code != 200:
|
||||
abort(404)
|
||||
|
|
@ -38,25 +62,41 @@ def register_routes(bp):
|
|||
# creds, or inherited from music) — that's where the item was scanned.
|
||||
from core.video.sources import video_plex_config, video_jellyfin_config
|
||||
source = ref.get("server_source")
|
||||
fallback = None
|
||||
if source == "plex":
|
||||
cfg = video_plex_config()
|
||||
base, token = cfg.get("base_url"), cfg.get("token")
|
||||
if not base or not token:
|
||||
abort(404)
|
||||
url = base.rstrip("/") + ref["poster_url"]
|
||||
base = base.rstrip("/")
|
||||
params = {"X-Plex-Token": token}
|
||||
if w:
|
||||
# Plex photo transcoder → a right-sized JPEG instead of full art;
|
||||
# fall back to the original if a server has transcoding disabled.
|
||||
from urllib.parse import quote
|
||||
h = int(w * (9 / 16 if backdrop else 3 / 2))
|
||||
url = (base + "/photo/:/transcode?width=%d&height=%d&minSize=1&upscale=1&url=%s"
|
||||
% (w, h, quote(ref["poster_url"], safe="")))
|
||||
fallback = base + ref["poster_url"]
|
||||
else:
|
||||
url = base + ref["poster_url"]
|
||||
elif source == "jellyfin":
|
||||
cfg = video_jellyfin_config()
|
||||
base, key = cfg.get("base_url"), cfg.get("api_key")
|
||||
if not base:
|
||||
abort(404)
|
||||
image = "Backdrop" if art == "backdrop" else "Primary"
|
||||
image = "Backdrop" if backdrop else "Primary"
|
||||
url = base.rstrip("/") + f"/Items/{ref['server_id']}/Images/{image}"
|
||||
params = {"api_key": key} if key else {}
|
||||
if w:
|
||||
params["maxWidth"] = w
|
||||
params["quality"] = 90
|
||||
else:
|
||||
abort(404)
|
||||
|
||||
upstream = requests.get(url, params=params, timeout=15, stream=True)
|
||||
if upstream.status_code != 200 and fallback:
|
||||
upstream = requests.get(fallback, params=params, timeout=15, stream=True)
|
||||
if upstream.status_code != 200:
|
||||
abort(404)
|
||||
ctype = upstream.headers.get("Content-Type", "image/jpeg")
|
||||
|
|
|
|||
|
|
@ -877,6 +877,7 @@
|
|||
<div class="loading-spinner"></div>
|
||||
<div class="loading-text">Loading calendar…</div>
|
||||
</div>
|
||||
<div class="vcal-hero-wrap" data-video-cal-hero></div>
|
||||
<div class="vcal-grid" data-video-cal-grid>
|
||||
<div class="vcal-cols" data-video-cal-cols></div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -68,9 +68,10 @@
|
|||
|
||||
function epCell(ep, idx) {
|
||||
var hue = showHue(ep.show_title || '');
|
||||
var art = ep.has_still ? ('/api/video/poster/episode/' + ep.id)
|
||||
: (ep.show_has_backdrop ? ('/api/video/backdrop/show/' + ep.show_id) : '');
|
||||
var img = art ? '<img class="vcal-cell-img" src="' + art + '" alt="" loading="lazy" onerror="this.style.display=\'none\'">' : '';
|
||||
var art = ep.has_still ? ('/api/video/poster/episode/' + ep.id + '?w=500')
|
||||
: (ep.show_has_backdrop ? ('/api/video/backdrop/show/' + ep.show_id + '?w=500') : '');
|
||||
var img = art ? '<img class="vcal-cell-img" src="' + art + '" alt="" loading="lazy" decoding="async" ' +
|
||||
'onload="this.classList.add(\'vcal-loaded\')" onerror="this.style.display=\'none\'">' : '';
|
||||
var se = 'S' + ep.season_number + ' · E' + ep.episode_number;
|
||||
var epTitle = ep.title || ''; // no redundant "Episode N" when untitled
|
||||
var tl = fmtMins(airMins(ep.airs_time));
|
||||
|
|
@ -127,6 +128,47 @@
|
|||
cols.innerHTML = html;
|
||||
}
|
||||
|
||||
// ── featured "next up" billboard ──────────────────────────────────────────
|
||||
function featured(d) {
|
||||
var eps = (d.episodes || []).slice();
|
||||
eps.sort(function (a, b) {
|
||||
if (a.air_date !== b.air_date) return a.air_date < b.air_date ? -1 : 1;
|
||||
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;
|
||||
});
|
||||
return eps[0] || null;
|
||||
}
|
||||
function whenLabel(ep, today) {
|
||||
var mins = airMins(ep.airs_time);
|
||||
var diff = Math.round((parseISO(ep.air_date) - parseISO(today)) / 86400000);
|
||||
var day = diff === 0 ? ((mins != null && mins >= 17 * 60) ? 'Tonight' : 'Today')
|
||||
: diff === 1 ? 'Tomorrow' : WD_FULL[parseISO(ep.air_date).getDay()];
|
||||
return day + (mins != null ? ', ' + fmtMins(mins) : '');
|
||||
}
|
||||
function renderHero(d) {
|
||||
var host = $('[data-video-cal-hero]'); if (!host) return;
|
||||
var ep = featured(d);
|
||||
if (!ep) { host.innerHTML = ''; return; }
|
||||
var hue = showHue(ep.show_title || '');
|
||||
var bg = ep.show_has_backdrop ? ('/api/video/backdrop/show/' + ep.show_id + '?w=1280') : '';
|
||||
var se = 'S' + ep.season_number + ' · E' + ep.episode_number;
|
||||
var epTitle = ep.title || '';
|
||||
var owned = ep.has_file ? '<span class="vcal-bb-badge">✓ In your library</span>' : '';
|
||||
host.innerHTML =
|
||||
'<div class="vcal-bb" style="--vcal-h:' + hue + '" data-cal-ep="' + ep.id + '" role="button" tabindex="0">' +
|
||||
(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>' +
|
||||
'<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>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function load() {
|
||||
state.loaded = true;
|
||||
showEmpty(false); showLoading(true);
|
||||
|
|
@ -138,21 +180,51 @@
|
|||
if (!(d.episodes && d.episodes.length)) { showEmpty(true); return; }
|
||||
state.eps = {};
|
||||
d.episodes.forEach(function (e) { state.eps[e.id] = e; });
|
||||
renderGrid(d); setSub(d);
|
||||
renderHero(d); renderGrid(d); setSub(d);
|
||||
})
|
||||
.catch(function () { showLoading(false); showEmpty(true); });
|
||||
}
|
||||
|
||||
function openFrom(target) {
|
||||
var el = target.closest('[data-cal-ep]');
|
||||
if (!el) return false;
|
||||
var ep = state.eps[el.getAttribute('data-cal-ep')];
|
||||
if (ep) openModal(ep);
|
||||
return true;
|
||||
}
|
||||
// Cursor-following 3D tilt (delegated, one element at a time).
|
||||
function wireTilt(container, sel, deg) {
|
||||
if (!container) return;
|
||||
var last = null;
|
||||
container.addEventListener('mousemove', function (e) {
|
||||
var c = e.target.closest(sel);
|
||||
if (c !== last && last) { last.style.removeProperty('--rx'); last.style.removeProperty('--ry'); }
|
||||
last = c;
|
||||
if (!c) return;
|
||||
var r = c.getBoundingClientRect();
|
||||
var px = (e.clientX - r.left) / r.width - 0.5, py = (e.clientY - r.top) / r.height - 0.5;
|
||||
c.style.setProperty('--ry', (px * deg).toFixed(2) + 'deg');
|
||||
c.style.setProperty('--rx', (-py * deg).toFixed(2) + 'deg');
|
||||
});
|
||||
container.addEventListener('mouseleave', function () {
|
||||
if (last) { last.style.removeProperty('--rx'); last.style.removeProperty('--ry'); last = null; }
|
||||
});
|
||||
}
|
||||
function wire() {
|
||||
var cols = $('[data-video-cal-cols]');
|
||||
if (cols) cols.addEventListener('click', function (e) {
|
||||
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
||||
var card = e.target.closest('[data-cal-ep]');
|
||||
if (!card || !cols.contains(card)) return;
|
||||
e.preventDefault();
|
||||
var ep = state.eps[card.getAttribute('data-cal-ep')];
|
||||
if (ep) openModal(ep);
|
||||
if (e.target.closest('[data-cal-ep]') && cols.contains(e.target)) { e.preventDefault(); openFrom(e.target); }
|
||||
});
|
||||
var hero = $('[data-video-cal-hero]');
|
||||
if (hero) {
|
||||
hero.addEventListener('click', function (e) { if (openFrom(e.target)) e.preventDefault(); });
|
||||
hero.addEventListener('keydown', function (e) {
|
||||
if ((e.key === 'Enter' || e.key === ' ') && e.target.closest('[data-cal-ep]')) { e.preventDefault(); openFrom(e.target); }
|
||||
});
|
||||
wireTilt(hero, '.vcal-bb', 3);
|
||||
}
|
||||
wireTilt(cols, '.vcal-cell', 7);
|
||||
}
|
||||
|
||||
// ── episode modal ─────────────────────────────────────────────────────────
|
||||
|
|
@ -171,9 +243,9 @@
|
|||
function openModal(ep) {
|
||||
closeModal();
|
||||
var hue = showHue(ep.show_title || '');
|
||||
var backdrop = ep.show_has_backdrop ? ('/api/video/backdrop/show/' + ep.show_id) : '';
|
||||
var still = ep.has_still ? ('/api/video/poster/episode/' + ep.id)
|
||||
: (ep.show_has_backdrop ? ('/api/video/backdrop/show/' + ep.show_id) : '');
|
||||
var backdrop = ep.show_has_backdrop ? ('/api/video/backdrop/show/' + ep.show_id + '?w=1000') : '';
|
||||
var still = ep.has_still ? ('/api/video/poster/episode/' + ep.id + '?w=600')
|
||||
: (ep.show_has_backdrop ? ('/api/video/backdrop/show/' + ep.show_id + '?w=600') : '');
|
||||
var se = 'S' + ep.season_number + ' · E' + ep.episode_number;
|
||||
var epTitle = ep.title || ('Episode ' + ep.episode_number);
|
||||
var tl = fmtMins(airMins(ep.airs_time));
|
||||
|
|
@ -204,7 +276,8 @@
|
|||
'<div class="vcm-body">' +
|
||||
'<div class="vcm-ep">' +
|
||||
'<div class="vcm-ep-still">' +
|
||||
(still ? '<img src="' + still + '" alt="" onerror="this.style.display=\'none\'">' : '') +
|
||||
(still ? '<img src="' + still + '" alt="" decoding="async" ' +
|
||||
'onload="this.classList.add(\'vcm-loaded\')" onerror="this.style.display=\'none\'">' : '') +
|
||||
'<span class="vcm-ep-fb">▶</span></div>' +
|
||||
'<div class="vcm-ep-main">' +
|
||||
'<div class="vcm-ep-se">' + se + '</div>' +
|
||||
|
|
|
|||
|
|
@ -1532,7 +1532,7 @@ 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(150px, 1fr)); align-items: start; }
|
||||
.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%); }
|
||||
|
|
@ -1556,19 +1556,31 @@ body[data-side="video"] #soulsync-toggle { display: none; }
|
|||
|
||||
/* 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;
|
||||
transition: transform 0.2s ease; }
|
||||
transform: perspective(760px) translateY(var(--ty, 0px)) rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg)) scale(var(--sc, 1));
|
||||
transition: transform 0.16s ease; }
|
||||
/* Calm by default; the colour only comes alive on hover. */
|
||||
.vcal-glow { position: absolute; inset: -11px; z-index: 0; border-radius: 22px; pointer-events: none;
|
||||
background: radial-gradient(closest-side, hsla(var(--vcal-show), 90%, 55%, 0.55), transparent 76%);
|
||||
opacity: 0.18; animation: vcalBreathe 6.5s ease-in-out infinite; animation-delay: calc(var(--i) * -0.43s); }
|
||||
@keyframes vcalBreathe { 0%, 100% { opacity: 0.12; } 50% { opacity: 0.42; } }
|
||||
background: radial-gradient(closest-side, hsla(var(--vcal-show), 90%, 55%, 0.6), transparent 76%);
|
||||
opacity: 0.06; animation: vcalBreathe 7s ease-in-out infinite; animation-delay: calc(var(--i) * -0.5s);
|
||||
transition: opacity 0.25s ease; }
|
||||
@keyframes vcalBreathe { 0%, 100% { opacity: 0.04; } 50% { opacity: 0.13; } }
|
||||
.vcal-card { position: relative; z-index: 1; display: block; border-radius: 14px; overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08); background: #14141a;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease; }
|
||||
.vcal-art { position: relative; display: block; aspect-ratio: 16 / 9; overflow: hidden;
|
||||
background: linear-gradient(135deg, hsl(var(--vcal-show), 48%, 26%), #0b0b0f 88%); }
|
||||
.vcal-cell-img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.vcal-art-scrim { position: absolute; inset: 0; background: linear-gradient(0deg, rgba(0, 0, 0, 0.45), transparent 55%); }
|
||||
.vcal-flag { position: absolute; top: 8px; right: 8px; z-index: 2; width: 22px; height: 22px; border-radius: 50%;
|
||||
background: linear-gradient(135deg, hsl(var(--vcal-show), 42%, 22%), #0a0a0e 90%); }
|
||||
/* skeleton shimmer shown until the image fades in */
|
||||
.vcal-art::before { content: ''; position: absolute; inset: 0; z-index: 0;
|
||||
background: linear-gradient(100deg, transparent 16%, rgba(255, 255, 255, 0.05) 38%,
|
||||
rgba(255, 255, 255, 0.10) 50%, rgba(255, 255, 255, 0.05) 62%, transparent 84%);
|
||||
background-size: 220% 100%; animation: vcalShimmer 1.5s ease-in-out infinite; }
|
||||
.vcal-cell-img { position: relative; z-index: 1; width: 100%; height: 100%; object-fit: cover; display: block;
|
||||
opacity: 0; transition: opacity 0.5s ease; }
|
||||
.vcal-cell-img.vcal-loaded { opacity: 1; }
|
||||
@keyframes vcalShimmer { 0% { background-position: 200% 0; } 100% { background-position: -130% 0; } }
|
||||
@media (prefers-reduced-motion: reduce) { .vcal-art::before { animation: none; } .vcal-cell-img { transition: none; } }
|
||||
.vcal-art-scrim { position: absolute; inset: 0; z-index: 2; background: linear-gradient(0deg, rgba(0, 0, 0, 0.45), transparent 55%); }
|
||||
.vcal-flag { position: absolute; top: 8px; right: 8px; z-index: 3; width: 22px; height: 22px; border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 900;
|
||||
background: rgba(108, 211, 145, 0.94); color: #07130c; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5); }
|
||||
.vcal-info { display: block; padding: 9px 11px 11px; }
|
||||
|
|
@ -1583,12 +1595,15 @@ body[data-side="video"] #soulsync-toggle { display: none; }
|
|||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.vcal-se { font-weight: 800; color: rgba(255, 255, 255, 0.72); }
|
||||
.vcal-ep { margin-left: 6px; }
|
||||
.vcal-cell:hover { transform: translateY(-4px); }
|
||||
.vcal-cell:hover { --ty: -6px; --sc: 1.04; z-index: 3; }
|
||||
.vcal-cell:hover .vcal-glow { opacity: 0.6 !important; animation-play-state: paused; }
|
||||
.vcal-cell:hover .vcal-card { border-color: hsla(var(--vcal-show), 78%, 60%, 0.6);
|
||||
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.45), 0 0 22px hsla(var(--vcal-show), 75%, 55%, 0.25); }
|
||||
.vcal-cell:hover .vcal-card { border-color: hsla(var(--vcal-show), 78%, 62%, 0.7);
|
||||
box-shadow: 0 22px 46px rgba(0, 0, 0, 0.55), 0 0 30px hsla(var(--vcal-show), 78%, 55%, 0.34); }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) { .vcal-glow { animation: none; opacity: 0.2; } }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.vcal-glow { animation: none; opacity: 0.08; }
|
||||
.vcal-cell { transform: none; } .vcal-cell:hover { --ty: 0px; --sc: 1; }
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.vcal-state { text-align: center; padding: 90px 20px; color: rgba(255, 255, 255, 0.6); }
|
||||
|
|
@ -1630,7 +1645,9 @@ body[data-side="video"] #soulsync-toggle { display: none; }
|
|||
.vcm-ep { display: grid; grid-template-columns: 176px 1fr; gap: 20px; }
|
||||
.vcm-ep-still { position: relative; aspect-ratio: 16 / 9; border-radius: 12px; overflow: hidden;
|
||||
background: linear-gradient(135deg, hsl(var(--vcm-h), 48%, 28%), #0b0b0f); }
|
||||
.vcm-ep-still img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
|
||||
.vcm-ep-still img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; z-index: 1;
|
||||
opacity: 0; transition: opacity 0.45s ease; }
|
||||
.vcm-ep-still img.vcm-loaded { opacity: 1; }
|
||||
.vcm-ep-fb { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 24px; color: rgba(255, 255, 255, 0.3); }
|
||||
.vcm-ep-main { min-width: 0; }
|
||||
.vcm-ep-se { font-size: 12px; font-weight: 800; color: hsl(var(--vcm-h), 82%, 78%); letter-spacing: 0.03em; }
|
||||
|
|
@ -1662,3 +1679,40 @@ body[data-side="video"] #soulsync-toggle { display: none; }
|
|||
.vcm-actions { flex-direction: column-reverse; }
|
||||
.vcm-btn { width: 100%; }
|
||||
}
|
||||
|
||||
/* ── Calendar — featured "next up" billboard ──────────────────────────────── */
|
||||
.vcal-hero-wrap { padding: 4px 40px 8px; perspective: 1400px; }
|
||||
.vcal-hero-wrap:empty { display: none; }
|
||||
.vcal-bb { --vcal-h: 230; position: relative; display: flex; align-items: flex-end; min-height: 306px;
|
||||
border-radius: 22px; overflow: hidden; cursor: pointer; border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
box-shadow: 0 34px 90px rgba(0, 0, 0, 0.55);
|
||||
transform: rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg)) scale(var(--sc, 1));
|
||||
transition: transform 0.18s ease, box-shadow 0.3s ease; will-change: transform; }
|
||||
.vcal-bb:hover { --sc: 1.012; box-shadow: 0 44px 110px rgba(0, 0, 0, 0.62), 0 0 60px hsla(var(--vcal-h), 72%, 50%, 0.22); }
|
||||
.vcal-bb-bg { position: absolute; inset: 0; background-size: cover; background-position: center 22%; transform: scale(1.08); }
|
||||
.vcal-bb-scrim { position: absolute; inset: 0;
|
||||
background: linear-gradient(90deg, rgba(8, 8, 12, 0.94) 0%, rgba(8, 8, 12, 0.62) 42%, transparent 78%),
|
||||
linear-gradient(0deg, rgba(8, 8, 12, 0.82), transparent 58%),
|
||||
radial-gradient(120% 130% at 0% 100%, hsla(var(--vcal-h), 72%, 45%, 0.42), transparent 58%); }
|
||||
.vcal-bb-content { position: relative; z-index: 1; padding: 34px 42px 34px; max-width: 640px; }
|
||||
.vcal-bb-eyebrow { display: inline-flex; align-items: center; gap: 8px; font-size: 12px; font-weight: 800;
|
||||
letter-spacing: 0.09em; text-transform: uppercase; color: hsl(var(--vcal-h), 88%, 80%); }
|
||||
.vcal-bb-dot { width: 7px; height: 7px; border-radius: 50%; background: hsl(var(--vcal-h), 88%, 62%);
|
||||
box-shadow: 0 0 12px hsl(var(--vcal-h), 88%, 60%); animation: vcalPulse 2s ease-in-out infinite; }
|
||||
@keyframes vcalPulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.45; transform: scale(0.6); } }
|
||||
.vcal-bb-title { font-size: 42px; font-weight: 900; letter-spacing: -0.03em; line-height: 1.02; margin: 11px 0 0;
|
||||
text-shadow: 0 2px 20px rgba(0, 0, 0, 0.6); }
|
||||
.vcal-bb-sub { font-size: 15px; color: rgba(255, 255, 255, 0.82); margin-top: 9px; font-weight: 600; }
|
||||
.vcal-bb-se { font-weight: 800; color: #fff; }
|
||||
.vcal-bb-actions { display: flex; align-items: center; gap: 14px; margin-top: 22px; }
|
||||
.vcal-bb-btn { display: inline-flex; align-items: center; font-size: 13.5px; font-weight: 800; padding: 12px 22px;
|
||||
border-radius: 12px; background: #fff; color: #0a0a0e; box-shadow: 0 10px 28px rgba(0, 0, 0, 0.45);
|
||||
transition: transform 0.15s ease; }
|
||||
.vcal-bb:hover .vcal-bb-btn { transform: translateY(-2px); }
|
||||
.vcal-bb-badge { font-size: 12px; font-weight: 800; padding: 6px 13px; border-radius: 999px;
|
||||
background: rgba(108, 211, 145, 0.18); color: #6cd391; }
|
||||
@media (prefers-reduced-motion: reduce) { .vcal-bb { transform: none; } .vcal-bb-dot { animation: none; } }
|
||||
@media (max-width: 900px) {
|
||||
.vcal-hero-wrap { padding-left: 18px; padding-right: 18px; }
|
||||
.vcal-bb-title { font-size: 30px; } .vcal-bb { min-height: 240px; }
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue