mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-17 16:10:12 +01:00
feat(preview): sampling, metrics, governance, server mana data
Preview endpoint + fast caches; curated pins + role quotas + rarity/overlap tuning; catalog+preview metrics; governance enforcement flags; server mana/color identity fields; docs/tests/scripts updated.
This commit is contained in:
parent
8f47dfbb81
commit
c4a7fc48ea
40 changed files with 6092 additions and 17312 deletions
|
|
@ -5,6 +5,10 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>MTG Deckbuilder</title>
|
||||
<script src="https://unpkg.com/htmx.org@1.9.12" onerror="var s=document.createElement('script');s.src='/static/vendor/htmx-1.9.12.min.js';document.head.appendChild(s);"></script>
|
||||
<script>
|
||||
// Ensure legacy hover system never initializes (set before its script executes)
|
||||
window.__disableLegacyCardHover = true;
|
||||
</script>
|
||||
<script>
|
||||
(function(){
|
||||
// Pre-CSS theme bootstrapping to avoid flash/mismatch on first paint
|
||||
|
|
@ -71,13 +75,14 @@
|
|||
<span class="dot black"></span>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="nav">
|
||||
<nav class="nav" id="primary-nav">
|
||||
<a href="/">Home</a>
|
||||
<a href="/build">Build</a>
|
||||
<a href="/configs">Build from JSON</a>
|
||||
{% if show_setup %}<a href="/setup">Setup/Tag</a>{% endif %}
|
||||
<a href="/owned">Owned Library</a>
|
||||
<a href="/decks">Finished Decks</a>
|
||||
<a href="/themes/">Themes</a>
|
||||
{% if show_diagnostics %}<a href="/diagnostics">Diagnostics</a>{% endif %}
|
||||
{% if show_logs %}<a href="/logs">Logs</a>{% endif %}
|
||||
</nav>
|
||||
|
|
@ -117,6 +122,26 @@
|
|||
.card-meta ul { margin:.25rem 0; padding-left: 1.1rem; list-style: disc; }
|
||||
.card-meta li { margin:.1rem 0; }
|
||||
.card-meta .themes-list { font-size: 18px; line-height: 1.35; }
|
||||
/* Global theme badge styles (moved from picker for reuse on standalone pages) */
|
||||
.theme-badge { display:inline-block; padding:2px 6px; border-radius:12px; font-size:10px; background: var(--panel-alt); border:1px solid var(--border); letter-spacing:.5px; }
|
||||
.theme-synergies { font-size:11px; opacity:.85; display:flex; flex-wrap:wrap; gap:4px; }
|
||||
.badge-fallback { background:#7f1d1d; color:#fff; }
|
||||
.badge-quality-draft { background:#4338ca; color:#fff; }
|
||||
.badge-quality-reviewed { background:#065f46; color:#fff; }
|
||||
.badge-quality-final { background:#065f46; color:#fff; font-weight:600; }
|
||||
.badge-pop-vc { background:#065f46; color:#fff; }
|
||||
.badge-pop-c { background:#047857; color:#fff; }
|
||||
.badge-pop-u { background:#0369a1; color:#fff; }
|
||||
.badge-pop-n { background:#92400e; color:#fff; }
|
||||
.badge-pop-r { background:#7f1d1d; color:#fff; }
|
||||
.badge-curated { background:#4f46e5; color:#fff; }
|
||||
.badge-enforced { background:#334155; color:#fff; }
|
||||
.badge-inferred { background:#57534e; color:#fff; }
|
||||
.theme-detail-card { background:var(--panel); padding:1rem 1.1rem; border:1px solid var(--border); border-radius:10px; box-shadow:0 2px 6px rgba(0,0,0,.25); }
|
||||
.theme-detail-card h3 { margin-top:0; margin-bottom:.4rem; }
|
||||
.theme-detail-card .desc { margin-top:0; font-size:13px; line-height:1.45; }
|
||||
.theme-detail-card h4 { margin-bottom:.35rem; margin-top:.85rem; font-size:13px; letter-spacing:.05em; text-transform:uppercase; opacity:.85; }
|
||||
.breadcrumb { font-size:12px; margin-bottom:.4rem; }
|
||||
.card-meta .label { color:#94a3b8; text-transform: uppercase; font-size: 10px; letter-spacing: .04em; display:block; margin-bottom:.15rem; }
|
||||
.card-meta .themes-label { color: var(--text); font-size: 20px; letter-spacing: .05em; }
|
||||
.card-meta .line + .line { margin-top:.35rem; }
|
||||
|
|
@ -127,6 +152,51 @@
|
|||
@media (max-width: 900px){
|
||||
.card-hover{ display: none !important; }
|
||||
}
|
||||
.card-hover .themes-list li.overlap { color:#0ea5e9; font-weight:600; }
|
||||
.card-hover .ov-chip { display:inline-block; background:#0ea5e91a; color:#0ea5e9; border:1px solid #0ea5e9; border-radius:12px; padding:2px 6px; font-size:11px; margin-right:4px; }
|
||||
/* Two-faced: keep full single-card width; allow wrapping on narrow viewport */
|
||||
.card-hover .dual.two-faced img { width:320px; }
|
||||
.card-hover .dual.two-faced { gap:8px; }
|
||||
/* Combo (two distinct cards) keep larger but slightly reduced to fit side-by-side */
|
||||
.card-hover .dual.combo img { width:300px; }
|
||||
@media (max-width: 1100px){
|
||||
.card-hover .dual.two-faced img { width:280px; }
|
||||
.card-hover .dual.combo img { width:260px; }
|
||||
}
|
||||
/* Unified hover-card-panel styling parity */
|
||||
#hover-card-panel.is-payoff { border-color: var(--accent, #38bdf8); box-shadow:0 6px 24px rgba(0,0,0,.65), 0 0 0 1px var(--accent, #38bdf8) inset; }
|
||||
#hover-card-panel.is-payoff .hcp-img { border-color: var(--accent, #38bdf8); }
|
||||
/* Inline theme/tag list styling (unifies legacy second panel) */
|
||||
/* Two-column hover layout */
|
||||
#hover-card-panel .hcp-body { display:grid; grid-template-columns: 320px 1fr; gap:18px; align-items:start; }
|
||||
#hover-card-panel .hcp-img-wrap { grid-column:1 / 2; }
|
||||
#hover-card-panel.compact-img .hcp-body { grid-template-columns: 120px 1fr; }
|
||||
/* Tag list as multi-column list instead of pill chips for readability */
|
||||
#hover-card-panel .hcp-taglist { columns:2; column-gap:18px; font-size:13px; line-height:1.3; margin:6px 0 6px; padding:0; list-style:none; max-height:180px; overflow:auto; }
|
||||
#hover-card-panel .hcp-taglist li { break-inside:avoid; padding:2px 0 2px 0; position:relative; }
|
||||
#hover-card-panel .hcp-taglist li.overlap { font-weight:600; color:var(--accent,#38bdf8); }
|
||||
#hover-card-panel .hcp-taglist li.overlap::before { content:'•'; color:var(--accent,#38bdf8); position:absolute; left:-10px; }
|
||||
#hover-card-panel .hcp-overlaps { font-size:10px; line-height:1.25; margin-top:2px; }
|
||||
#hover-card-panel .hcp-ov-chip { display:inline-block; background:var(--accent,#38bdf8); color:#fff; border:1px solid var(--accent,#38bdf8); border-radius:10px; padding:2px 5px; font-size:9px; margin-right:4px; margin-top:2px; }
|
||||
/* Hide modal-specific close button outside modal host */
|
||||
#preview-close-btn { display:none; }
|
||||
#theme-preview-modal #preview-close-btn { display:inline-flex; }
|
||||
/* Overlay flip toggle for double-faced cards */
|
||||
.dfc-host { position:relative; }
|
||||
.dfc-toggle { position:absolute; top:6px; left:6px; z-index:5; background:rgba(15,23,42,.82); color:#fff; border:1px solid #475569; border-radius:50%; width:36px; height:36px; padding:0; font-size:16px; cursor:pointer; line-height:1; display:flex; align-items:center; justify-content:center; opacity:.92; backdrop-filter: blur(3px); }
|
||||
.dfc-toggle:hover, .dfc-toggle:focus { opacity:1; box-shadow:0 0 0 2px rgba(56,189,248,.35); outline:none; }
|
||||
.dfc-toggle:active { transform: translateY(1px); }
|
||||
.dfc-toggle .icon { font-size:12px; }
|
||||
.dfc-toggle[data-face='back'] { background:rgba(76,29,149,.85); }
|
||||
.dfc-toggle[data-face='front'] { background:rgba(15,23,42,.82); }
|
||||
.dfc-toggle[aria-pressed='true'] { box-shadow:0 0 0 2px var(--accent, #38bdf8); }
|
||||
/* Fade transition for hover panel image */
|
||||
#hover-card-panel .hcp-img { transition: opacity .22s ease; }
|
||||
.sr-only { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0 0 0 0); white-space:nowrap; border:0; }
|
||||
</style>
|
||||
<style>
|
||||
.nav a.active { font-weight:600; position:relative; }
|
||||
.nav a.active::after { content:''; position:absolute; left:0; bottom:2px; width:100%; height:2px; background:var(--accent, #38bdf8); border-radius:2px; }
|
||||
</style>
|
||||
<script>
|
||||
(function(){
|
||||
|
|
@ -230,6 +300,8 @@
|
|||
pollHealth();
|
||||
|
||||
function ensureCard() {
|
||||
// Legacy large image hover kept for fallback; disabled in favor of unified hover-card-panel
|
||||
if (window.__disableLegacyCardHover) return document.getElementById('card-hover') || document.createElement('div');
|
||||
var pop = document.getElementById('card-hover');
|
||||
if (!pop) {
|
||||
pop = document.createElement('div');
|
||||
|
|
@ -256,9 +328,10 @@
|
|||
}
|
||||
var cardPop = ensureCard();
|
||||
var PREVIEW_VERSIONS = ['normal','large'];
|
||||
function buildCardUrl(name, version, nocache){
|
||||
function buildCardUrl(name, version, nocache, face){
|
||||
var q = encodeURIComponent(name||'');
|
||||
var url = 'https://api.scryfall.com/cards/named?fuzzy=' + q + '&format=image&version=' + (version||'normal');
|
||||
if (face === 'back') url += '&face=back';
|
||||
if (nocache) url += '&t=' + Date.now();
|
||||
return url;
|
||||
}
|
||||
|
|
@ -325,6 +398,7 @@
|
|||
if (y + rect.height + 8 > vh) cardPop.style.top = (e.clientY - rect.height - 16) + 'px';
|
||||
}
|
||||
function attachCardHover() {
|
||||
if (window.__disableLegacyCardHover) return; // short-circuit legacy system
|
||||
document.querySelectorAll('[data-card-name]').forEach(function(el) {
|
||||
if (el.__cardHoverBound) return; // avoid duplicate bindings
|
||||
el.__cardHoverBound = true;
|
||||
|
|
@ -332,33 +406,62 @@
|
|||
var img = cardPop.querySelector('.card-hover-inner img');
|
||||
var img2 = cardPop.querySelector('.card-hover-inner .dual img:nth-child(2)');
|
||||
if (img2) img2.style.display = 'none';
|
||||
var dualNode = cardPop.querySelector('.card-hover-inner .dual');
|
||||
if (img2) { img2.style.display = 'none'; }
|
||||
if (dualNode) { dualNode.classList.remove('combo','two-faced'); }
|
||||
var meta = cardPop.querySelector('.card-meta');
|
||||
var name = el.getAttribute('data-card-name') || '';
|
||||
var vi = 0; // always start at 'normal' on hover
|
||||
img.src = buildCardUrl(name, PREVIEW_VERSIONS[vi], false);
|
||||
img.src = buildCardUrl(name, PREVIEW_VERSIONS[vi], false, 'front');
|
||||
// Bind a one-off error handler per enter to try fallbacks
|
||||
var triedNoCache = false;
|
||||
function onErr(){
|
||||
if (vi < PREVIEW_VERSIONS.length - 1){ vi += 1; img.src = buildCardUrl(name, PREVIEW_VERSIONS[vi], false); }
|
||||
else if (!triedNoCache){ triedNoCache = true; img.src = buildCardUrl(name, PREVIEW_VERSIONS[vi], true); }
|
||||
if (vi < PREVIEW_VERSIONS.length - 1){ vi += 1; img.src = buildCardUrl(name, PREVIEW_VERSIONS[vi], false, 'front'); }
|
||||
else if (!triedNoCache){ triedNoCache = true; img.src = buildCardUrl(name, PREVIEW_VERSIONS[vi], true, 'front'); }
|
||||
else { img.removeEventListener('error', onErr); }
|
||||
}
|
||||
img.addEventListener('error', onErr, { once:false });
|
||||
img.addEventListener('load', function onOk(){ img.removeEventListener('load', onOk); img.removeEventListener('error', onErr); });
|
||||
|
||||
// Attempt to load back face (double-faced / transform). If it fails, we silently hide.
|
||||
if (img2) {
|
||||
img2.style.display = 'none';
|
||||
var backTriedNoCache = false;
|
||||
var backIdx = 0;
|
||||
function backErr(){
|
||||
if (backIdx < PREVIEW_VERSIONS.length - 1){
|
||||
backIdx += 1; img2.src = buildCardUrl(name, PREVIEW_VERSIONS[backIdx], false, 'back');
|
||||
} else if (!backTriedNoCache){
|
||||
backTriedNoCache = true; img2.src = buildCardUrl(name, PREVIEW_VERSIONS[backIdx], true, 'back');
|
||||
} else {
|
||||
img2.removeEventListener('error', backErr); img2.style.display='none';
|
||||
}
|
||||
}
|
||||
function backOk(){ img2.removeEventListener('error', backErr); img2.removeEventListener('load', backOk); if (dualNode) dualNode.classList.add('two-faced'); img2.style.display=''; }
|
||||
img2.addEventListener('error', backErr, { once:false });
|
||||
img2.addEventListener('load', backOk, { once:false });
|
||||
img2.src = buildCardUrl(name, PREVIEW_VERSIONS[0], false, 'back');
|
||||
}
|
||||
var role = el.getAttribute('data-role') || '';
|
||||
var rawTags = el.getAttribute('data-tags') || '';
|
||||
var overlapsRaw = el.getAttribute('data-overlaps') || '';
|
||||
// Clean and split tags into an array; remove brackets and quotes
|
||||
var tags = rawTags
|
||||
.replace(/[\[\]\u2018\u2019'\u201C\u201D"]/g,'')
|
||||
.split(/\s*,\s*/)
|
||||
.filter(function(t){ return t && t.trim(); });
|
||||
var overlaps = overlapsRaw.split(/\s*,\s*/).filter(function(t){ return t; });
|
||||
var overlapSet = new Set(overlaps);
|
||||
if (role || (tags && tags.length)) {
|
||||
var html = '';
|
||||
if (role) {
|
||||
html += '<div class="line"><span class="label">Role</span>' + role.replace(/</g,'<') + '</div>';
|
||||
}
|
||||
if (tags && tags.length) {
|
||||
html += '<div class="line"><span class="label themes-label">Themes</span><ul class="themes-list">' + tags.map(function(t){ return '<li>' + t.replace(/</g,'<') + '</li>'; }).join('') + '</ul></div>';
|
||||
html += '<div class="line"><span class="label themes-label">Themes</span><ul class="themes-list">' + tags.map(function(t){ var safe=t.replace(/</g,'<'); return '<li'+(overlapSet.has(t)?' class="overlap"':'')+'>' + safe + '</li>'; }).join('') + '</ul></div>';
|
||||
if (overlaps.length){
|
||||
html += '<div class="line" style="margin-top:4px;"><span class="label" title="Themes shared with preview selection">Overlaps</span>' + overlaps.map(function(o){ return '<span class="ov-chip">'+o.replace(/</g,'<')+'</span>'; }).join(' ') + '</div>';
|
||||
}
|
||||
}
|
||||
meta.innerHTML = html;
|
||||
meta.style.display = '';
|
||||
|
|
@ -385,7 +488,10 @@
|
|||
if (img2) img2.style.display = '';
|
||||
var vi1 = 0, vi2 = 0; var triedNoCache1 = false, triedNoCache2 = false;
|
||||
img.src = buildCardUrl(a, PREVIEW_VERSIONS[vi1], false);
|
||||
img2.src = buildCardUrl(b, PREVIEW_VERSIONS[vi2], false);
|
||||
img.src = buildCardUrl(a, PREVIEW_VERSIONS[vi1], false, 'front');
|
||||
img2.src = buildCardUrl(b, PREVIEW_VERSIONS[vi2], false, 'front');
|
||||
var dualNode = cardPop.querySelector('.card-hover-inner .dual');
|
||||
if (dualNode){ dualNode.classList.add('combo'); dualNode.classList.remove('two-faced'); }
|
||||
function err1(){ if (vi1 < PREVIEW_VERSIONS.length - 1){ vi1 += 1; img.src = buildCardUrl(a, PREVIEW_VERSIONS[vi1], false);} else if (!triedNoCache1){ triedNoCache1 = true; img.src = buildCardUrl(a, PREVIEW_VERSIONS[vi1], true);} else { img.removeEventListener('error', err1);} }
|
||||
function err2(){ if (vi2 < PREVIEW_VERSIONS.length - 1){ vi2 += 1; img2.src = buildCardUrl(b, PREVIEW_VERSIONS[vi2], false);} else if (!triedNoCache2){ triedNoCache2 = true; img2.src = buildCardUrl(b, PREVIEW_VERSIONS[vi2], true);} else { img2.removeEventListener('error', err2);} }
|
||||
img.addEventListener('error', err1, { once:false });
|
||||
|
|
@ -404,6 +510,114 @@
|
|||
document.addEventListener('htmx:afterSwap', function() { attachCardHover(); bindAllCardImageRetries(); });
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
// Overlay flip button + persistence + accessibility for double-faced cards
|
||||
(function(){
|
||||
var FACE_ATTR = 'data-current-face';
|
||||
var LS_PREFIX = 'mtg:face:';
|
||||
var DEBOUNCE_MS = 120; // prevent rapid flip spamming / extra fetches
|
||||
var lastFlip = 0;
|
||||
function hasTwoFaces(card){
|
||||
if(!card) return false;
|
||||
var name = (card.getAttribute('data-card-name')||'') + ' ' + (card.getAttribute('data-original-name')||'');
|
||||
return name.indexOf('//') > -1;
|
||||
}
|
||||
function keyFor(card){
|
||||
var nm = (card.getAttribute('data-card-name')|| card.getAttribute('data-original-name')||'').toLowerCase();
|
||||
return LS_PREFIX + nm;
|
||||
}
|
||||
function applyStoredFace(card){
|
||||
try {
|
||||
var k = keyFor(card);
|
||||
var val = localStorage.getItem(k);
|
||||
if(val === 'front' || val === 'back') card.setAttribute(FACE_ATTR, val);
|
||||
} catch(_){}
|
||||
}
|
||||
function storeFace(card, face){
|
||||
try { localStorage.setItem(keyFor(card), face); } catch(_){}
|
||||
}
|
||||
function announce(face, card){
|
||||
var live = document.getElementById('dfc-live');
|
||||
if(!live){
|
||||
live = document.createElement('div');
|
||||
live.id = 'dfc-live'; live.className='sr-only'; live.setAttribute('aria-live','polite');
|
||||
document.body.appendChild(live);
|
||||
}
|
||||
var nm = (card.getAttribute('data-card-name')||'').split('//')[0].trim();
|
||||
live.textContent = 'Showing ' + (face==='front'?'front face':'back face') + ' of ' + nm;
|
||||
}
|
||||
function updateButton(btn, face){
|
||||
btn.setAttribute('data-face', face);
|
||||
btn.setAttribute('aria-label', face==='front' ? 'Flip to back face' : 'Flip to front face');
|
||||
btn.innerHTML = '<span class="icon" aria-hidden="true" style="font-size:18px;">⥮</span>';
|
||||
}
|
||||
function ensureButton(card){
|
||||
if(!hasTwoFaces(card)) return;
|
||||
if(card.querySelector('.dfc-toggle')) return;
|
||||
card.classList.add('dfc-host');
|
||||
applyStoredFace(card);
|
||||
var face = card.getAttribute(FACE_ATTR) || 'front';
|
||||
var btn = document.createElement('button');
|
||||
btn.type='button';
|
||||
btn.className='dfc-toggle';
|
||||
btn.setAttribute('aria-pressed','false');
|
||||
btn.setAttribute('tabindex','0');
|
||||
btn.addEventListener('click', function(ev){ ev.stopPropagation(); flip(card, btn); });
|
||||
btn.addEventListener('keydown', function(ev){ if(ev.key==='Enter' || ev.key===' ' || ev.key==='f' || ev.key==='F'){ ev.preventDefault(); flip(card, btn); }});
|
||||
updateButton(btn, face);
|
||||
card.insertBefore(btn, card.firstChild);
|
||||
}
|
||||
function flip(card, btn){
|
||||
var now = Date.now();
|
||||
if(now - lastFlip < DEBOUNCE_MS) return;
|
||||
lastFlip = now;
|
||||
var cur = card.getAttribute(FACE_ATTR) || 'front';
|
||||
var next = cur === 'front' ? 'back' : 'front';
|
||||
card.setAttribute(FACE_ATTR, next);
|
||||
storeFace(card, next);
|
||||
if(btn) updateButton(btn, next);
|
||||
// visual cue
|
||||
card.style.outline='2px solid var(--accent)'; setTimeout(function(){ card.style.outline=''; }, 160);
|
||||
announce(next, card);
|
||||
// retrigger hover update under pointer if applicable
|
||||
if(window.__hoverShowCard){ window.__hoverShowCard(card); }
|
||||
}
|
||||
function scan(){
|
||||
document.querySelectorAll('.card-sample, .commander-cell, .card-tile, .candidate-tile').forEach(ensureButton);
|
||||
}
|
||||
document.addEventListener('pointermove', function(e){ window.__lastPointerEvent = e; }, { passive:true });
|
||||
document.addEventListener('DOMContentLoaded', scan);
|
||||
document.addEventListener('htmx:afterSwap', scan);
|
||||
// Expose for debugging
|
||||
window.__dfcScan = scan;
|
||||
// MutationObserver to re-inject buttons if card tiles are replaced (e.g., HTMX swaps, dynamic filtering)
|
||||
var moDebounce = null;
|
||||
var observer = new MutationObserver(function(muts){
|
||||
if(moDebounce) cancelAnimationFrame(moDebounce);
|
||||
moDebounce = requestAnimationFrame(function(){ scan(); });
|
||||
});
|
||||
try { observer.observe(document.body, { childList:true, subtree:true }); } catch(_){ }
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
(function(){
|
||||
try {
|
||||
var path = window.location.pathname || '/';
|
||||
var nav = document.getElementById('primary-nav'); if(!nav) return;
|
||||
var links = nav.querySelectorAll('a');
|
||||
var best = null; var bestLen = -1;
|
||||
links.forEach(function(a){
|
||||
var href = a.getAttribute('href') || '';
|
||||
if(!href) return;
|
||||
// Exact match or prefix match (ignoring trailing slash)
|
||||
if(path === href || path === href + '/' || (href !== '/' && path.startsWith(href))){
|
||||
if(href.length > bestLen){ best = a; bestLen = href.length; }
|
||||
}
|
||||
});
|
||||
if(best) best.classList.add('active');
|
||||
} catch(_) {}
|
||||
})();
|
||||
</script>
|
||||
<script src="/static/app.js?v=20250826-4"></script>
|
||||
{% if enable_themes %}
|
||||
<script>
|
||||
|
|
@ -521,5 +735,253 @@
|
|||
}catch(_){ }
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
// Global delegated hover card panel initializer (ensures functionality after HTMX swaps)
|
||||
(function(){
|
||||
// Disable legacy multi-element hover in favor of single unified panel
|
||||
window.__disableLegacyCardHover = true;
|
||||
// Global delegated curated-only & reasons controls (works after HTMX swaps and inline render)
|
||||
function findPreviewRoot(el){ return el.closest('.preview-modal-content.theme-preview-expanded') || el.closest('.preview-modal-content'); }
|
||||
function applyCuratedFor(root){
|
||||
var checkbox = root.querySelector('#curated-only-toggle');
|
||||
var status = root.querySelector('#preview-status');
|
||||
if(!checkbox) return;
|
||||
// persist
|
||||
try{ localStorage.setItem('mtg:preview.curatedOnly', checkbox.checked ? '1':'0'); }catch(_){ }
|
||||
var curatedOnly = checkbox.checked;
|
||||
var hidden=0;
|
||||
root.querySelectorAll('.card-sample').forEach(function(card){
|
||||
var role = card.getAttribute('data-role');
|
||||
var isCurated = role==='example'|| role==='curated_synergy' || role==='synthetic';
|
||||
if(curatedOnly && !isCurated){ card.style.display='none'; hidden++; } else { card.style.display=''; }
|
||||
});
|
||||
if(status) status.textContent = curatedOnly ? ('Hid '+hidden+' sampled cards') : '';
|
||||
}
|
||||
function applyReasonsFor(root){
|
||||
var cb = root.querySelector('#reasons-toggle'); if(!cb) return;
|
||||
try{ localStorage.setItem('mtg:preview.showReasons', cb.checked ? '1':'0'); }catch(_){ }
|
||||
var show = cb.checked;
|
||||
root.querySelectorAll('[data-reasons-block]').forEach(function(el){ el.style.display = show ? '' : 'none'; });
|
||||
}
|
||||
document.addEventListener('change', function(e){
|
||||
if(e.target && e.target.id === 'curated-only-toggle'){
|
||||
var root = findPreviewRoot(e.target); if(root) applyCuratedFor(root);
|
||||
}
|
||||
});
|
||||
document.addEventListener('change', function(e){
|
||||
if(e.target && e.target.id === 'reasons-toggle'){
|
||||
var root = findPreviewRoot(e.target); if(root) applyReasonsFor(root);
|
||||
}
|
||||
});
|
||||
document.addEventListener('htmx:afterSwap', function(ev){
|
||||
var frag = ev.target;
|
||||
if(frag && frag.querySelector){
|
||||
if(frag.querySelector('#curated-only-toggle')) applyCuratedFor(frag);
|
||||
if(frag.querySelector('#reasons-toggle')) applyReasonsFor(frag);
|
||||
}
|
||||
});
|
||||
document.addEventListener('DOMContentLoaded', function(){
|
||||
document.querySelectorAll('.preview-modal-content').forEach(function(root){
|
||||
// restore persisted states before applying
|
||||
try {
|
||||
var cVal = localStorage.getItem('mtg:preview.curatedOnly');
|
||||
if(cVal !== null){ var cb = root.querySelector('#curated-only-toggle'); if(cb){ cb.checked = cVal === '1'; } }
|
||||
var rVal = localStorage.getItem('mtg:preview.showReasons');
|
||||
if(rVal !== null){ var rb = root.querySelector('#reasons-toggle'); if(rb){ rb.checked = rVal === '1'; } }
|
||||
}catch(_){ }
|
||||
if(root.querySelector('#curated-only-toggle')) applyCuratedFor(root);
|
||||
if(root.querySelector('#reasons-toggle')) applyReasonsFor(root);
|
||||
});
|
||||
});
|
||||
function createPanel(){
|
||||
var panel = document.createElement('div');
|
||||
panel.id = 'hover-card-panel';
|
||||
panel.setAttribute('role','dialog');
|
||||
panel.setAttribute('aria-label','Card detail hover panel');
|
||||
panel.setAttribute('aria-hidden','true');
|
||||
panel.style.cssText = 'display:none;position:fixed;z-index:9999;width:560px;max-width:98vw;background:#1f2937;border:1px solid #374151;border-radius:12px;padding:18px;box-shadow:0 16px 42px rgba(0,0,0,.75);color:#f3f4f6;font-size:14px;line-height:1.45;pointer-events:none;';
|
||||
panel.innerHTML = ''+
|
||||
'<div class="hcp-header" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;gap:6px;">'+
|
||||
'<div class="hcp-name" style="font-weight:600;font-size:16px;flex:1;padding-right:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;"> </div>'+
|
||||
'<div class="hcp-rarity" style="font-size:11px;text-transform:uppercase;letter-spacing:.5px;opacity:.75;"></div>'+
|
||||
'</div>'+
|
||||
'<div class="hcp-body">'+
|
||||
'<div class="hcp-img-wrap" style="text-align:center;display:flex;flex-direction:column;gap:12px;">'+
|
||||
'<img class="hcp-img" alt="Card image" style="max-width:320px;width:100%;height:auto;border-radius:10px;border:1px solid #475569;background:#0b0d12;opacity:1;" />'+
|
||||
'</div>'+
|
||||
'<div class="hcp-right" style="display:flex;flex-direction:column;min-width:0;">'+
|
||||
'<div style="display:flex;align-items:center;gap:6px;margin:0 0 4px;flex-wrap:wrap;">'+
|
||||
'<div class="hcp-role" style="display:inline-block;padding:3px 8px;font-size:11px;letter-spacing:.65px;border:1px solid #475569;border-radius:12px;background:#243044;text-transform:uppercase;"> </div>'+
|
||||
'<div class="hcp-overlaps" style="flex:1;min-height:14px;"></div>'+
|
||||
'</div>'+
|
||||
'<ul class="hcp-taglist" aria-label="Themes"></ul>'+
|
||||
'<div class="hcp-meta" style="font-size:12px;opacity:.85;margin:2px 0 6px;"></div>'+
|
||||
'<ul class="hcp-reasons" style="list-style:disc;margin:4px 0 8px 18px;padding:0;max-height:140px;overflow:auto;font-size:11px;line-height:1.35;"></ul>'+
|
||||
'<div class="hcp-tags" style="font-size:11px;opacity:.55;word-break:break-word;"></div>'+
|
||||
'</div>'+
|
||||
'</div>';
|
||||
document.body.appendChild(panel);
|
||||
return panel;
|
||||
}
|
||||
function ensurePanel(){
|
||||
var panel = document.getElementById('hover-card-panel');
|
||||
if (panel) return panel;
|
||||
// Auto-create for direct theme pages where fragment-specific markup not injected
|
||||
return createPanel();
|
||||
}
|
||||
function setup(){
|
||||
var panel = ensurePanel();
|
||||
if(!panel || panel.__hoverInit) return;
|
||||
panel.__hoverInit = true;
|
||||
var imgEl = panel.querySelector('.hcp-img');
|
||||
var nameEl = panel.querySelector('.hcp-name');
|
||||
var rarityEl = panel.querySelector('.hcp-rarity');
|
||||
var metaEl = panel.querySelector('.hcp-meta');
|
||||
var reasonsList = panel.querySelector('.hcp-reasons');
|
||||
var tagsEl = panel.querySelector('.hcp-tags');
|
||||
function move(evt){
|
||||
if(panel.style.display==='none') return;
|
||||
var pad=18; var x=evt.clientX+pad, y=evt.clientY+pad;
|
||||
var vw=window.innerWidth, vh=window.innerHeight; var r=panel.getBoundingClientRect();
|
||||
if(x + r.width + 8 > vw) x = evt.clientX - r.width - pad;
|
||||
if(y + r.height + 8 > vh) y = evt.clientY - r.height - pad;
|
||||
panel.style.left = x+'px'; panel.style.top = y+'px';
|
||||
}
|
||||
// Lightweight image prefetch LRU cache (size 12) (P2 UI Hover image prefetch)
|
||||
var _imgLRU=[];
|
||||
function prefetch(src){ if(!src) return; if(_imgLRU.indexOf(src)===-1){ _imgLRU.push(src); if(_imgLRU.length>12) _imgLRU.shift(); var im=new Image(); im.src=src; } }
|
||||
var activationDelay=120; // ms (P2 optional activation delay)
|
||||
var hoverTimer=null;
|
||||
function schedule(card, evt){ clearTimeout(hoverTimer); hoverTimer=setTimeout(function(){ show(card, evt); }, activationDelay); }
|
||||
function cancelSchedule(){ clearTimeout(hoverTimer); }
|
||||
var lastCard = null;
|
||||
function show(card, evt){
|
||||
if(!card) return;
|
||||
// Prefer attributes on container, fallback to child (image) if missing
|
||||
function attr(name){ return card.getAttribute(name) || (card.querySelector('[data-'+name.slice(5)+']') && card.querySelector('[data-'+name.slice(5)+']').getAttribute(name)) || ''; }
|
||||
var nm = attr('data-card-name') || attr('data-original-name') || 'Card';
|
||||
var rarity = (attr('data-rarity')||'').trim();
|
||||
var mana = (attr('data-mana')||'').trim();
|
||||
var role = (attr('data-role')||'').trim();
|
||||
var reasonsRaw = attr('data-reasons')||'';
|
||||
var tags = attr('data-tags')||'';
|
||||
var roleEl = panel.querySelector('.hcp-role');
|
||||
var tagListEl = panel.querySelector('.hcp-taglist');
|
||||
var overlapsEl = panel.querySelector('.hcp-overlaps');
|
||||
var overlapsAttr = attr('data-overlaps') || '';
|
||||
var overlapArr = overlapsAttr.split(/\s*,\s*/).filter(Boolean);
|
||||
nameEl.textContent = nm;
|
||||
rarityEl.textContent = rarity;
|
||||
metaEl.textContent = [role?('Role: '+role):'', mana?('Mana: '+mana):''].filter(Boolean).join(' • ');
|
||||
reasonsList.innerHTML='';
|
||||
reasonsRaw.split(';').map(function(r){return r.trim();}).filter(Boolean).forEach(function(r){ var li=document.createElement('li'); li.style.margin='2px 0'; li.textContent=r; reasonsList.appendChild(li); });
|
||||
// Build inline tag list with overlap highlighting
|
||||
if(tagListEl){
|
||||
tagListEl.innerHTML='';
|
||||
if(tags){
|
||||
var tagArr = tags.split(/\s*,\s*/).filter(Boolean);
|
||||
var setOverlap = new Set(overlapArr);
|
||||
tagArr.forEach(function(t){
|
||||
var li = document.createElement('li');
|
||||
if(setOverlap.has(t)) li.className='overlap';
|
||||
li.textContent = t;
|
||||
tagListEl.appendChild(li);
|
||||
});
|
||||
}
|
||||
}
|
||||
if(overlapsEl){
|
||||
overlapsEl.innerHTML = overlapArr.map(function(o){ return '<span class="hcp-ov-chip" title="Overlapping synergy">'+o+'</span>'; }).join('');
|
||||
}
|
||||
tagsEl.textContent = tags; // raw tag string fallback (legacy consumers)
|
||||
if(roleEl){ roleEl.textContent = role || ''; }
|
||||
panel.classList.toggle('is-payoff', role === 'payoff');
|
||||
var fuzzy = encodeURIComponent(nm);
|
||||
var rawName = nm || '';
|
||||
var hasBack = rawName.indexOf('//')>-1 || (attr('data-original-name')||'').indexOf('//')>-1;
|
||||
var storageKey = 'mtg:face:' + rawName.toLowerCase();
|
||||
var storedFace = (function(){ try { return localStorage.getItem(storageKey); } catch(_){ return null; } })();
|
||||
if(storedFace === 'front' || storedFace === 'back') card.setAttribute('data-current-face', storedFace);
|
||||
var chosenFace = card.getAttribute('data-current-face') || 'front';
|
||||
(function(){
|
||||
var desiredVersion='large';
|
||||
var faceParam = (chosenFace==='back') ? '&face=back' : '';
|
||||
var currentKey = nm+':'+chosenFace+':'+desiredVersion;
|
||||
var prevFace = imgEl.getAttribute('data-face');
|
||||
var faceChanged = prevFace && prevFace !== chosenFace;
|
||||
if(imgEl.getAttribute('data-current')!== currentKey){
|
||||
var src='https://api.scryfall.com/cards/named?fuzzy='+fuzzy+'&format=image&version='+desiredVersion+faceParam;
|
||||
if(faceChanged){ imgEl.style.opacity = 0; }
|
||||
prefetch(src);
|
||||
imgEl.src = src;
|
||||
imgEl.setAttribute('data-current', currentKey);
|
||||
imgEl.setAttribute('data-face', chosenFace);
|
||||
imgEl.addEventListener('load', function onLoad(){ imgEl.removeEventListener('load', onLoad); requestAnimationFrame(function(){ imgEl.style.opacity = 1; }); });
|
||||
}
|
||||
if(!imgEl.__errBound){
|
||||
imgEl.__errBound = true;
|
||||
imgEl.addEventListener('error', function(){
|
||||
var cur = imgEl.getAttribute('src')||'';
|
||||
if(cur.indexOf('version=large')>-1){ imgEl.src = cur.replace('version=large','version=normal'); }
|
||||
else if(cur.indexOf('version=normal')>-1){ imgEl.src = cur.replace('version=normal','version=small'); }
|
||||
});
|
||||
}
|
||||
})();
|
||||
panel.style.display='block'; panel.setAttribute('aria-hidden','false'); move(evt); lastCard = card;
|
||||
}
|
||||
function hide(){ panel.style.display='none'; panel.setAttribute('aria-hidden','true'); cancelSchedule(); }
|
||||
document.addEventListener('mousemove', move);
|
||||
function getCardFromEl(el){
|
||||
if(!el) return null;
|
||||
// If inside flip button
|
||||
var btn = el.closest && el.closest('.dfc-toggle');
|
||||
if(btn) return btn.closest('.card-sample, .commander-cell, .card-tile, .candidate-tile, .card-preview');
|
||||
if(el.matches && el.matches('img.card-thumb')) return el.closest('.card-sample, .commander-cell, .card-tile, .candidate-tile, .card-preview');
|
||||
return null;
|
||||
}
|
||||
document.addEventListener('pointerover', function(e){
|
||||
var card = getCardFromEl(e.target);
|
||||
if(!card) return;
|
||||
// If hovering flip button, refresh immediately (no activation delay)
|
||||
if(e.target.closest && e.target.closest('.dfc-toggle')){
|
||||
show(card, e);
|
||||
return;
|
||||
}
|
||||
if(lastCard === card && panel.style.display==='block') { return; }
|
||||
schedule(card, e);
|
||||
});
|
||||
document.addEventListener('pointerout', function(e){
|
||||
var relCard = getCardFromEl(e.relatedTarget);
|
||||
if(relCard && lastCard && relCard === lastCard) return; // moving within same card (img <-> button)
|
||||
if(!panel.contains(e.relatedTarget)){
|
||||
cancelSchedule();
|
||||
if(!relCard) hide();
|
||||
}
|
||||
});
|
||||
// Expose show function for external refresh (flip updates)
|
||||
window.__hoverShowCard = function(card){
|
||||
var ev = window.__lastPointerEvent || { clientX: (card.getBoundingClientRect().left+12), clientY: (card.getBoundingClientRect().top+12) };
|
||||
show(card, ev);
|
||||
};
|
||||
// Keyboard accessibility & focus traversal (P2 UI Hover keyboard accessibility)
|
||||
document.addEventListener('focusin', function(e){ var card=e.target.closest && e.target.closest('.card-sample, .commander-cell'); if(card){ show(card, {clientX:card.getBoundingClientRect().left+10, clientY:card.getBoundingClientRect().top+10}); }});
|
||||
document.addEventListener('focusout', function(e){ var next=e.relatedTarget && e.relatedTarget.closest && e.relatedTarget.closest('.card-sample, .commander-cell'); if(!next) hide(); });
|
||||
document.addEventListener('keydown', function(e){ if(e.key==='Escape') hide(); });
|
||||
// Compact mode event listener
|
||||
document.addEventListener('mtg:hoverCompactToggle', function(){ panel.classList.toggle('compact-img', !!window.__hoverCompactMode); });
|
||||
}
|
||||
document.addEventListener('htmx:afterSwap', setup);
|
||||
document.addEventListener('DOMContentLoaded', setup);
|
||||
setup();
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
// Global compact mode toggle function (UI Hover compact mode toggle)
|
||||
(function(){
|
||||
window.toggleHoverCompactMode = function(state){
|
||||
if(typeof state==='boolean') window.__hoverCompactMode = state; else window.__hoverCompactMode = !window.__hoverCompactMode;
|
||||
document.dispatchEvent(new CustomEvent('mtg:hoverCompactToggle'));
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
{% if show_setup %}<a class="action-button" href="/setup">Initial Setup</a>{% endif %}
|
||||
<a class="action-button" href="/owned">Owned Library</a>
|
||||
<a class="action-button" href="/decks">Finished Decks</a>
|
||||
<a class="action-button" href="/themes/">Browse Themes</a>
|
||||
{% if show_logs %}<a class="action-button" href="/logs">View Logs</a>{% endif %}
|
||||
</div>
|
||||
<div id="themes-quick" style="margin-top:1rem; font-size:.85rem; color:var(--text-muted);">
|
||||
|
|
|
|||
121
code/web/templates/themes/catalog_simple.html
Normal file
121
code/web/templates/themes/catalog_simple.html
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
{% extends 'base.html' %}
|
||||
{% block content %}
|
||||
<h2>Theme Catalog (Simple)</h2>
|
||||
<div id="theme-catalog-simple">
|
||||
<div style="display:flex; gap:.75rem; flex-wrap:wrap; margin-bottom:.85rem; align-items:flex-end;">
|
||||
<div style="flex:1; min-width:220px; position:relative;">
|
||||
<label style="font-size:11px; display:block; opacity:.7;">Search</label>
|
||||
<input type="text" id="theme-search" placeholder="Search themes" aria-label="Search" style="width:100%;" autocomplete="off" />
|
||||
<div id="theme-search-results" class="search-suggestions" style="position:absolute; top:100%; left:0; right:0; background:var(--panel); border:1px solid var(--border); border-top:none; z-index:25; display:none; max-height:300px; overflow:auto; border-radius:0 0 8px 8px;"></div>
|
||||
</div>
|
||||
<div style="min-width:160px;">
|
||||
<label style="font-size:11px; display:block; opacity:.7;">Popularity</label>
|
||||
<select id="pop-filter" style="width:100%; font-size:13px;">
|
||||
<option value="">All</option>
|
||||
<option>Very Common</option>
|
||||
<option>Common</option>
|
||||
<option>Uncommon</option>
|
||||
<option>Niche</option>
|
||||
<option>Rare</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style="min-width:210px;">
|
||||
<label style="font-size:11px; display:block; opacity:.7;">Colors</label>
|
||||
<div id="color-filter" style="display:flex; gap:.45rem; font-size:12px; flex-wrap:wrap;">
|
||||
{% for c in ['W','U','B','R','G'] %}
|
||||
<label style="display:flex; gap:2px; align-items:center;"><input type="checkbox" value="{{ c }}"/> {{ c }}</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<button id="clear-search" class="btn btn-ghost" style="font-size:12px;" hidden>Clear</button>
|
||||
</div>
|
||||
<div id="quick-popularity" style="display:flex; gap:.4rem; flex-wrap:wrap; margin-bottom:.55rem;">
|
||||
{% for b in ['Very Common','Common','Uncommon','Niche','Rare'] %}
|
||||
<button class="btn btn-ghost pop-chip" data-pop="{{ b }}" style="font-size:11px; padding:2px 8px;">{{ b }}</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div id="active-filters" style="display:flex; gap:6px; flex-wrap:wrap; margin-bottom:.55rem; font-size:11px;"></div>
|
||||
<div id="theme-results" aria-live="polite" aria-busy="true">
|
||||
<div style="display:flex; flex-direction:column; gap:8px;">
|
||||
{% for i in range(6) %}<div style="height:48px; border-radius:8px; background:linear-gradient(90deg,var(--panel-alt) 25%,var(--hover) 50%,var(--panel-alt) 75%); background-size:200% 100%; animation: sk 1.2s ease-in-out infinite;"></div>{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<style>
|
||||
.search-suggestions a { display:block; padding:.5rem .6rem; font-size:13px; text-decoration:none; color:var(--text); border-bottom:1px solid var(--border); }
|
||||
.search-suggestions a:last-child { border-bottom:none; }
|
||||
.search-suggestions a:hover { background:var(--hover); }
|
||||
</style>
|
||||
<script>
|
||||
(function(){
|
||||
const input = document.getElementById('theme-search');
|
||||
const resultsBox = document.getElementById('theme-search-results');
|
||||
const clearBtn = document.getElementById('clear-search');
|
||||
const popSel = document.getElementById('pop-filter');
|
||||
const popChips = document.querySelectorAll('.pop-chip');
|
||||
const colorBox = document.getElementById('color-filter');
|
||||
const activeFilters = document.getElementById('active-filters');
|
||||
const resultsHost = document.getElementById('theme-results');
|
||||
let lastQuery=''; let lastSearchIssued=0; const SEARCH_THROTTLE=150;
|
||||
function hideResults(){ resultsBox.style.display='none'; resultsBox.innerHTML=''; }
|
||||
function buildParams(){
|
||||
const params = new URLSearchParams();
|
||||
const q = input.value.trim(); if(q) params.set('q', q);
|
||||
const pop = popSel.value; if(pop) params.set('bucket', pop);
|
||||
const colors = Array.from(colorBox.querySelectorAll('input:checked')).map(c=>c.value); if(colors.length) params.set('colors', colors.join(','));
|
||||
params.set('limit','50'); params.set('offset','0');
|
||||
return params.toString();
|
||||
}
|
||||
function addChip(label, remover){
|
||||
const span=document.createElement('span');
|
||||
span.style.cssText='background:var(--panel-alt); border:1px solid var(--border); padding:2px 8px; border-radius:14px; display:inline-flex; align-items:center; gap:6px;';
|
||||
span.innerHTML='<span>'+label+'</span><button style="background:none; border:none; cursor:pointer; font-size:12px;" aria-label="Remove">×</button>';
|
||||
span.querySelector('button').addEventListener('click', remover);
|
||||
activeFilters.appendChild(span);
|
||||
}
|
||||
function renderActive(){
|
||||
activeFilters.innerHTML='';
|
||||
const q = input.value.trim(); if(q) addChip('Search: '+q, ()=>{ input.value=''; fetchList(); });
|
||||
const pop = popSel.value; if(pop) addChip('Popularity: '+pop, ()=>{ popSel.value=''; fetchList(); });
|
||||
const colors = Array.from(colorBox.querySelectorAll('input:checked')).map(c=>c.value); if(colors.length) addChip('Colors: '+colors.join(','), ()=>{ colorBox.querySelectorAll('input:checked').forEach(i=>i.checked=false); fetchList(); });
|
||||
}
|
||||
function fetchList(){
|
||||
const ps = buildParams();
|
||||
resultsHost.setAttribute('aria-busy','true');
|
||||
fetch('/themes/fragment/list_simple?'+ps, {cache:'no-store'})
|
||||
.then(r=>r.text())
|
||||
.then(html=>{ resultsHost.innerHTML=html; resultsHost.removeAttribute('aria-busy'); renderActive(); })
|
||||
.catch(()=>{ resultsHost.innerHTML='<div class="empty" style="font-size:13px;">Failed to load.</div>'; resultsHost.removeAttribute('aria-busy'); });
|
||||
}
|
||||
function performSearch(q){
|
||||
if(!q){ hideResults(); return; }
|
||||
const now=Date.now(); if(now - lastSearchIssued < SEARCH_THROTTLE){ clearTimeout(window.__simpleSearchDelay); window.__simpleSearchDelay=setTimeout(()=>performSearch(q), SEARCH_THROTTLE); return; }
|
||||
lastSearchIssued=now; const issueId=lastSearchIssued;
|
||||
resultsBox.style.display='block';
|
||||
resultsBox.innerHTML='<div style="padding:.5rem; font-size:12px; opacity:.7;">Searching…</div>';
|
||||
fetch('/themes/api/search?q='+encodeURIComponent(q), {cache:'no-store'})
|
||||
.then(r=>r.json()).then(js=>{
|
||||
if(issueId!==lastSearchIssued) return;
|
||||
if(!js.ok){ hideResults(); return; }
|
||||
const items=js.items||[]; if(!items.length){ hideResults(); return; }
|
||||
resultsBox.innerHTML=items.map(it=>`<a href="/themes/${it.id}" data-theme-id="${it.id}">${it.theme}</a>`).join('');
|
||||
resultsBox.style.display='block';
|
||||
}).catch(()=>hideResults());
|
||||
}
|
||||
input.addEventListener('input', function(){
|
||||
const q=this.value.trim(); clearBtn.hidden=!q; if(q!==lastQuery){ lastQuery=q; performSearch(q); fetchList(); }
|
||||
});
|
||||
input.addEventListener('keydown', function(ev){
|
||||
if(ev.key==='Enter' && ev.shiftKey){ const q=input.value.trim(); if(!q) return; ev.preventDefault(); fetch('/themes/api/search?q='+encodeURIComponent(q)+'&include_synergies=1',{cache:'no-store'}).then(r=>r.json()).then(js=>{ if(!js.ok) return; const items=js.items||[]; if(!items.length) return; resultsBox.innerHTML=items.map(it=>`<a href="/themes/${it.id}" data-theme-id="${it.id}">${it.theme} <span style=\"opacity:.55; font-size:10px;\">(w/ synergies)</span></a>`).join(''); resultsBox.style.display='block'; }); }
|
||||
});
|
||||
clearBtn.addEventListener('click', function(){ input.value=''; lastQuery=''; hideResults(); clearBtn.hidden=true; input.focus(); fetchList(); });
|
||||
resultsBox.addEventListener('click', function(ev){ const a=ev.target.closest('a[data-theme-id]'); if(!a) return; ev.preventDefault(); window.location.href='/themes/'+a.getAttribute('data-theme-id'); });
|
||||
resultsBox.addEventListener('mouseover', function(ev){ const a=ev.target.closest('a[data-theme-id]'); if(!a) return; const id=a.getAttribute('data-theme-id'); if(!id || a._prefetched) return; a._prefetched=true; fetch('/themes/fragment/detail/'+id,{cache:'reload'}).catch(()=>{}); });
|
||||
document.addEventListener('click', function(ev){ if(!resultsBox.contains(ev.target) && ev.target!==input){ hideResults(); } });
|
||||
popSel.addEventListener('change', fetchList); popChips.forEach(ch=> ch.addEventListener('click', ()=>{ popSel.value=ch.getAttribute('data-pop'); fetchList(); }));
|
||||
colorBox.addEventListener('change', fetchList);
|
||||
// Initial load
|
||||
fetchList();
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
84
code/web/templates/themes/detail_fragment.html
Normal file
84
code/web/templates/themes/detail_fragment.html
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
{% if theme %}
|
||||
<div class="theme-detail-card">
|
||||
{% if standalone_page %}
|
||||
<div class="breadcrumb"><a href="/themes/" class="btn btn-ghost" style="font-size:11px; padding:2px 6px;">← Catalog</a></div>
|
||||
{% endif %}
|
||||
<h3 id="theme-detail-heading-{{ theme.id }}" tabindex="-1">{{ theme.theme }}
|
||||
{% if diagnostics and yaml_available %}
|
||||
<a href="/themes/yaml/{{ theme.id }}" target="_blank" style="font-size:11px; font-weight:400; margin-left:.5rem;">(YAML)</a>
|
||||
{% endif %}
|
||||
</h3>
|
||||
{% if theme.description %}
|
||||
<p class="desc">{{ theme.description }}</p>
|
||||
{% else %}
|
||||
{% if theme.synergies %}
|
||||
<p class="desc" data-fallback-desc="1">Built around {{ theme.synergies[:6]|join(', ') }}.</p>
|
||||
{% else %}
|
||||
<p class="desc" data-fallback-desc="1">No description.</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<div style="font-size:12px; margin-bottom:.5rem; display:flex; gap:8px; flex-wrap:wrap;">
|
||||
{% if theme.popularity_bucket %}<span class="theme-badge {% if theme.popularity_bucket=='Very Common' %}badge-pop-vc{% elif theme.popularity_bucket=='Common' %}badge-pop-c{% elif theme.popularity_bucket=='Uncommon' %}badge-pop-u{% elif theme.popularity_bucket=='Niche' %}badge-pop-n{% elif theme.popularity_bucket=='Rare' %}badge-pop-r{% endif %}" title="Popularity: {{ theme.popularity_bucket }}" aria-label="Popularity bucket: {{ theme.popularity_bucket }}">{{ theme.popularity_bucket }}</span>{% endif %}
|
||||
{% if diagnostics and theme.editorial_quality %}<span class="theme-badge badge-quality-{{ theme.editorial_quality }}" title="Editorial quality: {{ theme.editorial_quality }}" aria-label="Editorial quality: {{ theme.editorial_quality }}">{{ theme.editorial_quality }}</span>{% endif %}
|
||||
{% if diagnostics and theme.has_fallback_description %}<span class="theme-badge badge-fallback" title="Fallback generic description" aria-label="Fallback generic description">Fallback</span>{% endif %}
|
||||
</div>
|
||||
<div class="synergy-section">
|
||||
<h4>Synergies {% if not uncapped %}(capped){% endif %}</h4>
|
||||
<div class="theme-synergies">
|
||||
{% for s in theme.synergies %}<span class="theme-badge">{{ s }}</span>{% endfor %}
|
||||
</div>
|
||||
{% if diagnostics %}
|
||||
{% if not uncapped and theme.uncapped_synergies %}
|
||||
<button hx-get="/themes/fragment/detail/{{ theme.id }}?diagnostics=1&uncapped=1" hx-target="#theme-detail" hx-swap="innerHTML" style="margin-top:.5rem;">Show Uncapped ({{ theme.uncapped_synergies|length }})</button>
|
||||
{% elif uncapped %}
|
||||
<button hx-get="/themes/fragment/detail/{{ theme.id }}?diagnostics=1" hx-target="#theme-detail" hx-swap="innerHTML" style="margin-top:.5rem;">Hide Uncapped</button>
|
||||
{% if theme.uncapped_synergies %}
|
||||
<div class="theme-synergies" style="margin-top:.4rem;">
|
||||
{% for s in theme.uncapped_synergies %}<span class="theme-badge">{{ s }}</span>{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="examples" style="margin-top:.75rem;">
|
||||
<h4 style="margin-bottom:.4rem;">Example Cards</h4>
|
||||
<div class="example-card-grid" style="display:grid; grid-template-columns:repeat(auto-fill,minmax(230px,1fr)); gap:.85rem;">
|
||||
{% if theme.example_cards %}
|
||||
{% for c in theme.example_cards %}
|
||||
<div class="ex-card card-sample" style="text-align:center;" data-card-name="{{ c }}" data-role="example_card" data-tags="{{ theme.synergies|join(', ') }}">
|
||||
<img class="card-thumb" loading="lazy" decoding="async" alt="{{ c }} image" style="width:100%; height:auto; border:1px solid var(--border); border-radius:10px;" src="https://api.scryfall.com/cards/named?fuzzy={{ c|urlencode }}&format=image&version=small" />
|
||||
<div style="font-size:11px; margin-top:4px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; font-weight:600;" class="card-ref" data-card-name="{{ c }}" data-tags="{{ theme.synergies|join(', ') }}">{{ c }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div style="font-size:12px; opacity:.7;">No curated example cards.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<h4 style="margin:.9rem 0 .4rem;">Example Commanders</h4>
|
||||
<div class="example-commander-grid" style="display:grid; grid-template-columns:repeat(auto-fill,minmax(230px,1fr)); gap:.85rem;">
|
||||
{% if theme.example_commanders %}
|
||||
{% for c in theme.example_commanders %}
|
||||
<div class="ex-commander commander-cell" style="text-align:center;" data-card-name="{{ c }}" data-role="commander_example" data-tags="{{ theme.synergies|join(', ') }}">
|
||||
<img class="card-thumb" loading="lazy" decoding="async" alt="{{ c }} image" style="width:100%; height:auto; border:1px solid var(--border); border-radius:10px;" src="https://api.scryfall.com/cards/named?fuzzy={{ c|urlencode }}&format=image&version=small" />
|
||||
<div style="font-size:11px; margin-top:4px; font-weight:600; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;" class="card-ref" data-card-name="{{ c }}" data-tags="{{ theme.synergies|join(', ') }}">{{ c }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div style="font-size:12px; opacity:.7;">No curated commander examples.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty">Theme not found.</div>
|
||||
{% endif %}
|
||||
<style>
|
||||
.card-ref { cursor:pointer; text-decoration:underline dotted; }
|
||||
.card-ref:hover { color:var(--accent); }
|
||||
</style>
|
||||
<script>
|
||||
// Accessibility: automatically move focus to the detail heading after the fragment is swapped in
|
||||
(function(){
|
||||
try { var h=document.getElementById('theme-detail-heading-{{ theme.id }}'); if(h){ h.focus({preventScroll:false}); } } catch(_e){}
|
||||
})();
|
||||
</script>
|
||||
4
code/web/templates/themes/detail_page.html
Normal file
4
code/web/templates/themes/detail_page.html
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{% extends 'base.html' %}
|
||||
{% block content %}
|
||||
{% include 'themes/detail_fragment.html' %}
|
||||
{% endblock %}
|
||||
155
code/web/templates/themes/list_fragment.html
Normal file
155
code/web/templates/themes/list_fragment.html
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
{% if items %}
|
||||
<div class="pager" style="display:flex; justify-content:space-between; align-items:center; margin-bottom:.35rem; font-size:12px;">
|
||||
<div>
|
||||
Showing {{ offset + 1 }}–{{ (offset + items|length) }} of {{ total }}
|
||||
</div>
|
||||
<div class="pager-buttons" style="display:flex; gap:.4rem;">
|
||||
{% if prev_offset is not none %}
|
||||
<button hx-get="/themes/fragment/list?offset={{ prev_offset }}&limit={{ limit }}" hx-target="#theme-results" hx-swap="innerHTML" class="btn btn-ghost" style="font-size:11px; padding:2px 8px;">« Prev</button>
|
||||
{% else %}
|
||||
<button disabled class="btn btn-ghost" style="opacity:.3; font-size:11px; padding:2px 8px;">« Prev</button>
|
||||
{% endif %}
|
||||
{% if next_offset is not none %}
|
||||
<button hx-get="/themes/fragment/list?offset={{ next_offset }}&limit={{ limit }}" hx-target="#theme-results" hx-swap="innerHTML" class="btn btn-ghost" style="font-size:11px; padding:2px 8px;">Next »</button>
|
||||
{% else %}
|
||||
<button disabled class="btn btn-ghost" style="opacity:.3; font-size:11px; padding:2px 8px;">Next »</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:22%">Theme</th>
|
||||
<th style="width:10%">Primary</th>
|
||||
<th style="width:10%">Secondary</th>
|
||||
<th style="width:12%">Popularity</th>
|
||||
<th style="width:12%">Archetype</th>
|
||||
<th>Synergies</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for it in items %}
|
||||
<tr hx-get="/themes/fragment/detail/{{ it.id }}" hx-target="#theme-detail" hx-swap="innerHTML" title="Click for details" class="theme-row" data-theme-id="{{ it.id }}" tabindex="0" role="option" aria-selected="false">
|
||||
<td title="{{ it.short_description or '' }}">{% set q = request.query_params.get('q') %}{% set name = it.theme %}{% if q %}{% set ql = q.lower() %}{% set nl = name.lower() %}{% if ql in nl %}{% set start = nl.find(ql) %}{% set end = start + q|length %}<span class="trunc-name">{{ name[:start] }}<mark>{{ name[start:end] }}</mark>{{ name[end:] }}</span>{% else %}<span class="trunc-name">{{ name }}</span>{% endif %}{% else %}<span class="trunc-name">{{ name }}</span>{% endif %} {% if diagnostics and it.has_fallback_description %}<span class="theme-badge badge-fallback" title="Fallback description">⚠</span>{% endif %}
|
||||
{% if diagnostics and it.editorial_quality %}
|
||||
<span class="theme-badge badge-quality-{{ it.editorial_quality }}" title="Editorial quality: {{ it.editorial_quality }}">{{ it.editorial_quality[0]|upper }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{% if it.primary_color %}<span aria-label="Primary color: {{ it.primary_color }}">{{ it.primary_color }}</span>{% endif %}</td>
|
||||
<td>{% if it.secondary_color %}<span aria-label="Secondary color: {{ it.secondary_color }}">{{ it.secondary_color }}</span>{% endif %}</td>
|
||||
<td>
|
||||
{% if it.popularity_bucket %}
|
||||
<span class="theme-badge {% if it.popularity_bucket=='Very Common' %}badge-pop-vc{% elif it.popularity_bucket=='Common' %}badge-pop-c{% elif it.popularity_bucket=='Uncommon' %}badge-pop-u{% elif it.popularity_bucket=='Niche' %}badge-pop-n{% elif it.popularity_bucket=='Rare' %}badge-pop-r{% endif %}" title="Popularity: {{ it.popularity_bucket }}">{{ it.popularity_bucket }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ it.deck_archetype or '' }}</td>
|
||||
<td>
|
||||
<div class="theme-synergies">
|
||||
{% for s in it.synergies %}<span class="theme-badge">{{ s }}</span>{% endfor %}
|
||||
{% if it.synergies_capped %}<span class="theme-badge" title="Additional synergies hidden">…</span>{% endif %}
|
||||
</div>
|
||||
<div style="margin-top:4px;">
|
||||
<button
|
||||
data-preview-btn
|
||||
data-theme-id="{{ it.id }}"
|
||||
hx-get="/themes/fragment/preview/{{ it.id }}"
|
||||
hx-target="#theme-preview-modal"
|
||||
hx-swap="innerHTML"
|
||||
onclick="(function(){var m=document.getElementById('theme-preview-modal'); if(!m){ m=document.createElement('div'); m.id='theme-preview-modal'; m.className='preview-modal'; m.innerHTML='<div class=\'preview-modal-content\'>Loading…</div>'; document.body.appendChild(m);} m.style.display='block';})();"
|
||||
style="font-size:10px; padding:2px 6px; margin-top:2px;">Preview</button>
|
||||
{% if it.synergies_capped %}
|
||||
<button
|
||||
hx-get="/themes/fragment/detail/{{ it.id }}"
|
||||
hx-target="#theme-detail"
|
||||
hx-swap="innerHTML"
|
||||
title="Show full synergy list in details panel"
|
||||
style="font-size:10px; padding:2px 6px; margin-top:2px;">+ All</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="pager" style="display:flex; justify-content:space-between; align-items:center; margin-top:.5rem; font-size:12px;">
|
||||
<div>
|
||||
Showing {{ offset + 1 }}–{{ (offset + items|length) }} of {{ total }}
|
||||
</div>
|
||||
<div class="pager-buttons" style="display:flex; gap:.4rem;">
|
||||
{% if prev_offset is not none %}
|
||||
<button hx-get="/themes/fragment/list?offset={{ prev_offset }}&limit={{ limit }}" hx-target="#theme-results" hx-swap="innerHTML" class="btn btn-ghost" style="font-size:11px; padding:2px 8px;">« Prev</button>
|
||||
{% else %}
|
||||
<button disabled class="btn btn-ghost" style="opacity:.3; font-size:11px; padding:2px 8px;">« Prev</button>
|
||||
{% endif %}
|
||||
{% if next_offset is not none %}
|
||||
<button hx-get="/themes/fragment/list?offset={{ next_offset }}&limit={{ limit }}" hx-target="#theme-results" hx-swap="innerHTML" class="btn btn-ghost" style="font-size:11px; padding:2px 8px;">Next »</button>
|
||||
{% else %}
|
||||
<button disabled class="btn btn-ghost" style="opacity:.3; font-size:11px; padding:2px 8px;">Next »</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div id="theme-detail" class="theme-detail" style="margin-top:1rem;">Select a theme above to view details.</div>
|
||||
<script>
|
||||
// Enhance preview button with sessionStorage fragment cache (ETag aware) + structured logs
|
||||
(function(){
|
||||
try {
|
||||
var store = window.sessionStorage;
|
||||
var buttons = document.querySelectorAll('button[data-preview-btn]');
|
||||
function log(ev){ if(!window.THEME_DIAG_ENABLED) return; try { fetch('/themes/log',{method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({event:ev, ts:Date.now()})}); } catch(_e){} }
|
||||
buttons.forEach(function(btn){
|
||||
if(btn.getAttribute('data-cache-enhanced')) return;
|
||||
btn.setAttribute('data-cache-enhanced','1');
|
||||
btn.addEventListener('click', function(ev){
|
||||
var theme = btn.getAttribute('data-theme-id');
|
||||
if(!theme) return;
|
||||
var key = 'preview:'+theme+':limit12';
|
||||
try {
|
||||
var cached = store.getItem(key);
|
||||
if(cached){
|
||||
var parsed = JSON.parse(cached);
|
||||
if(parsed && parsed.html && parsed.etag){
|
||||
log('cache_hit');
|
||||
// Optimistic render cached first, then revalidate
|
||||
var host = document.getElementById('theme-preview-modal');
|
||||
if(host){ host.innerHTML = parsed.html; }
|
||||
fetch('/themes/fragment/preview/'+theme, { headers:{'If-None-Match': parsed.etag}}).then(function(r){
|
||||
if(r.status === 304) return null; return r.text().then(function(ht){ return {ht:ht, et:r.headers.get('ETag')}; });
|
||||
}).then(function(obj){ if(!obj) return; var host2=document.getElementById('theme-preview-modal'); if(host2){ host2.innerHTML=obj.ht; } store.setItem(key, JSON.stringify({html: obj.ht, etag: obj.et || ''})); }).catch(function(){});
|
||||
return; // short-circuit default htmx fetch (we already handled)
|
||||
}
|
||||
}
|
||||
log('cache_miss');
|
||||
} catch(_e){}
|
||||
// No cache path: allow htmx; hook after swap to store
|
||||
document.addEventListener('htmx:afterSwap', function handler(e){
|
||||
if(e.target && e.target.id==='theme-preview-modal'){
|
||||
try {
|
||||
var et = e.detail.xhr.getResponseHeader('ETag') || '';
|
||||
store.setItem(key, JSON.stringify({html: e.target.innerHTML, etag: et}));
|
||||
} catch(_){}
|
||||
document.removeEventListener('htmx:afterSwap', handler);
|
||||
}
|
||||
});
|
||||
}, {capture:true});
|
||||
});
|
||||
} catch(_err){}
|
||||
})();
|
||||
</script>
|
||||
{% else %}
|
||||
{% if total == 0 %}
|
||||
<div class="empty">No themes match your filters.</div>
|
||||
{% else %}
|
||||
<div class="skeleton-table" style="display:flex; flex-direction:column; gap:6px;">
|
||||
{% for i in range(6) %}
|
||||
<div class="skeleton-row" style="display:grid; grid-template-columns:22% 10% 10% 12% 12% 1fr; gap:8px; align-items:center;">
|
||||
<div class="sk-cell sk-wide" style="height:14px; background:var(--hover); border-radius:4px;"></div>
|
||||
<div class="sk-cell" style="height:14px; background:var(--hover); border-radius:4px;"></div>
|
||||
<div class="sk-cell" style="height:14px; background:var(--hover); border-radius:4px;"></div>
|
||||
<div class="sk-cell" style="height:14px; background:var(--hover); border-radius:4px;"></div>
|
||||
<div class="sk-cell" style="height:14px; background:var(--hover); border-radius:4px;"></div>
|
||||
<div class="sk-cell sk-long" style="height:18px; background:var(--hover); border-radius:4px;"></div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
44
code/web/templates/themes/list_simple_fragment.html
Normal file
44
code/web/templates/themes/list_simple_fragment.html
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
{% if items %}
|
||||
<div class="pager" style="display:flex; justify-content:space-between; align-items:center; margin-bottom:.5rem; font-size:12px;">
|
||||
<div>Showing {{ offset + 1 }}–{{ (offset + items|length) }} of {{ total }}</div>
|
||||
<div style="display:flex; gap:.4rem;">
|
||||
{% if prev_offset is not none %}
|
||||
<button hx-get="/themes/fragment/list_simple?offset={{ prev_offset }}&limit={{ limit }}" hx-target="#theme-results" hx-swap="innerHTML" class="btn btn-ghost" style="font-size:11px; padding:2px 8px;">« Prev</button>
|
||||
{% else %}<button disabled class="btn btn-ghost" style="opacity:.3; font-size:11px; padding:2px 8px;">« Prev</button>{% endif %}
|
||||
{% if next_offset is not none %}
|
||||
<button hx-get="/themes/fragment/list_simple?offset={{ next_offset }}&limit={{ limit }}" hx-target="#theme-results" hx-swap="innerHTML" class="btn btn-ghost" style="font-size:11px; padding:2px 8px;">Next »</button>
|
||||
{% else %}<button disabled class="btn btn-ghost" style="opacity:.3; font-size:11px; padding:2px 8px;">Next »</button>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<ul class="theme-simple-list" style="list-style:none; padding:0; margin:0; display:flex; flex-direction:column; gap:.65rem;">
|
||||
{% for it in items %}
|
||||
<li style="padding:.6rem .75rem; border:1px solid var(--border); border-radius:8px; background:var(--panel-alt);">
|
||||
<a href="/themes/{{ it.id }}" style="font-weight:600; font-size:14px; text-decoration:none; color:var(--text);">{{ it.theme }}</a>
|
||||
{% if it.short_description %}<div style="font-size:12px; opacity:.85; margin-top:2px;">{{ it.short_description }}</div>{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<div class="pager" style="display:flex; justify-content:space-between; align-items:center; margin-top:.75rem; font-size:12px;">
|
||||
<div>Showing {{ offset + 1 }}–{{ (offset + items|length) }} of {{ total }}</div>
|
||||
<div style="display:flex; gap:.4rem;">
|
||||
{% if prev_offset is not none %}
|
||||
<button hx-get="/themes/fragment/list_simple?offset={{ prev_offset }}&limit={{ limit }}" hx-target="#theme-results" hx-swap="innerHTML" class="btn btn-ghost" style="font-size:11px; padding:2px 8px;">« Prev</button>
|
||||
{% else %}<button disabled class="btn btn-ghost" style="opacity:.3; font-size:11px; padding:2px 8px;">« Prev</button>{% endif %}
|
||||
{% if next_offset is not none %}
|
||||
<button hx-get="/themes/fragment/list_simple?offset={{ next_offset }}&limit={{ limit }}" hx-target="#theme-results" hx-swap="innerHTML" class="btn btn-ghost" style="font-size:11px; padding:2px 8px;">Next »</button>
|
||||
{% else %}<button disabled class="btn btn-ghost" style="opacity:.3; font-size:11px; padding:2px 8px;">Next »</button>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
{% if total == 0 %}
|
||||
<div class="empty" style="font-size:13px;">No themes found.</div>
|
||||
{% else %}
|
||||
<div style="display:flex; flex-direction:column; gap:8px;">
|
||||
{% for i in range(8) %}<div style="height:48px; border-radius:8px; background:linear-gradient(90deg,var(--panel-alt) 25%,var(--hover) 50%,var(--panel-alt) 75%); background-size:200% 100%; animation: sk 1.2s ease-in-out infinite;"></div>{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<style>
|
||||
@keyframes sk {0%{background-position:0 0;}100%{background-position:-200% 0;}}
|
||||
.theme-simple-list li:hover { background:var(--hover); }
|
||||
</style>
|
||||
403
code/web/templates/themes/picker.html
Normal file
403
code/web/templates/themes/picker.html
Normal file
|
|
@ -0,0 +1,403 @@
|
|||
{% extends 'base.html' %}
|
||||
{% block content %}
|
||||
<h2 style="position:relative;">Theme Catalog <small id="theme-stale-indicator" style="display:none; font-size:12px; color:#b45309;">(Refreshing…)</small></h2>
|
||||
<div id="theme-picker" class="theme-picker" hx-get="/themes/fragment/list?limit=20&offset=0" hx-trigger="load" hx-target="#theme-results" hx-swap="innerHTML" role="region" aria-label="Theme picker">
|
||||
<div class="theme-picker-controls">
|
||||
<input type="text" id="theme-search" placeholder="Search themes or synergies" aria-label="Search"
|
||||
hx-get="/themes/fragment/list" hx-target="#theme-results" hx-trigger="keyup changed delay:250ms" name="q" />
|
||||
<select id="theme-archetype" name="archetype" hx-get="/themes/fragment/list" hx-target="#theme-results" hx-trigger="change">
|
||||
<option value="">All Archetypes</option>
|
||||
{% if archetypes %}{% for a in archetypes %}<option value="{{ a }}">{{ a }}</option>{% endfor %}{% endif %}
|
||||
</select>
|
||||
<select id="theme-bucket" name="bucket" hx-get="/themes/fragment/list" hx-target="#theme-results" hx-trigger="change">
|
||||
<option value="">All Popularity</option>
|
||||
<option>Very Common</option>
|
||||
<option>Common</option>
|
||||
<option>Uncommon</option>
|
||||
<option>Niche</option>
|
||||
<option>Rare</option>
|
||||
</select>
|
||||
<select id="theme-limit" name="limit" hx-get="/themes/fragment/list" hx-target="#theme-results" hx-trigger="change" title="Themes per page">
|
||||
<option value="20" selected>20</option>
|
||||
<option value="30">30</option>
|
||||
<option value="50">50</option>
|
||||
<option value="75">75</option>
|
||||
<option value="100">100</option>
|
||||
</select>
|
||||
<label title="Show full synergy list (diagnostics only)"><input type="checkbox" id="synergy-full" name="synergy_mode" value="full" hx-get="/themes/fragment/list" hx-target="#theme-results" hx-trigger="change"/> Full Synergies</label>
|
||||
<label title="Search input responsiveness experiment">
|
||||
Mode:
|
||||
<select id="search-mode">
|
||||
<option value="throttle" selected>Throttle 250ms</option>
|
||||
<option value="debounce">Debounce 250ms</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="color-filters" role="group" aria-label="Filter by primary/secondary color">
|
||||
{% for c in ['W','U','B','R','G'] %}
|
||||
<label><input type="checkbox" name="colors" value="{{ c }}"
|
||||
hx-get="/themes/fragment/list" hx-target="#theme-results" hx-trigger="change"/> {{ c }}</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if theme_picker_diagnostics %}
|
||||
<label title="Show diagnostics-only badges"><input type="checkbox" id="diag-toggle" name="diagnostics" value="1" hx-get="/themes/fragment/list" hx-target="#theme-results" hx-trigger="change"/> Diagnostics</label>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div id="filter-chips" class="filter-chips" aria-label="Active filters" style="margin-bottom:.25rem;"></div>
|
||||
<div id="theme-results" class="theme-results preload-hide" aria-live="polite" aria-busy="true" role="listbox" aria-label="Loading themes">
|
||||
<div class="skeleton-table" aria-hidden="true">
|
||||
{% for i in range(6) %}
|
||||
<div class="skeleton-row skeleton-align">
|
||||
<div class="sk-cell sk-col sk-theme"></div>
|
||||
<div class="sk-cell sk-col sk-arch"></div>
|
||||
<div class="sk-cell sk-col sk-pop"></div>
|
||||
<div class="sk-cell sk-col sk-colors"></div>
|
||||
<div class="sk-cell sk-col sk-cnt"></div>
|
||||
<div class="sk-cell sk-col sk-synergies"></div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<template id="theme-row-template"></template>
|
||||
<div class="legend" style="margin-top:1rem; font-size:12px; line-height:1.3;">
|
||||
<strong>Legend:</strong>
|
||||
<span class="theme-badge badge-enforced" title="Enforced synergy (whitelist governance)">ENF</span>
|
||||
<span class="theme-badge badge-curated" title="Curated synergy (hand authored)">CUR</span>
|
||||
<span class="theme-badge badge-inferred" title="Inferred synergy (analytics)">INF</span>
|
||||
<span class="theme-badge badge-pop-vc" title="Popularity: Very Common">VC</span>
|
||||
<span class="theme-badge badge-pop-c" title="Popularity: Common">C</span>
|
||||
<span class="theme-badge badge-pop-u" title="Popularity: Uncommon">U</span>
|
||||
<span class="theme-badge badge-pop-n" title="Popularity: Niche">N</span>
|
||||
<span class="theme-badge badge-pop-r" title="Popularity: Rare">R</span>
|
||||
{% if theme_picker_diagnostics %}
|
||||
<span class="theme-badge badge-fallback" title="Generic fallback description">⚠</span>
|
||||
<span class="theme-badge badge-quality-draft" title="Editorial quality: draft">D</span>
|
||||
<span class="theme-badge badge-quality-reviewed" title="Editorial quality: reviewed">R</span>
|
||||
<span class="theme-badge badge-quality-final" title="Editorial quality: final">F</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div id="theme-preview-host"></div>
|
||||
<div id="filter-chips" class="filter-chips" aria-label="Active filters"></div>
|
||||
</div>
|
||||
<style>
|
||||
.theme-picker-controls { display:flex; flex-wrap:wrap; gap:.5rem; margin-bottom:.75rem; }
|
||||
.theme-results table { width:100%; border-collapse: collapse; }
|
||||
.theme-results th, .theme-results td { padding:.35rem .5rem; border-bottom:1px solid var(--border); font-size:13px; }
|
||||
.theme-results tr:hover { background: var(--hover); cursor:pointer; }
|
||||
:root { --focus:#6366f1; }
|
||||
@media (prefers-contrast: more){ :root { --focus:#ff9800; } }
|
||||
.theme-row.is-active { outline:2px solid var(--focus); outline-offset:-2px; background:var(--hover); }
|
||||
/* Long theme name truncation */
|
||||
.theme-results td:first-child { max-width:260px; }
|
||||
.theme-results td:first-child span.trunc-name { display:inline-block; max-width:240px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; vertical-align:bottom; }
|
||||
/* Badge wrapping heuristics */
|
||||
.theme-synergies .theme-badge { max-width:120px; overflow:hidden; text-overflow:ellipsis; }
|
||||
.theme-synergies { font-size:11px; opacity:.85; display:flex; flex-wrap:wrap; gap:4px; }
|
||||
.theme-badge { display:inline-block; padding:2px 6px; border-radius:12px; font-size:10px; background: var(--panel-alt); border:1px solid var(--border); letter-spacing:.5px; }
|
||||
.badge-fallback { background:#7f1d1d; color:#fff; }
|
||||
.badge-quality-draft { background:#4338ca; color:#fff; }
|
||||
.badge-quality-reviewed { background:#065f46; color:#fff; }
|
||||
.badge-quality-final { background:#065f46; color:#fff; font-weight:600; }
|
||||
.badge-pop-vc { background:#065f46; color:#fff; }
|
||||
.badge-pop-c { background:#047857; color:#fff; }
|
||||
.badge-pop-u { background:#0369a1; color:#fff; }
|
||||
.badge-pop-n { background:#92400e; color:#fff; }
|
||||
.badge-pop-r { background:#7f1d1d; color:#fff; }
|
||||
.badge-curated { background:#4f46e5; color:#fff; }
|
||||
.badge-enforced { background:#334155; color:#fff; }
|
||||
.badge-inferred { background:#57534e; color:#fff; }
|
||||
/* Preview modal */
|
||||
.preview-modal { position:fixed; inset:0; background:rgba(0,0,0,0.55); display:flex; align-items:flex-start; justify-content:center; padding:4vh 2vw; z-index:9000; }
|
||||
.preview-modal-content { background:var(--panel); padding:1rem; border-radius:8px; max-width:900px; width:100%; max-height:88vh; overflow:auto; box-shadow:0 4px 18px rgba(0,0,0,0.4); }
|
||||
/* Skeleton */
|
||||
.skeleton-table { display:flex; flex-direction:column; gap:6px; }
|
||||
.skeleton-row { display:grid; grid-template-columns:22% 10% 10% 12% 12% 1fr; gap:8px; align-items:center; }
|
||||
.sk-cell { height:14px; background:linear-gradient(90deg, var(--panel-alt) 25%, var(--hover) 50%, var(--panel-alt) 75%); background-size:200% 100%; animation: sk 1.2s ease-in-out infinite; border-radius:4px; opacity:.7; }
|
||||
.skeleton-align .sk-col { height:14px; }
|
||||
.sk-synergies { height:18px; }
|
||||
/* New UX additions */
|
||||
.filter-chips { display:flex; gap:6px; flex-wrap:wrap; margin-top:.35rem; }
|
||||
.filter-chip { background:var(--panel-alt); border:1px solid var(--border); padding:2px 8px; border-radius:14px; font-size:11px; cursor:pointer; display:inline-flex; align-items:center; gap:4px; }
|
||||
.filter-chip button { background:none; border:none; color:inherit; cursor:pointer; font-size:11px; padding:0; line-height:1; }
|
||||
mark { background:#fde68a; color:inherit; padding:0 2px; border-radius:2px; }
|
||||
@keyframes sk {0%{background-position:0 0;}100%{background-position:-200% 0;}}
|
||||
</style>
|
||||
<script>
|
||||
(function(){
|
||||
function serializeFilters(container){
|
||||
var params = [];
|
||||
var qs = container.querySelector('#theme-search');
|
||||
if(qs && qs.value.trim()){ params.push('q='+encodeURIComponent(qs.value.trim())); }
|
||||
var as = container.querySelector('#theme-archetype');
|
||||
if(as && as.value) params.push('archetype='+encodeURIComponent(as.value));
|
||||
var bs = container.querySelector('#theme-bucket');
|
||||
if(bs && bs.value) params.push('bucket='+encodeURIComponent(bs.value));
|
||||
var lim = container.querySelector('#theme-limit');
|
||||
if(lim && lim.value) params.push('limit='+encodeURIComponent(lim.value));
|
||||
var diag = container.querySelector('#diag-toggle');
|
||||
if(diag && diag.checked) params.push('diagnostics=1');
|
||||
var syFull = container.querySelector('#synergy-full');
|
||||
if(syFull && syFull.checked) params.push('synergy_mode=full');
|
||||
var colorChecks = container.querySelectorAll('input[name="colors"]:checked');
|
||||
if(colorChecks.length){
|
||||
var vals = Array.prototype.map.call(colorChecks, c=>c.value).join(',');
|
||||
params.push('colors='+encodeURIComponent(vals));
|
||||
}
|
||||
return params.join('&');
|
||||
}
|
||||
var perfMarks={}; function mark(n){ perfMarks[n]=performance.now(); }
|
||||
function fetchList(){
|
||||
saveScroll();
|
||||
var container = document.getElementById('theme-picker');
|
||||
if(!container) return;
|
||||
var target = document.getElementById('theme-results');
|
||||
if(!target) return;
|
||||
// Abort any in-flight request (resilience: rapid search)
|
||||
if(window.__themeListAbort){ try { window.__themeListAbort.abort(); } catch(_e){} }
|
||||
var controller = new AbortController();
|
||||
window.__themeListAbort = controller;
|
||||
target.setAttribute('aria-busy','true');
|
||||
target.setAttribute('aria-label','Loading themes');
|
||||
mark('list_render_start');
|
||||
var base = serializeFilters(container);
|
||||
if(base.indexOf('offset=') === -1){ base += (base ? '&' : '') + 'offset=0'; }
|
||||
toggleRefreshBtn(true);
|
||||
fetch('/themes/fragment/list?'+base, {cache:'no-store', signal: controller.signal})
|
||||
.then(r=>r.text())
|
||||
.then(html=>{ if(controller.signal.aborted) return; target.innerHTML = html; target.removeAttribute('aria-busy'); target.classList.remove('preload-hide'); listRenderComplete(); toggleRefreshBtn(false); })
|
||||
.catch(err=>{ if(controller.signal.aborted) return; target.innerHTML = '<div class="error" role="alert">Failed loading themes. <button id="retry-fetch" class="btn btn-ghost">Retry</button></div>'; target.removeAttribute('aria-busy'); target.classList.remove('preload-hide'); attachRetry(); structuredLog('list_fetch_error'); announceResultCount(); toggleRefreshBtn(false); });
|
||||
}
|
||||
function attachRetry(){ var b=document.getElementById('retry-fetch'); if(!b) return; b.addEventListener('click', function(){ fetchList(); }); }
|
||||
function listRenderComplete(){ mark('list_ready'); announceResultCount(); if(window.THEME_DIAG_ENABLED){ try { var dur=perfMarks.list_ready - perfMarks.list_render_start; if(navigator.sendBeacon){ navigator.sendBeacon('/themes/metrics/client', new Blob([JSON.stringify({events:[{name:'list_render', duration_ms:Math.round(dur)}]})], {type:'application/json'})); } } catch(_e){} } }
|
||||
function injectPrefetchLinks(){
|
||||
try {
|
||||
var head=document.head; if(!head) return;
|
||||
// Remove old dynamic prefetch links
|
||||
Array.from(head.querySelectorAll('link[data-dynamic-prefetch]')).forEach(l=>l.remove());
|
||||
// Choose top 5 rows (skip currently selected to bias exploration)
|
||||
var rows = document.querySelectorAll('#theme-results tr.theme-row[data-theme-id]');
|
||||
var current = new URL(window.location.href).searchParams.get('theme');
|
||||
var picked=[]; rows.forEach(r=>{ var id=r.getAttribute('data-theme-id'); if(!id) return; if(id===current) return; if(picked.length<5) picked.push(id); });
|
||||
picked.forEach(function(id){ var link=document.createElement('link'); link.rel='prefetch'; link.href='/themes/fragment/detail/'+id; link.as='fetch'; link.setAttribute('data-dynamic-prefetch','1'); head.appendChild(link); });
|
||||
} catch(e) {}
|
||||
}
|
||||
function structuredLog(ev){ if(!window.THEME_DIAG_ENABLED) return; try { fetch('/themes/log',{method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({event:ev, ts:Date.now()})}); } catch(_e){} }
|
||||
document.addEventListener('htmx:afterOnLoad', function(ev){
|
||||
if(ev.target && ev.target.id==='theme-results'){
|
||||
var rows = ev.target.querySelectorAll('tr.theme-row[data-theme-id]');
|
||||
rows.forEach(function(r){
|
||||
var id = r.getAttribute('data-theme-id');
|
||||
if(!id) return;
|
||||
r.addEventListener('click', function(){
|
||||
var url = new URL(window.location.href);
|
||||
url.searchParams.set('theme', id);
|
||||
var filters = serializeFilters(document.getElementById('theme-picker'));
|
||||
if(filters){ url.searchParams.set('filters', filters); }
|
||||
history.pushState({ theme:id, filters: filters }, '', url.toString()); window._lastThemeFocusId=id;
|
||||
}, { once:false });
|
||||
var prefetchTimer;
|
||||
r.addEventListener('mouseenter', function(){
|
||||
clearTimeout(prefetchTimer);
|
||||
prefetchTimer = setTimeout(function(){
|
||||
fetch('/themes/fragment/detail/'+id+'?'+(document.getElementById('diag-toggle')?.checked?'diagnostics=1':''), {cache:'force-cache'}).then(function(resp){ structuredLog('prefetch_success'); return resp.text(); }).then(function(_html){ }).catch(function(){ structuredLog('prefetch_error'); });
|
||||
}, 180);
|
||||
});
|
||||
r.addEventListener('mouseleave', function(){ clearTimeout(prefetchTimer); });
|
||||
});
|
||||
var current = new URL(window.location.href).searchParams.get('theme');
|
||||
if(current && !document.getElementById('theme-detail')?.dataset?.loaded){
|
||||
htmx.ajax('GET', '/themes/fragment/detail/'+current, '#theme-detail');
|
||||
}
|
||||
// Restore focus to previously active row if available
|
||||
if(window._lastThemeFocusId){
|
||||
var targetRow = ev.target.querySelector('tr.theme-row[data-theme-id="'+window._lastThemeFocusId+'"]');
|
||||
if(targetRow){ targetRow.focus({preventScroll:false}); }
|
||||
}
|
||||
buildFilterChips();
|
||||
injectPrefetchLinks();
|
||||
restoreScroll();
|
||||
enableKeyboardNav();
|
||||
}
|
||||
});
|
||||
window.addEventListener('popstate', function(ev){
|
||||
var state = ev.state || {};
|
||||
if(state.filters){
|
||||
var params = new URLSearchParams(state.filters);
|
||||
var container = document.getElementById('theme-picker');
|
||||
if(container){
|
||||
if(params.get('q')) container.querySelector('#theme-search').value = decodeURIComponent(params.get('q'));
|
||||
if(params.get('archetype')) container.querySelector('#theme-archetype').value = decodeURIComponent(params.get('archetype'));
|
||||
if(params.get('bucket')) container.querySelector('#theme-bucket').value = decodeURIComponent(params.get('bucket'));
|
||||
if(params.get('limit')) container.querySelector('#theme-limit').value = decodeURIComponent(params.get('limit'));
|
||||
var colorStr = params.get('colors');
|
||||
if(colorStr){
|
||||
var set = new Set(colorStr.split(','));
|
||||
container.querySelectorAll('input[name="colors"]').forEach(function(cb){ cb.checked = set.has(cb.value); });
|
||||
}
|
||||
if(params.get('diagnostics')==='1'){ var diag=container.querySelector('#diag-toggle'); if(diag){ diag.checked=true; } }
|
||||
fetchList();
|
||||
}
|
||||
}
|
||||
if(state.theme){
|
||||
htmx.ajax('GET', '/themes/fragment/detail/'+state.theme, '#theme-detail');
|
||||
}
|
||||
});
|
||||
window.addEventListener('load', function(){
|
||||
var url = new URL(window.location.href);
|
||||
var filters = url.searchParams.get('filters');
|
||||
if(filters){
|
||||
var params = new URLSearchParams(filters);
|
||||
var container = document.getElementById('theme-picker');
|
||||
if(container){
|
||||
if(params.get('q')) container.querySelector('#theme-search').value = decodeURIComponent(params.get('q'));
|
||||
if(params.get('archetype')) container.querySelector('#theme-archetype').value = decodeURIComponent(params.get('archetype'));
|
||||
if(params.get('bucket')) container.querySelector('#theme-bucket').value = decodeURIComponent(params.get('bucket'));
|
||||
if(params.get('limit')) container.querySelector('#theme-limit').value = decodeURIComponent(params.get('limit'));
|
||||
var colorStr = params.get('colors');
|
||||
if(colorStr){
|
||||
var set = new Set(colorStr.split(','));
|
||||
container.querySelectorAll('input[name="colors"]').forEach(function(cb){ cb.checked = set.has(cb.value); });
|
||||
}
|
||||
if(params.get('diagnostics')==='1'){ var diag=container.querySelector('#diag-toggle'); if(diag){ diag.checked=true; } }
|
||||
}
|
||||
}
|
||||
var theme = url.searchParams.get('theme');
|
||||
if(theme){ htmx.ajax('GET','/themes/fragment/detail/'+theme,'#theme-detail'); }
|
||||
window.THEME_DIAG_ENABLED = !!document.getElementById('diag-toggle');
|
||||
}, { once:true });
|
||||
var lastScroll = 0;
|
||||
function saveScroll(){ lastScroll = window.scrollY || document.documentElement.scrollTop; }
|
||||
function restoreScroll(){ if(typeof lastScroll === 'number'){ window.scrollTo(0, lastScroll); } }
|
||||
function buildFilterChips(){
|
||||
var host = document.getElementById('filter-chips');
|
||||
if(!host) return;
|
||||
host.innerHTML='';
|
||||
var container = document.getElementById('theme-picker');
|
||||
if(!container) return;
|
||||
var q = container.querySelector('#theme-search').value.trim();
|
||||
var archetype = container.querySelector('#theme-archetype').value;
|
||||
var bucket = container.querySelector('#theme-bucket').value;
|
||||
var colors = Array.from(container.querySelectorAll('input[name="colors"]:checked')).map(c=>c.value).join(',');
|
||||
var diag = container.querySelector('#diag-toggle')?.checked;
|
||||
function addChip(label, key){
|
||||
var chip = document.createElement('span');
|
||||
chip.className='filter-chip';
|
||||
chip.innerHTML = '<span>'+label+'</span><button aria-label="Remove '+key+'">×</button>';
|
||||
chip.querySelector('button').addEventListener('click', function(){
|
||||
if(key==='q'){ container.querySelector('#theme-search').value=''; }
|
||||
if(key==='archetype'){ container.querySelector('#theme-archetype').value=''; }
|
||||
if(key==='bucket'){ container.querySelector('#theme-bucket').value=''; }
|
||||
if(key==='colors'){ container.querySelectorAll('input[name="colors"]').forEach(cb=>cb.checked=false); }
|
||||
if(key==='diagnostics'){ var d=container.querySelector('#diag-toggle'); if(d) d.checked=false; }
|
||||
fetchList();
|
||||
});
|
||||
host.appendChild(chip);
|
||||
}
|
||||
if(q) addChip('Search: '+q, 'q');
|
||||
if(archetype) addChip('Archetype: '+archetype, 'archetype');
|
||||
if(bucket) addChip('Popularity: '+bucket, 'bucket');
|
||||
if(colors) addChip('Colors: '+colors, 'colors');
|
||||
if(diag) addChip('Diagnostics', 'diagnostics');
|
||||
var syFull = container.querySelector('#synergy-full')?.checked; if(syFull) addChip('Full Synergies','synergy_mode');
|
||||
}
|
||||
function enableKeyboardNav(){
|
||||
var tbody = document.querySelector('#theme-results tbody');
|
||||
if(!tbody) return;
|
||||
var rows = Array.from(tbody.querySelectorAll('tr.theme-row'));
|
||||
if(!rows.length) return;
|
||||
var activeIndex = -1;
|
||||
function setActive(i){ rows.forEach(r=>r.classList.remove('is-active')); if(i>=0 && rows[i]){ rows[i].classList.add('is-active'); rows[i].focus({preventScroll:true}); activeIndex=i; } }
|
||||
document.addEventListener('keydown', function(e){
|
||||
if(['ArrowDown','ArrowUp','Enter','Escape'].indexOf(e.key)===-1) return;
|
||||
if(e.key==='ArrowDown'){ e.preventDefault(); setActive(Math.min(activeIndex+1, rows.length-1)); }
|
||||
else if(e.key==='ArrowUp'){ e.preventDefault(); setActive(Math.max(activeIndex-1, 0)); }
|
||||
else if(e.key==='Enter'){ if(activeIndex>=0){ rows[activeIndex].click(); } }
|
||||
else if(e.key==='Escape'){ setActive(-1); var detail=document.getElementById('theme-detail'); if(detail){ detail.innerHTML='Selection cleared.'; detail.setAttribute('aria-live','polite'); } }
|
||||
}, { once:false });
|
||||
document.addEventListener('keydown', function(e){
|
||||
if(e.key==='Enter' && e.shiftKey){ var cb=document.getElementById('synergy-full'); if(cb){ cb.checked=!cb.checked; fetchList(); } }
|
||||
});
|
||||
}
|
||||
var searchInput = document.getElementById('theme-search');
|
||||
var searchModeSel = document.getElementById('search-mode');
|
||||
var lastExec = 0; var pendingTimer = null; var BASE_DELAY = 250;
|
||||
function performSearch(){ fetchList(); }
|
||||
function throttledHandler(){ var now=Date.now(); if(now - lastExec > BASE_DELAY){ lastExec = now; performSearch(); } }
|
||||
function debouncedHandler(){ clearTimeout(pendingTimer); pendingTimer = setTimeout(performSearch, BASE_DELAY); }
|
||||
function attachSearchHandler(){
|
||||
if(!searchInput) return;
|
||||
searchInput.removeEventListener('keyup', throttledHandler);
|
||||
searchInput.removeEventListener('keyup', debouncedHandler);
|
||||
if(searchModeSel && searchModeSel.value==='debounce'){
|
||||
searchInput.addEventListener('keyup', debouncedHandler);
|
||||
} else {
|
||||
searchInput.addEventListener('keyup', throttledHandler);
|
||||
}
|
||||
}
|
||||
if(searchModeSel){ searchModeSel.addEventListener('change', attachSearchHandler); }
|
||||
attachSearchHandler();
|
||||
function toggleRefreshBtn(dis){ var btn=document.getElementById('catalog-refresh-btn'); if(btn){ if(dis){ btn.setAttribute('disabled','disabled'); btn.setAttribute('aria-busy','true'); } else { btn.removeAttribute('disabled'); btn.removeAttribute('aria-busy'); } } }
|
||||
function checkStatus(){
|
||||
fetch('/themes/status',{cache:'no-store'}).then(r=>r.json()).then(js=>{
|
||||
if(js.stale){
|
||||
var ind=document.getElementById('theme-stale-indicator'); if(ind){ ind.style.display='inline'; }
|
||||
toggleRefreshBtn(true);
|
||||
fetch('/themes/refresh',{method:'POST'}).then(()=>{
|
||||
var attempts=0; var max=20; var iv=setInterval(()=>{
|
||||
fetch('/themes/status',{cache:'no-store'}).then(r=>r.json()).then(s2=>{
|
||||
if(!s2.stale){ clearInterval(iv); if(ind) ind.style.display='none'; fetchList(); }
|
||||
}).catch(()=>{});
|
||||
attempts++; if(attempts>max){ clearInterval(iv); }
|
||||
},1500);
|
||||
}).finally(()=>{ toggleRefreshBtn(false); });
|
||||
}
|
||||
}).catch(()=>{});
|
||||
}
|
||||
function announceResultCount(){ var tbody=document.querySelector('#theme-results tbody'); if(!tbody) return; var count=tbody.querySelectorAll('tr.theme-row').length; var host=document.getElementById('theme-results'); if(host){ host.setAttribute('aria-label', count+' themes'); } }
|
||||
window.addEventListener('load', checkStatus, {once:true});
|
||||
})();
|
||||
// Preview modal retry/backoff & logging
|
||||
(function(){
|
||||
document.addEventListener('click', function(e){ var btn=e.target.closest('button[data-preview-btn]'); if(!btn) return; var theme=btn.getAttribute('data-theme-id'); if(!theme) return; setTimeout(function(){ attach(theme); }, 40); });
|
||||
function attach(theme){
|
||||
var modal=document.getElementById('theme-preview-modal'); if(!modal) return;
|
||||
var cacheKey='preview:'+theme;
|
||||
var tStart = performance.now();
|
||||
// Stale-While-Revalidate: show cached HTML immediately if present
|
||||
try {
|
||||
var cached=sessionStorage.getItem(cacheKey);
|
||||
if(cached){ modal.innerHTML=cached; modal.setAttribute('data-swr','stale'); }
|
||||
} catch(_e){}
|
||||
var attempts=0,max=3,back=350; function run(){ attempts++; fetch('/themes/fragment/preview/'+theme,{cache:'no-store'}).then(function(r){ if(r.status===200) return r.text(); if(r.status===304) return ''; throw new Error('bad'); }).then(function(html){ if(!html) return; modal.innerHTML=html; structuredLog('preview_fetch_success'); try { sessionStorage.setItem(cacheKey, html); } catch(_e){} try { recordPreviewLatency(performance.now()-tStart); } catch(_e){} }).catch(function(){ structuredLog('preview_fetch_error'); try { recordPreviewLatency(performance.now()-tStart, true); } catch(_e){} if(attempts<max){ setTimeout(run, back); back*=2; } else { modal.innerHTML='<div class="preview-modal-content"><div role="alert" style="font-size:13px;">Failed to load preview.<br/><button id="retry-preview" class="btn">Retry</button></div></div>'; var rp=document.getElementById('retry-preview'); if(rp){ rp.addEventListener('click', function(){ attempts=0; back=350; run(); }); } } }); }
|
||||
if(/Loading/.test(modal.textContent||'') || modal.getAttribute('data-swr')==='stale') run();
|
||||
// Inject export buttons (single insertion guard)
|
||||
setTimeout(function(){
|
||||
try {
|
||||
if(!modal.querySelector('.preview-export-bar') && modal.querySelector('.preview-header')){
|
||||
var bar = document.createElement('div');
|
||||
bar.className='preview-export-bar';
|
||||
bar.style.cssText='margin:.5rem 0 .25rem; display:flex; gap:.5rem; flex-wrap:wrap; align-items:center; font-size:11px;';
|
||||
bar.innerHTML = '<button class="btn btn-ghost" style="font-size:11px;padding:2px 8px;" data-exp-json>Export JSON</button>'+
|
||||
'<button class="btn btn-ghost" style="font-size:11px;padding:2px 8px;" data-exp-csv>Export CSV</button>'+
|
||||
'<span style="opacity:.6;">(Respects curated toggle)</span>';
|
||||
var header = modal.querySelector('.preview-header');
|
||||
header.parentNode.insertBefore(bar, header.nextSibling);
|
||||
function curatedOnly(){ try { return localStorage.getItem('mtg:preview.curatedOnly')==='1'; } catch(_){ return false; } }
|
||||
bar.querySelector('[data-exp-json]').addEventListener('click', function(){ window.open('/themes/preview/'+encodeURIComponent(theme)+'/export.json?curated_only='+(curatedOnly()?'1':'0'),'_blank'); });
|
||||
bar.querySelector('[data-exp-csv]').addEventListener('click', function(){ window.open('/themes/preview/'+encodeURIComponent(theme)+'/export.csv?curated_only='+(curatedOnly()?'1':'0'),'_blank'); });
|
||||
}
|
||||
} catch(_e){}
|
||||
}, 120);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<style>
|
||||
#theme-results.preload-hide { visibility:hidden; }
|
||||
#catalog-refresh-btn[disabled]{ opacity:.55; cursor:progress; }
|
||||
</style>
|
||||
<script>
|
||||
// Batch preview latency beacons every 20 events
|
||||
(function(){
|
||||
var latSamples=[]; var errSamples=0; var BATCH=20; window.recordPreviewLatency=function(ms, isErr){ try { latSamples.push(ms); if(isErr) errSamples++; if(latSamples.length>=BATCH && window.THEME_DIAG_ENABLED){ var avg=Math.round(latSamples.reduce((a,b)=>a+b,0)/latSamples.length); var payload={events:[{name:'preview_load_batch', count:latSamples.length, avg_ms:avg, err:errSamples}]}; if(navigator.sendBeacon){ navigator.sendBeacon('/themes/metrics/client', new Blob([JSON.stringify(payload)],{type:'application/json'})); } latSamples=[]; errSamples=0; } } catch(_e){} };
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
350
code/web/templates/themes/preview_fragment.html
Normal file
350
code/web/templates/themes/preview_fragment.html
Normal file
|
|
@ -0,0 +1,350 @@
|
|||
{% if preview %}
|
||||
<div class="preview-modal-content theme-preview-expanded{% if minimal %} minimal-variant{% endif %}">
|
||||
{% if not minimal %}
|
||||
<div class="preview-header" style="display:flex; justify-content:space-between; align-items:center; gap:1rem;">
|
||||
<h3 style="margin:0; font-size:16px;" data-preview-heading>{{ preview.theme }}</h3>
|
||||
<button id="preview-close-btn" onclick="document.getElementById('theme-preview-modal') && document.getElementById('theme-preview-modal').remove();" class="btn btn-ghost" style="font-size:12px; line-height:1;">Close ✕</button>
|
||||
</div>
|
||||
{% if preview.stub %}<div class="note note-stub">Stub sample (placeholder logic)</div>{% endif %}
|
||||
<div class="preview-controls" style="display:flex; gap:1rem; align-items:center; margin:.5rem 0 .75rem; font-size:11px;">
|
||||
<label style="display:inline-flex; gap:4px; align-items:center;"><input type="checkbox" id="curated-only-toggle"/> Curated Only</label>
|
||||
<label style="display:inline-flex; gap:4px; align-items:center;"><input type="checkbox" id="reasons-toggle" checked/> Reasons <span style="opacity:.55; font-size:10px; cursor:help;" title="Toggle why the payoff is included (i.e. overlapping themes or other reasoning)">?</span></label>
|
||||
<span id="preview-status" aria-live="polite" style="opacity:.65;"></span>
|
||||
</div>
|
||||
<details id="preview-rationale" class="preview-rationale" style="margin:.25rem 0 .85rem; font-size:11px; background:var(--panel-alt); border:1px solid var(--border); padding:.55rem .7rem; border-radius:8px;">
|
||||
<summary style="cursor:pointer; font-weight:600; letter-spacing:.05em;">Commander Overlap & Diversity Rationale</summary>
|
||||
<div style="display:flex; flex-wrap:wrap; gap:.75rem; align-items:center; margin-top:.4rem;">
|
||||
<button type="button" class="btn btn-ghost" style="font-size:10px; padding:4px 8px;" onclick="toggleHoverCompactMode()" title="Toggle compact hover panel (smaller image & condensed metadata)">Hover Compact</button>
|
||||
<span id="hover-compact-indicator" style="font-size:10px; opacity:.7;">Mode: <span data-mode>normal</span></span>
|
||||
</div>
|
||||
<ul id="rationale-points" style="margin:.5rem 0 0 .9rem; padding:0; list-style:disc; line-height:1.35;">
|
||||
<li>Computing…</li>
|
||||
</ul>
|
||||
</details>
|
||||
{% endif %}
|
||||
<div class="two-col" style="display:grid; grid-template-columns: 1fr 480px; gap:1.25rem; align-items:start; position:relative;" role="group" aria-label="Theme preview cards and commanders">
|
||||
<div class="col-divider" style="position:absolute; top:0; bottom:0; left:calc(100% - 480px - .75rem); width:1px; background:var(--border); opacity:.55;"></div>
|
||||
<div class="col-left">
|
||||
{% if not minimal %}{% if not suppress_curated %}<h4 style="margin:.25rem 0 .5rem; font-size:13px; letter-spacing:.05em; text-transform:uppercase; opacity:.8;">Example Cards</h4>{% else %}<h4 style="margin:.25rem 0 .5rem; font-size:13px; letter-spacing:.05em; text-transform:uppercase; opacity:.8;">Sampled Synergy Cards</h4>{% endif %}{% endif %}
|
||||
<hr style="border:0; border-top:1px solid var(--border); margin:.35rem 0 .6rem;" />
|
||||
<div class="cards-flow" style="display:flex; flex-wrap:wrap; gap:10px;" data-synergies="{{ preview.synergies_used|join(',') if preview.synergies_used }}">
|
||||
{% set inserted = {'examples': False, 'curated_synergy': False, 'payoff': False, 'enabler_support': False, 'wildcard': False} %}
|
||||
{% for c in preview.sample if (not suppress_curated and ('example' in c.roles or 'curated_synergy' in c.roles)) or 'payoff' in c.roles or 'enabler' in c.roles or 'support' in c.roles or 'wildcard' in c.roles %}
|
||||
{% set primary = c.roles[0] if c.roles else '' %}
|
||||
{% if (not suppress_curated) and 'example' in c.roles and not inserted.examples %}<div class="group-separator" data-group="examples" style="flex-basis:100%; font-size:10px; text-transform:uppercase; letter-spacing:.05em; opacity:.65; margin-top:.25rem;">Curated Examples</div>{% set _ = inserted.update({'examples': True}) %}{% endif %}
|
||||
{% if (not suppress_curated) and primary == 'curated_synergy' and not inserted.curated_synergy %}<div class="group-separator" data-group="curated_synergy" style="flex-basis:100%; font-size:10px; text-transform:uppercase; letter-spacing:.05em; opacity:.65; margin-top:.5rem;">Curated Synergy</div>{% set _ = inserted.update({'curated_synergy': True}) %}{% endif %}
|
||||
{% if primary == 'payoff' and not inserted.payoff %}<div class="group-separator" data-group="payoff" style="flex-basis:100%; font-size:10px; text-transform:uppercase; letter-spacing:.05em; opacity:.65; margin-top:.5rem;">Payoffs</div>{% set _ = inserted.update({'payoff': True}) %}{% endif %}
|
||||
{% if primary in ['enabler','support'] and not inserted.enabler_support %}<div class="group-separator" data-group="enabler_support" style="flex-basis:100%; font-size:10px; text-transform:uppercase; letter-spacing:.05em; opacity:.65; margin-top:.5rem;">Enablers & Support</div>{% set _ = inserted.update({'enabler_support': True}) %}{% endif %}
|
||||
{% if primary == 'wildcard' and not inserted.wildcard %}<div class="group-separator" data-group="wildcard" style="flex-basis:100%; font-size:10px; text-transform:uppercase; letter-spacing:.05em; opacity:.65; margin-top:.5rem;">Wildcards</div>{% set _ = inserted.update({'wildcard': True}) %}{% endif %}
|
||||
{% set overlaps = [] %}
|
||||
{% if preview.synergies_used and c.tags %}
|
||||
{% for tg in c.tags %}{% if tg in preview.synergies_used %}{% set _ = overlaps.append(tg) %}{% endif %}{% endfor %}
|
||||
{% endif %}
|
||||
<div class="card-sample{% if overlaps %} has-overlap{% endif %}" style="width:230px;" data-card-name="{{ c.name }}" data-role="{{ c.roles[0] if c.roles }}" data-reasons="{{ c.reasons|join('; ') if c.reasons }}" data-tags="{{ c.tags|join(', ') if c.tags }}" data-overlaps="{{ overlaps|join(',') }}" data-mana="{{ c.mana_cost if c.mana_cost }}" data-rarity="{{ c.rarity if c.rarity }}">
|
||||
<div class="thumb-wrap" style="position:relative;">
|
||||
<img class="card-thumb" width="230" loading="lazy" decoding="async" src="https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=small" alt="{{ c.name }} image" data-card-name="{{ c.name }}" data-role="{{ c.roles[0] if c.roles }}" data-tags="{{ c.tags|join(', ') if c.tags }}" {% if overlaps %}data-overlaps="{{ overlaps|join(',') }}"{% endif %} data-placeholder-color="#0b0d12" style="filter:blur(4px); transition:filter .35s ease; background:linear-gradient(145deg,#0b0d12,#111b29);" onload="this.style.filter='blur(0)';" />
|
||||
<span class="role-chip role-{{ c.roles[0] if c.roles }}" title="Primary role: {{ c.roles[0] if c.roles }}">{{ c.roles[0][0]|upper if c.roles }}</span>
|
||||
{% if overlaps %}<span class="overlap-badge" title="Synergy overlaps: {{ overlaps|join(', ') }}">{{ overlaps|length }}</span>{% endif %}
|
||||
</div>
|
||||
<div class="meta" style="font-size:12px; margin-top:2px;">
|
||||
<div class="ci-ribbon" aria-label="Color identity" style="display:flex; gap:2px; margin-bottom:2px; min-height:10px;"></div>
|
||||
<div class="nm" style="font-weight:600; line-height:1.25; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;" title="{{ c.name }}">{{ c.name }}</div>
|
||||
<div class="mana-line" aria-label="Mana Cost" style="min-height:14px; display:flex; flex-wrap:wrap; gap:2px; font-size:10px;"></div>
|
||||
{% if c.rarity %}<div class="rarity-badge rarity-{{ c.rarity }}" title="Rarity: {{ c.rarity }}" style="font-size:9px; letter-spacing:.5px; text-transform:uppercase; opacity:.7;">{{ c.rarity }}</div>{% endif %}
|
||||
<div class="role" style="opacity:.75; font-size:11px; display:flex; flex-wrap:wrap; gap:3px;">
|
||||
{% for r in c.roles %}<span class="mini-badge role-{{ r }}" title="{{ r }} role">{{ r[0]|upper }}</span>{% endfor %}
|
||||
</div>
|
||||
{% if c.reasons %}<div class="reasons" data-reasons-block style="font-size:9px; opacity:.55; line-height:1.15;" title="Heuristics: {{ c.reasons|join(', ') }}">{{ c.reasons|map('replace','commander_bias','cmbias')|join(' · ') }}</div>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% set has_synth = false %}
|
||||
{% for c in preview.sample %}{% if 'synthetic' in c.roles %}{% set has_synth = true %}{% endif %}{% endfor %}
|
||||
{% if has_synth %}
|
||||
<div style="flex-basis:100%; height:0;"></div>
|
||||
{% for c in preview.sample %}
|
||||
{% if 'synthetic' in c.roles %}
|
||||
<div class="card-sample synthetic" style="width:230px; border:1px dashed var(--border); padding:8px; border-radius:10px; background:var(--panel-alt);" data-card-name="{{ c.name }}" data-role="synthetic" data-reasons="{{ c.reasons|join('; ') if c.reasons }}" data-tags="{{ c.tags|join(', ') if c.tags }}" data-overlaps="">
|
||||
<div style="font-size:12px; font-weight:600; line-height:1.2;">{{ c.name }}</div>
|
||||
<div style="font-size:11px; opacity:.8;">{{ c.roles|join(', ') }}</div>
|
||||
{% if c.reasons %}<div style="font-size:10px; margin-top:2px; opacity:.6; line-height:1.15;">{{ c.reasons|join(', ') }}</div>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-right">
|
||||
{% if not minimal %}{% if not suppress_curated %}<h4 style="margin:.25rem 0 .25rem; font-size:13px; letter-spacing:.05em; text-transform:uppercase; opacity:.8;">Example Commanders</h4>{% else %}<h4 style="margin:.25rem 0 .25rem; font-size:13px; letter-spacing:.05em; text-transform:uppercase; opacity:.8;">Synergy Commanders</h4>{% endif %}{% endif %}
|
||||
<hr style="border:0; border-top:1px solid var(--border); margin:.35rem 0 .6rem;" />
|
||||
{% if example_commanders and not suppress_curated %}
|
||||
<div class="commander-grid" style="display:grid; grid-template-columns:repeat(auto-fill,minmax(230px,1fr)); gap:1rem;">
|
||||
{% for name in example_commanders %}
|
||||
{# Derive per-commander overlaps; still show full theme synergy set in data-tags for context #}
|
||||
{% set base = name %}
|
||||
{% set overlaps = [] %}
|
||||
{% if ' - Synergy (' in name %}
|
||||
{% set base = name.split(' - Synergy (')[0] %}
|
||||
{% set annot = name.split(' - Synergy (')[1].rstrip(')') %}
|
||||
{% for sy in annot.split(',') %}{% set _ = overlaps.append(sy.strip()) %}{% endfor %}
|
||||
{% endif %}
|
||||
{% set tags_all = preview.synergies_used[:] if preview.synergies_used else [] %}
|
||||
{% for ov in overlaps %}{% if ov not in tags_all %}{% set _ = tags_all.append(ov) %}{% endif %}{% endfor %}
|
||||
<div class="commander-cell" style="display:flex; flex-direction:column; gap:.35rem; align-items:center;" data-card-name="{{ base }}" data-role="commander_example" data-tags="{{ tags_all|join(', ') if tags_all }}" data-overlaps="{{ overlaps|join(', ') if overlaps }}" data-original-name="{{ name }}">
|
||||
<img class="card-thumb" width="230" src="https://api.scryfall.com/cards/named?fuzzy={{ base|urlencode }}&format=image&version=small" alt="{{ base }} image" loading="lazy" decoding="async" data-card-name="{{ base }}" data-role="commander_example" data-tags="{{ tags_all|join(', ') if tags_all }}" data-overlaps="{{ overlaps|join(', ') if overlaps }}" data-original-name="{{ name }}" data-placeholder-color="#0b0d12" style="filter:blur(4px); transition:filter .35s ease; background:linear-gradient(145deg,#0b0d12,#111b29);" onload="this.style.filter='blur(0)';" />
|
||||
<div class="commander-name" style="font-size:13px; text-align:center; line-height:1.35; font-weight:600; max-width:230px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;" title="{{ name }}">{{ name }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% elif not suppress_curated %}
|
||||
<div style="font-size:11px; opacity:.7;">No curated commander examples.</div>
|
||||
{% endif %}
|
||||
{% if synergy_commanders %}
|
||||
<div style="margin-top:1rem;">
|
||||
<div style="display:flex; align-items:center; gap:.4rem; margin-bottom:.4rem;">
|
||||
<h5 style="margin:0; font-size:11px; letter-spacing:.05em; text-transform:uppercase; opacity:.75;">Synergy Commanders</h5>
|
||||
<span title="Derived from synergy overlap heuristics" style="background:var(--panel-alt); border:1px solid var(--border); border-radius:10px; padding:2px 6px; font-size:10px; line-height:1;">Derived</span>
|
||||
</div>
|
||||
<div class="commander-grid" style="display:grid; grid-template-columns:repeat(auto-fill,minmax(230px,1fr)); gap:1rem;">
|
||||
{% for name in synergy_commanders[:8] %}
|
||||
{# Strip any appended ' - Synergy (...' suffix for image lookup while preserving display #}
|
||||
{% set base = name %}
|
||||
{% if ' - Synergy' in name %}{% set base = name.split(' - Synergy')[0] %}{% endif %}
|
||||
{% set overlaps = [] %}
|
||||
{% if ' - Synergy (' in name %}
|
||||
{% set annot = name.split(' - Synergy (')[1].rstrip(')') %}
|
||||
{% for sy in annot.split(',') %}{% set _ = overlaps.append(sy.strip()) %}{% endfor %}
|
||||
{% endif %}
|
||||
{% set tags_all = preview.synergies_used[:] if preview.synergies_used else [] %}
|
||||
{% for ov in overlaps %}{% if ov not in tags_all %}{% set _ = tags_all.append(ov) %}{% endif %}{% endfor %}
|
||||
<div class="commander-cell synergy" style="display:flex; flex-direction:column; gap:.35rem; align-items:center;" data-card-name="{{ base }}" data-role="synergy_commander" data-tags="{{ tags_all|join(', ') if tags_all }}" data-overlaps="{{ overlaps|join(', ') if overlaps }}" data-original-name="{{ name }}">
|
||||
<img class="card-thumb" width="230" src="https://api.scryfall.com/cards/named?fuzzy={{ base|urlencode }}&format=image&version=small" alt="{{ base }} image" loading="lazy" decoding="async" data-card-name="{{ base }}" data-role="synergy_commander" data-tags="{{ tags_all|join(', ') if tags_all }}" data-overlaps="{{ overlaps|join(', ') if overlaps }}" data-original-name="{{ name }}" data-placeholder-color="#0b0d12" style="filter:blur(4px); transition:filter .35s ease; background:linear-gradient(145deg,#0b0d12,#111b29);" onload="this.style.filter='blur(0)';" />
|
||||
<div class="commander-name" style="font-size:12px; text-align:center; line-height:1.3; font-weight:500; opacity:.92; max-width:230px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;" title="{{ name }}">{{ name }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% if not minimal %}<div style="margin-top:1rem; font-size:10px; opacity:.65; line-height:1.4;">Hover any card or commander for a larger preview and tag breakdown. Use Curated Only to hide sampled roles. Role chips: P=Payoff, E=Enabler, S=Support, W=Wildcard, X=Curated Example.</div>{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="preview-modal-content">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center;">
|
||||
<div class="sk-bar" style="height:16px; width:200px; background:var(--hover); border-radius:4px;"></div>
|
||||
<div class="sk-bar" style="height:16px; width:60px; background:var(--hover); border-radius:4px;"></div>
|
||||
</div>
|
||||
<div style="display:flex; flex-wrap:wrap; gap:10px; margin-top:1rem;">
|
||||
{% for i in range(8) %}<div style="width:230px; height:327px; background:var(--hover); border-radius:10px;"></div>{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<style>
|
||||
.theme-preview-expanded .card-thumb { width:230px; height:auto; border-radius:10px; border:1px solid var(--border); background:#0b0d12; object-fit:cover; }
|
||||
.theme-preview-expanded .role-chip { position:absolute; top:4px; left:4px; background:rgba(0,0,0,0.65); color:#fff; font-size:10px; padding:2px 5px; border-radius:10px; line-height:1; letter-spacing:.5px; }
|
||||
.theme-preview-expanded .mini-badge { background:var(--panel-alt); border:1px solid var(--border); padding:1px 4px; font-size:9px; border-radius:8px; line-height:1; }
|
||||
.theme-preview-expanded .role-payoff .role-chip, .mini-badge.role-payoff { background:#2563eb; color:#fff; }
|
||||
.mini-badge.role-payoff { background:#1d4ed8; color:#fff; }
|
||||
.mini-badge.role-enabler { background:#047857; color:#fff; }
|
||||
.mini-badge.role-support { background:#6d28d9; color:#fff; }
|
||||
.mini-badge.role-wildcard { background:#92400e; color:#fff; }
|
||||
.mini-badge.role-example, .mini-badge.role-curated_synergy { background:#4f46e5; color:#fff; }
|
||||
.theme-preview-expanded .commander-grid .card-thumb { width:230px; }
|
||||
.theme-preview-expanded.minimal-variant .preview-header,
|
||||
.theme-preview-expanded.minimal-variant .preview-controls,
|
||||
.theme-preview-expanded.minimal-variant .preview-rationale { display:none !important; }
|
||||
.theme-preview-expanded.minimal-variant h4 { display:none; }
|
||||
.theme-preview-expanded .commander-cell.synergy .card-thumb { filter:grayscale(.15) contrast(1.05); }
|
||||
.theme-preview-expanded .card-sample.synthetic { display:flex; flex-direction:column; justify-content:flex-start; }
|
||||
.theme-preview-expanded .card-sample.has-overlap { outline:1px solid var(--accent); outline-offset:2px; }
|
||||
/* Hover panel parity styling */
|
||||
#hover-card-panel { font-family: inherit; }
|
||||
#hover-card-panel .hcp-role { display:inline-block; margin-left:6px; padding:2px 6px; font-size:10px; letter-spacing:.5px; border:1px solid var(--border); border-radius:10px; background:var(--panel-alt); text-transform:uppercase; }
|
||||
#hover-card-panel.is-payoff .hcp-role { background:var(--accent, #38bdf8); color:#fff; border-color:var(--accent, #38bdf8); }
|
||||
#hover-card-panel .hcp-reasons li { margin:2px 0; }
|
||||
#hover-card-panel .hcp-reasons { scrollbar-width:thin; }
|
||||
#hover-card-panel .hcp-tags { font-size:10px; opacity:.75; }
|
||||
.theme-preview-expanded .overlap-badge { position:absolute; top:4px; right:4px; background:#0f766e; color:#fff; font-size:10px; padding:2px 5px; border-radius:10px; }
|
||||
.theme-preview-expanded .mana-symbol { width:14px; height:14px; border-radius:50%; background:#222; color:#fff; display:inline-flex; align-items:center; justify-content:center; font-size:9px; font-weight:600; box-shadow:0 0 0 1px #000 inset; }
|
||||
.theme-preview-expanded .mana-symbol.W { background:#f3f2dc; color:#222; }
|
||||
.theme-preview-expanded .mana-symbol.U { background:#5b8dd6; }
|
||||
.theme-preview-expanded .mana-symbol.B { background:#2d2d2d; }
|
||||
.theme-preview-expanded .mana-symbol.R { background:#c4472d; }
|
||||
.theme-preview-expanded .mana-symbol.G { background:#2f6b3a; }
|
||||
.theme-preview-expanded .mana-symbol.C { background:#555; }
|
||||
.theme-preview-expanded .ci-ribbon .pip { width:10px; height:10px; border-radius:50%; display:inline-block; box-shadow:0 0 0 1px #000 inset; }
|
||||
.theme-preview-expanded .ci-ribbon .pip.W { background:#f3f2dc; }
|
||||
.theme-preview-expanded .ci-ribbon .pip.U { background:#5b8dd6; }
|
||||
.theme-preview-expanded .ci-ribbon .pip.B { background:#2d2d2d; }
|
||||
.theme-preview-expanded .ci-ribbon .pip.R { background:#c4472d; }
|
||||
.theme-preview-expanded .ci-ribbon .pip.G { background:#2f6b3a; }
|
||||
.theme-preview-expanded .tooltip-reasons ul { margin:0; padding-left:14px; }
|
||||
.theme-preview-expanded .tooltip-reasons li { list-style:disc; margin:0; padding:0; }
|
||||
.theme-preview-expanded .rarity-common { color:#9ca3af; }
|
||||
.theme-preview-expanded .rarity-uncommon { color:#60a5fa; }
|
||||
.theme-preview-expanded .rarity-rare { color:#fbbf24; }
|
||||
.theme-preview-expanded .rarity-mythic { color:#fb923c; }
|
||||
@media (max-width: 950px){ .theme-preview-expanded .two-col { grid-template-columns: 1fr; } .theme-preview-expanded .col-right { order:-1; } }
|
||||
</style>
|
||||
<script>
|
||||
// sessionStorage preview fragment cache (keyed by theme + limit + commander). Stores HTML + ETag.
|
||||
(function(){ if(document.querySelector('.theme-preview-expanded.minimal-variant')) return;
|
||||
try {
|
||||
var root = document.getElementById('theme-preview-modal');
|
||||
if(!root) return;
|
||||
var container = root.querySelector('.preview-modal-content');
|
||||
if(!container) return;
|
||||
// Attach a marker for quick retrieval
|
||||
container.setAttribute('data-preview-fragment','1');
|
||||
} catch(_){}
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
// Lazy-load fallback for browsers ignoring loading=lazy (very old) + intersection observer prefetch enhancement
|
||||
(function(){
|
||||
try {
|
||||
if('loading' in HTMLImageElement.prototype) return; // native supported
|
||||
var imgs = Array.prototype.slice.call(document.querySelectorAll('.theme-preview-expanded img[loading="lazy"]'));
|
||||
imgs.forEach(function(img){
|
||||
if(!img.dataset.src){ img.dataset.src = img.src; }
|
||||
img.src = img.dataset.src;
|
||||
});
|
||||
} catch(_){}
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
// Lightweight hover tooltip for card reasons (progressive enhancement)
|
||||
(function(){
|
||||
var host = document.currentScript && document.currentScript.parentElement;
|
||||
if(!host) return;
|
||||
var tip = document.createElement('div');
|
||||
tip.className='tooltip-reasons';
|
||||
tip.style.position='fixed'; tip.style.pointerEvents='none'; tip.style.zIndex=9500; tip.style.padding='6px 8px'; tip.style.fontSize='11px'; tip.style.background='rgba(0,0,0,0.8)'; tip.style.color='#fff'; tip.style.border='1px solid var(--border)'; tip.style.borderRadius='6px'; tip.style.boxShadow='0 2px 8px rgba(0,0,0,0.4)'; tip.style.display='none'; maxWidth='260px';
|
||||
document.body.appendChild(tip);
|
||||
function show(e, html){ tip.innerHTML = html; tip.style.display='block'; move(e); }
|
||||
function move(e){ tip.style.top=(e.clientY+14)+'px'; tip.style.left=(e.clientX+12)+'px'; }
|
||||
function hide(){ tip.style.display='none'; }
|
||||
host.addEventListener('mouseover', function(ev){
|
||||
if(ev.target.closest('.thumb-wrap')) return;
|
||||
var t = ev.target.closest('.card-sample');
|
||||
if(!t) return;
|
||||
var name = t.querySelector('.nm') ? t.querySelector('.nm').textContent : t.getAttribute('data-card-name');
|
||||
var role = t.getAttribute('data-role');
|
||||
var reasons = t.getAttribute('data-reasons') || '';
|
||||
var tags = t.getAttribute('data-tags') || '';
|
||||
var overlaps = t.getAttribute('data-overlaps') || '';
|
||||
var html = '<strong>'+ (name||'') +'</strong><br/><em>'+ (role||'') +'</em>';
|
||||
if(tags){
|
||||
if(overlaps){
|
||||
var tagArr = tags.split(/\s*,\s*/);
|
||||
var overlapSet = new Set(overlaps.split(/\s*,\s*/).filter(Boolean));
|
||||
var rendered = tagArr.map(function(x){ return overlapSet.has(x) ? '<span style="color:#0ea5e9; font-weight:600;">'+x+'</span>' : x; }).join(', ');
|
||||
html += '<br/><span style="opacity:.85">'+ rendered +'</span>';
|
||||
} else {
|
||||
html += '<br/><span style="opacity:.8">'+tags+'</span>';
|
||||
}
|
||||
}
|
||||
if(reasons){
|
||||
var items = reasons.split(/;\s*/).filter(Boolean).map(function(r){ return '<li>'+r+'</li>'; }).join('');
|
||||
html += '<div style="margin-top:4px; font-size:10px; line-height:1.25;"><ul>'+items+'</ul></div>';
|
||||
}
|
||||
show(ev, html);
|
||||
});
|
||||
host.addEventListener('mousemove', function(ev){ if(tip.style.display==='block') move(ev); });
|
||||
host.addEventListener('mouseleave', function(ev){ if(!ev.relatedTarget || !ev.relatedTarget.closest('.card-sample')) hide(); }, true);
|
||||
host.addEventListener('mouseout', function(ev){ if(!ev.relatedTarget || !ev.relatedTarget.closest('.card-sample')) hide(); });
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
// Post-render safety pass: normalize commander thumbnails.
|
||||
// 1. If annotated form 'Name - Synergy (A, B)' still in data-card-name, strip to base.
|
||||
// 2. If annotation present in original name but data-tags/data-overlaps empty, populate them.
|
||||
(function(){
|
||||
try {
|
||||
document.querySelectorAll('.theme-preview-expanded img.card-thumb').forEach(function(img){
|
||||
var n = img.getAttribute('data-card-name') || '';
|
||||
var orig = img.getAttribute('data-original-name') || n;
|
||||
// Patterns to strip: ' - Synergy (' plus any trailing text/paren and optional closing paren
|
||||
var m = /(.*?)(\s*-\s*Synergy\b.*)$/i.exec(orig);
|
||||
if(m){
|
||||
var base = m[1].trim();
|
||||
if(base && base !== n){
|
||||
img.setAttribute('data-card-name', base);
|
||||
img.src = 'https://api.scryfall.com/cards/named?fuzzy=' + encodeURIComponent(base) + '&format=image&version=small';
|
||||
}
|
||||
// Attempt to derive overlaps if not already present
|
||||
if(!img.getAttribute('data-overlaps')){
|
||||
var annMatch = /-\s*Synergy\s*\(([^)]+)\)/i.exec(orig);
|
||||
if(annMatch){
|
||||
var list = annMatch[1].split(',').map(function(x){return x.trim();}).filter(Boolean).join(', ');
|
||||
if(list){
|
||||
// Preserve existing broader data-tags if present; only set overlaps
|
||||
if(!img.getAttribute('data-tags')) img.setAttribute('data-tags', list);
|
||||
img.setAttribute('data-overlaps', list);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch(_){ }
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
// Mana cost parser to convert {X}{2}{U}{B/P} style strings into colored symbol bubbles.
|
||||
// Removed legacy client-side mana parser (server now supplies normalized mana & pip/color metadata)
|
||||
// Placeholder: if server later supplies pre-rendered HTML we simply inject it here.
|
||||
// (Intentionally no-op; roadmap EXIT Server-side mana/rarity ingestion follow-up.)
|
||||
(()=>{})();
|
||||
</script>
|
||||
<script>
|
||||
// Color identity ribbon (simple heuristic from mana cost symbols); shown above name.
|
||||
// Removed heuristic color identity derivation (server now provides authoritative color_identity_list)
|
||||
// Future: server can inline <span class="pip W"></span> elements directly; leaving ribbon container empty if absent.
|
||||
(()=>{})();
|
||||
</script>
|
||||
<script>
|
||||
// (Removed fragment-specific large hover panel; using global unified panel in base.html)
|
||||
</script>
|
||||
<script>
|
||||
// Commander overlap & diversity rationale (client-side derivation) – Phase 1 tooltip implementation
|
||||
(function(){
|
||||
try {
|
||||
var listHost = document.getElementById('rationale-points');
|
||||
if(!listHost) return;
|
||||
var modeLabel = document.querySelector('#hover-compact-indicator [data-mode]');
|
||||
document.addEventListener('mtg:hoverCompactToggle', function(){ if(modeLabel){ modeLabel.textContent = window.__hoverCompactMode ? 'compact' : 'normal'; }});
|
||||
var cards = Array.from(document.querySelectorAll('.theme-preview-expanded .card-sample'))
|
||||
.filter(c=>!(c.classList.contains('synthetic')));
|
||||
if(!cards.length){ listHost.innerHTML='<li>No real cards in sample.</li>'; return; }
|
||||
var roleCounts = {payoff:0,enabler:0,support:0,wildcard:0,example:0,curated_synergy:0,synthetic:0};
|
||||
var overlapTotals = 0; var overlapSet = new Set();
|
||||
cards.forEach(c=>{
|
||||
var role = c.getAttribute('data-role')||'';
|
||||
if(roleCounts[role]!==undefined) roleCounts[role]++;
|
||||
var overlaps = (c.getAttribute('data-overlaps')||'').split(/\s*,\s*/).filter(Boolean);
|
||||
overlaps.forEach(o=>overlapSet.add(o));
|
||||
overlapTotals += overlaps.length;
|
||||
});
|
||||
var totalReal = cards.length;
|
||||
function pct(n){ return (n/totalReal*100).toFixed(1)+'%'; }
|
||||
var diversityScore = 0;
|
||||
var coreRoles = ['payoff','enabler','support','wildcard'];
|
||||
var ideal = {payoff:0.4,enabler:0.2,support:0.2,wildcard:0.2};
|
||||
coreRoles.forEach(r=>{ var actual = roleCounts[r]/Math.max(1,totalReal); diversityScore += (1 - Math.abs(actual - ideal[r])); });
|
||||
diversityScore = (diversityScore / coreRoles.length * 100).toFixed(1);
|
||||
var avgOverlap = (overlapTotals / Math.max(1,totalReal)).toFixed(2);
|
||||
var points = [];
|
||||
points.push('Roles mix: '+coreRoles.map(r=>r[0].toUpperCase()+r.slice(1)+"="+roleCounts[r]+' ('+pct(roleCounts[r])+')').join(', '));
|
||||
points.push('Distinct synergy overlaps represented: '+overlapSet.size);
|
||||
points.push('Average synergy overlaps per card: '+avgOverlap);
|
||||
points.push('Diversity heuristic score: '+diversityScore);
|
||||
var curated = roleCounts.example + roleCounts.curated_synergy;
|
||||
points.push('Curated cards: '+curated+' ('+pct(curated)+')');
|
||||
// Placeholder future richer analytics (P2 roadmap): spread index, top synergy concentration
|
||||
var spreadIndex = (overlapSet.size / Math.max(1, (cards.length))).toFixed(2);
|
||||
points.push('Synergy spread index: '+spreadIndex);
|
||||
listHost.innerHTML = points.map(p=>'<'+'li>'+p+'</li>').join('');
|
||||
} catch(e){ /* silent */ }
|
||||
})();
|
||||
</script>
|
||||
Loading…
Add table
Add a link
Reference in a new issue