arcana / frontend /deckview.html
JG1310's picture
reading: clear stale reading on entry; faster first-card paint (back last + brisk poll)
4680d42 verified
Raw
History Blame Contribute Delete
12.9 kB
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
@import url('https://fonts.googleapis.com/css2?family=Cinzel+Decorative:wght@700&family=Cormorant+Garamond:ital@0;1&display=swap');
:root { --gold:#d8b15a; --ink:#0b0716; --parch:#f3e6c4; }
* { box-sizing:border-box; }
html,body { margin:0; height:100%; background:transparent; overflow:hidden;
font-family:'Cormorant Garamond', ui-serif, Georgia, serif; color:var(--parch); }
#app { position:relative; width:100%; height:100vh; min-height:520px; }
/* mode toggle — subtle, upper-right; selected lightened, others darkened */
#modes { position:absolute; top:14px; right:16px; z-index:40; display:flex; gap:0;
border:1px solid rgba(216,177,90,.35); border-radius:10px; overflow:hidden;
background:rgba(10,7,20,.5); backdrop-filter:blur(3px); }
#modes button { font-family:'Cormorant Garamond',serif; font-size:1.05rem; letter-spacing:.4px;
border:0; background:transparent; color:#9a8f70; padding:7px 14px; cursor:pointer; }
#modes button.on { background:rgba(216,177,90,.22); color:#f6ecd0; }
/* coverflow */
#flow { position:absolute; inset:0; perspective:1600px; }
#flow .card { position:absolute; top:44%; left:50%; width:300px; height:459px;
margin:-229px 0 0 -150px; transform-style:preserve-3d; cursor:pointer;
transition:transform .5s cubic-bezier(.25,.6,.2,1), opacity .5s ease; will-change:transform,opacity; }
#flow .inner { position:relative; width:100%; height:100%; transform-style:preserve-3d;
transition:transform .5s cubic-bezier(.25,.6,.2,1); }
#flow .face { position:absolute; inset:0; backface-visibility:hidden; border-radius:14px; overflow:hidden;
box-shadow:0 18px 50px rgba(0,0,0,.6); }
#flow .face img { width:100%; height:100%; object-fit:cover; display:block; }
#flow .back { transform:rotateY(180deg); border:2px solid var(--gold); }
#flow .card.sel .inner { transform:rotateY(0deg); } /* selected shows front */
#flow .card:not(.sel) .inner { transform:rotateY(180deg); } /* others show back */
/* shimmer placeholder for a not-yet-painted card */
.shim { position:absolute; inset:0; border-radius:14px;
background:linear-gradient(115deg, rgba(28,22,44,.92) 0%, rgba(40,32,60,.92) 50%, rgba(28,22,44,.92) 100%),
radial-gradient(circle at 50% 38%, rgba(216,177,90,.10), transparent 60%);
background-size:220% 100%, 100% 100%; animation:shim 1.5s linear infinite;
display:flex; align-items:center; justify-content:center; }
.shim::after { content:'✦'; color:rgba(216,177,90,.5); font-size:2.2rem; animation:pulse 1.6s ease-in-out infinite; }
@keyframes shim { to { background-position:-220% 0, 0 0; } }
@keyframes pulse { 0%,100%{opacity:.35} 50%{opacity:.85} }
#grid .shim { position:relative; border-radius:10px; aspect-ratio:300/459; }
.arrow { position:absolute; top:44%; transform:translateY(-50%); z-index:30;
width:58px; height:58px; border-radius:50%; border:0; cursor:pointer; font-size:2rem; line-height:1;
color:#1a1208; background:linear-gradient(135deg,#9a7b2e,#d8b15a);
box-shadow:0 8px 22px rgba(0,0,0,.5); }
#prev { left:18px; } #next { right:18px; }
.arrow:active { transform:translateY(-50%) scale(.94); }
/* anchored just BELOW the card (same 40% reference) so the gap stays constant
regardless of iframe height — was bottom-pinned, which drifted far below */
#caption { position:absolute; left:0; right:0; top:calc(44% + 232px); text-align:center;
z-index:20; pointer-events:none; padding:0 14px; }
#caption .name { font-family:'Cinzel Decorative',serif; color:var(--gold); font-size:1.7rem;
text-shadow:0 2px 14px rgba(0,0,0,.8); }
#caption .arc { font-family:'Cormorant Garamond',serif; font-style:italic; color:#cdbf93;
font-size:1.28rem; margin-top:1px; } /* ~3/4 the title; the Major-Arcana identity */
#caption .desc { color:#d7c8a0; font-size:1.12rem; line-height:1.34; max-width:620px;
margin:5px auto 0; text-shadow:0 2px 12px rgba(0,0,0,.85); min-height:1.1em; }
#caption .count { color:#9a8f70; font-size:1.05rem; margin-top:4px; }
#caption .hint { color:#8c8266; font-size:.92rem; font-style:italic; margin-top:2px; }
/* overhead grid */
#grid { position:absolute; inset:0; overflow-y:auto; padding:64px 18px 24px;
display:none; grid-template-columns:repeat(auto-fill, minmax(150px,1fr)); gap:22px 16px; align-content:start; }
#grid.show { display:grid; }
#grid figure { margin:0; cursor:pointer; transition:transform .15s ease; }
#grid figure:hover { transform:translateY(-4px); }
#grid img { width:100%; border-radius:10px; box-shadow:0 8px 24px rgba(0,0,0,.55); display:block; }
/* lightbox popup (full image) */
#lb { position:fixed; inset:0; z-index:100; background:rgba(6,4,12,.92); display:none;
align-items:center; justify-content:center; cursor:zoom-out; }
#lb.show { display:flex; animation:fade .2s ease; }
@keyframes fade { from{opacity:0} to{opacity:1} }
#lb img { max-width:92vw; max-height:92vh; border-radius:12px; box-shadow:0 24px 80px rgba(0,0,0,.7); }
#lb .x { position:absolute; top:18px; right:24px; color:#f6ecd0; font-size:2.4rem; cursor:pointer;
background:none; border:0; line-height:1; }
</style>
</head>
<body>
<div id="app">
<div id="modes">
<button id="m-deck" class="on">Deck</button><button id="m-grid">Overhead</button>
</div>
<div id="flow"></div>
<button class="arrow" id="prev" aria-label="previous"></button>
<button class="arrow" id="next" aria-label="next"></button>
<div id="caption"><div class="name"></div><div class="arc"></div><div class="desc"></div><div class="count"></div><div class="hint">click the card to see it full size · drag or use ← →</div></div>
<div id="grid"></div>
</div>
<div id="lb"><button class="x">×</button><img alt=""></div>
<script>
(function () {
function parseDeck() {
try {
const d = window.DECK_B64 || new URL(window.location.href).searchParams.get('d');
if (!d) return {back:'', cards:[]};
return JSON.parse(decodeURIComponent(escape(atob(d))));
} catch (e) { return {back:'', cards:[]}; }
}
const deck = parseDeck();
const cards = deck.cards || [];
let back = deck.back || '';
let sel = 0;
// num (arcana 0-21) -> card index, for live updates
const numToIdx = {};
cards.forEach((c, i) => { if (c.num !== undefined && c.num !== null) numToIdx[c.num] = i; });
const flow = document.getElementById('flow');
const grid = document.getElementById('grid');
const nameEl = document.querySelector('#caption .name');
const arcEl = document.querySelector('#caption .arc');
const descEl = document.querySelector('#caption .desc');
const countEl = document.querySelector('#caption .count');
// preload available fronts for smooth flipping
cards.forEach(c => { if (c.front) { const i = new Image(); i.src = c.front; } });
if (back) { const b = new Image(); b.src = back; }
function frontFace(c) {
return c.front ? '<img alt="">' : '<div class="shim"></div>';
}
// build coverflow cards
const els = cards.map((c, idx) => {
const card = document.createElement('div');
card.className = 'card';
card.innerHTML =
'<div class="inner">' +
'<div class="face back">' + (back ? '<img src="' + back + '" alt="">' : '<div class="shim"></div>') + '</div>' +
'<div class="face front">' + frontFace(c) + '</div>' +
'</div>';
if (c.front) card.querySelector('.front img').src = c.front;
card.addEventListener('click', () => {
if (idx === sel) { if (c.front) openLb(c.full || c.front); }
else go(idx);
});
flow.appendChild(card);
return card;
});
const SPACING = 248, DEPTH = 220, ANGLE = 48, FADE = 0.32, WINDOW = 4;
function layout() {
els.forEach((card, idx) => {
const off = idx - sel;
const a = Math.abs(off);
if (a > WINDOW) { card.style.opacity = 0; card.style.pointerEvents='none';
card.style.transform = 'translateX(' + (off>0?1:-1)*1400 + 'px)'; return; }
card.style.pointerEvents='auto';
const tx = off * SPACING;
const tz = -a * DEPTH;
const ry = off === 0 ? 0 : (off > 0 ? -ANGLE : ANGLE);
const sc = off === 0 ? 1.0 : 0.86;
card.style.zIndex = 100 - a;
card.style.opacity = Math.max(0, 1 - a * FADE);
card.style.transform = 'translateX(' + tx + 'px) translateZ(' + tz + 'px) rotateY(' + ry + 'deg) scale(' + sc + ')';
card.classList.toggle('sel', off === 0);
});
const c = cards[sel] || {};
nameEl.textContent = c.name || '';
arcEl.textContent = c.arc || '';
descEl.textContent = c.desc || '';
countEl.textContent = (sel + 1) + ' / ' + cards.length;
}
function go(i) { sel = (i + cards.length) % cards.length; layout(); }
document.getElementById('prev').addEventListener('click', () => go(sel - 1));
document.getElementById('next').addEventListener('click', () => go(sel + 1));
window.addEventListener('keydown', e => {
if (e.key === 'ArrowLeft') go(sel - 1);
else if (e.key === 'ArrowRight') go(sel + 1);
else if (e.key === 'Escape') closeLb();
});
// drag / swipe
let down = null;
flow.addEventListener('pointerdown', e => { down = e.clientX; });
window.addEventListener('pointerup', e => {
if (down === null) return; const dx = e.clientX - down; down = null;
if (Math.abs(dx) > 45) go(sel + (dx < 0 ? 1 : -1));
});
// overhead grid
const figs = cards.map((c, idx) => {
const f = document.createElement('figure');
f.innerHTML = c.front ? '<img src="' + c.front + '" alt="">' : '<div class="shim"></div>';
f.addEventListener('click', () => { if (c.front) openLb(c.full || c.front); });
grid.appendChild(f);
return f;
});
// modes
const mDeck = document.getElementById('m-deck'), mGrid = document.getElementById('m-grid');
function setMode(deckMode) {
mDeck.classList.toggle('on', deckMode); mGrid.classList.toggle('on', !deckMode);
grid.classList.toggle('show', !deckMode);
flow.style.display = deckMode ? 'block' : 'none';
document.getElementById('prev').style.display = deckMode ? '' : 'none';
document.getElementById('next').style.display = deckMode ? '' : 'none';
document.getElementById('caption').style.display = deckMode ? '' : 'none';
}
mDeck.addEventListener('click', () => setMode(true));
mGrid.addEventListener('click', () => setMode(false));
// lightbox
const lb = document.getElementById('lb'), lbImg = lb.querySelector('img');
function openLb(src) { lbImg.src = src; lb.classList.add('show'); }
function closeLb() { lb.classList.remove('show'); lbImg.src=''; }
lb.addEventListener('click', closeLb);
lb.querySelector('img').addEventListener('click', e => e.stopPropagation());
// ---- live mode: poll status files and fill cards in as they finish --------
function setBack(url) {
if (!url || back) return;
back = url; const b = new Image(); b.src = url;
els.forEach(card => {
const bf = card.querySelector('.back');
bf.innerHTML = '<img src="' + url + '" alt="">';
});
}
function setCardArt(num, url) {
const idx = numToIdx[num];
if (idx === undefined || !url || cards[idx].front) return;
cards[idx].front = url; cards[idx].full = cards[idx].full || url;
const im = new Image(); im.src = url;
const ff = els[idx].querySelector('.front');
ff.innerHTML = '<img alt="">'; ff.querySelector('img').src = url;
if (figs[idx]) figs[idx].innerHTML = '<img src="' + url + '" alt="">';
}
function setCardDesc(num, desc) {
const idx = numToIdx[num];
if (idx === undefined || !desc) return;
cards[idx].desc = desc;
if (idx === sel) descEl.textContent = desc;
}
async function fetchJson(u) {
try { const r = await fetch(u + (u.indexOf('?')<0?'?':'&') + 't=' + Date.now());
return r.ok ? await r.json() : null; } catch (e) { return null; }
}
let polls = 0;
async function poll() {
let artDone = false, loreDone = false, filled = 0;
if (deck.artUrl) {
const a = await fetchJson(deck.artUrl);
if (a) { setBack(a.back); filled = Object.keys(a.cards || {}).length;
Object.keys(a.cards || {}).forEach(n => setCardArt(+n, a.cards[n])); artDone = !!a.done; }
} else artDone = true;
if (deck.loreUrl) {
const l = await fetchJson(deck.loreUrl);
if (l) { Object.keys(l.cards || {}).forEach(n => { const m = l.cards[n]; setCardDesc(+n, m.essence || m.upright || ''); }); loreDone = !!l.done; }
} else loreDone = true;
// poll briskly until the first cards land (snappy first paint), then ease off
const next = (filled === 0 || polls < 8) ? 700 : 1300;
polls++;
if (!(artDone && loreDone)) setTimeout(poll, next);
}
layout();
if (deck.live) poll();
})();
</script>
</body>
</html>