Spaces:
Sleeping
Sleeping
| <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> | |