refactor(web): consolidate inline JavaScript to TypeScript modules

Migrated app.js and components.js to TypeScript. Extracted inline scripts from base.html to cardHover.ts and cardImages.ts modules for better maintainability and code reuse.
This commit is contained in:
matt 2025-10-29 10:44:29 -07:00
parent ed381dfdce
commit 9379732eec
8 changed files with 1050 additions and 634 deletions

View file

@ -240,79 +240,13 @@
setInterval(pollStatus, 10000);
pollStatus();
// Card image URL builder with synergy annotation stripping
var PREVIEW_VERSIONS = ['normal','large'];
function normalizeCardName(raw){
// Expose normalizeCardName for cardImages module
window.__normalizeCardName = function(raw){
if(!raw) return raw;
// Strip ' - Synergy (...' annotation if present
var m = /(.*?)(\s*-\s*Synergy\s*\(.*\))$/i.exec(raw);
if(m){ return m[1].trim(); }
var m = /(.*?)(\s*-\s*Synergy\s*\(.*\))$/i.exec(raw);
if(m){ return m[1].trim(); }
return raw;
}
function buildCardUrl(name, version, nocache, face){
name = normalizeCardName(name);
var q = encodeURIComponent(name||'');
var url = '/api/images/' + (version||'normal') + '/' + q;
if (face === 'back') url += '?face=back';
if (nocache) url += (face === 'back' ? '&' : '?') + 't=' + Date.now();
return url;
}
// Generic card image URL builder
function buildScryfallImageUrl(name, version, nocache){
name = normalizeCardName(name);
var q = encodeURIComponent(name||'');
var url = '/api/images/' + (version||'normal') + '/' + q;
if (nocache) url += '?t=' + Date.now();
return url;
}
// Global image retry binding for any <img data-card-name>
var IMG_FLAG = '__cardImgRetry';
function bindCardImageRetry(img, versions){
try {
if (!img || img[IMG_FLAG]) return;
var name = img.getAttribute('data-card-name') || '';
if (!name) return;
img[IMG_FLAG] = { vi: 0, nocache: 0, versions: versions && versions.length ? versions.slice() : ['normal','large'] };
img.addEventListener('error', function(){
var st = img[IMG_FLAG];
if (!st) return;
if (st.vi < st.versions.length - 1){
st.vi += 1;
img.src = buildScryfallImageUrl(name, st.versions[st.vi], false);
} else if (!st.nocache){
st.nocache = 1;
img.src = buildScryfallImageUrl(name, st.versions[st.vi], true);
}
});
// If the initial load already failed before binding, try the next immediately
if (img.complete && img.naturalWidth === 0){
// If src corresponds to the first version, move to next; else, just force a reload
var st = img[IMG_FLAG];
var current = img.src || '';
var first = buildScryfallImageUrl(name, st.versions[0], false);
if (current.indexOf(encodeURIComponent(name)) !== -1 && current.indexOf('version='+st.versions[0]) !== -1){
st.vi = Math.min(1, st.versions.length - 1);
img.src = buildScryfallImageUrl(name, st.versions[st.vi], false);
} else {
// Re-trigger current request (may succeed if transient)
img.src = current;
}
}
} catch(_){}
}
function bindAllCardImageRetries(){
document.querySelectorAll('img[data-card-name]').forEach(function(img){
// Use thumbnail fallbacks for card-thumb, otherwise preview fallbacks
var versions = (img.classList && img.classList.contains('card-thumb')) ? ['small','normal','large'] : ['normal','large'];
bindCardImageRetry(img, versions);
});
}
// Expose image retry binding globally for dynamic content
window.bindAllCardImageRetries = bindAllCardImageRetries;
bindAllCardImageRetries();
document.addEventListener('htmx:afterSwap', bindAllCardImageRetries);
};
})();
</script>
<script>
@ -477,6 +411,8 @@
</script>
<script src="/static/js/components.js?v=20251028-1"></script>
<script src="/static/js/app.js?v=20250826-4"></script>
<script src="/static/js/cardImages.js?v=20251029-1"></script>
<script src="/static/js/cardHover.js?v=20251028-1"></script>
{% if enable_themes %}
<script>
(function(){
@ -610,534 +546,7 @@
})();
</script>
<script>
// Global delegated hover card panel initializer (ensures functionality after HTMX swaps)
(function(){
// 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;">&nbsp;</div>'+
'<div class="hcp-rarity" style="font-size:11px;text-transform:uppercase;letter-spacing:.5px;opacity:.75;"></div>'+
'<button type="button" class="hcp-close" aria-label="Close card details"><span aria-hidden="true"></span></button>'+
'</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;">&nbsp;</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;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');
var bodyEl = panel.querySelector('.hcp-body');
var rightCol = panel.querySelector('.hcp-right');
var coarseQuery = window.matchMedia('(pointer: coarse)');
function isMobileMode(){ return (coarseQuery && coarseQuery.matches) || window.innerWidth <= 768; }
function refreshPosition(){ if(panel.style.display==='block'){ move(window.__lastPointerEvent); } }
if(coarseQuery){
var handler = function(){ refreshPosition(); };
if(coarseQuery.addEventListener){ coarseQuery.addEventListener('change', handler); }
else if(coarseQuery.addListener){ coarseQuery.addListener(handler); }
}
window.addEventListener('resize', refreshPosition);
var closeBtn = panel.querySelector('.hcp-close');
if(closeBtn && !closeBtn.__bound){
closeBtn.__bound = true;
closeBtn.addEventListener('click', function(ev){ ev.preventDefault(); hide(); });
}
function positionPanel(evt){
if(isMobileMode()){
panel.classList.add('mobile');
panel.style.bottom = 'auto';
panel.style.left = '50%';
panel.style.top = '50%';
panel.style.right = 'auto';
panel.style.transform = 'translate(-50%, -50%)';
panel.style.pointerEvents = 'auto';
} else {
panel.classList.remove('mobile');
panel.style.pointerEvents = 'none';
panel.style.transform = 'none';
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;
if(x < 8) x = 8;
if(y < 8) y = 8;
panel.style.left = x+'px'; panel.style.top = y+'px';
panel.style.bottom = 'auto';
panel.style.right = 'auto';
}
}
function move(evt){
if(panel.style.display==='none') return;
if(!evt){ evt = window.__lastPointerEvent; }
if(!evt && lastCard){
var rect = lastCard.getBoundingClientRect();
evt = { clientX: rect.left + rect.width/2, clientY: rect.top + rect.height/2 };
}
if(!evt){ evt = { clientX: window.innerWidth/2, clientY: window.innerHeight/2 }; }
positionPanel(evt);
}
// 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 simpleSource = null;
if(card.closest){
simpleSource = card.closest('[data-hover-simple]');
}
var forceSimple = (card.hasAttribute && card.hasAttribute('data-hover-simple')) || !!simpleSource;
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 tagsRaw = attr('data-tags')||'';
var metadataTagsRaw = attr('data-metadata-tags')||''; // M5: Extract metadata tags
var reasonsRaw = attr('data-reasons')||'';
var roleEl = panel.querySelector('.hcp-role');
var hasFlip = !!card.querySelector('.dfc-toggle');
var tagListEl = panel.querySelector('.hcp-taglist');
var overlapsEl = panel.querySelector('.hcp-overlaps');
var overlapsAttr = attr('data-overlaps') || '';
function displayLabel(text){
if(!text) return '';
var label = String(text);
label = label.replace(/[\u2022\-_]+/g, ' ');
label = label.replace(/\s+/g, ' ').trim();
return label;
}
function parseTagList(raw){
if(!raw) return [];
var trimmed = String(raw).trim();
if(!trimmed) return [];
var result = [];
var candidate = trimmed;
if(trimmed[0] === '[' && trimmed[trimmed.length-1] === ']'){
candidate = trimmed.slice(1, -1);
}
// Try JSON parsing after normalizing quotes
try {
var normalized = trimmed;
if(trimmed.indexOf("'") > -1 && trimmed.indexOf('"') === -1){
normalized = trimmed.replace(/'/g, '"');
}
var parsed = JSON.parse(normalized);
if(Array.isArray(parsed)){
result = parsed;
}
} catch(_){ /* fall back below */ }
if(!result || !result.length){
result = candidate.split(/\s*,\s*/);
}
return result.map(function(t){ return String(t || '').trim(); }).filter(Boolean);
}
function deriveTagsFromReasons(reasons){
if(!reasons) return [];
// Reasons often include "because it overlaps X, Y" or "by <theme>"
var out = [];
// Grab bracketed or quoted lists first
var m = reasons.match(/\[(.*?)\]/);
if(m && m[1]){ out = out.concat(m[1].split(/\s*,\s*/)); }
// Common phrasing: "overlap(s) with A, B" or "by A, B"
var rx = /(overlap(?:s)?(?:\s+with)?|by)\s+([^.;]+)/ig;
var r;
while((r = rx.exec(reasons))){ out = out.concat((r[2]||'').split(/\s*,\s*/)); }
var tagRx = /tag:\s*([^.;]+)/ig;
var tMatch;
while((tMatch = tagRx.exec(reasons))){ out = out.concat((tMatch[1]||'').split(/\s*,\s*/)); }
return out.map(function(s){ return s.trim(); }).filter(Boolean);
}
var overlapArr = [];
if(overlapsAttr){
var parsedOverlaps = parseTagList(overlapsAttr);
if(parsedOverlaps.length){ overlapArr = parsedOverlaps; }
else { overlapArr = [String(overlapsAttr).trim()]; }
}
var derivedFromReasons = deriveTagsFromReasons(reasonsRaw);
var allTags = parseTagList(tagsRaw);
if(!allTags.length && derivedFromReasons.length){
// Fallback: try to derive tags from reasons text when tags missing
allTags = derivedFromReasons.slice();
}
if((!overlapArr || !overlapArr.length) && derivedFromReasons.length){
var normalizedAll = (allTags||[]).map(function(t){ return { raw: t, key: t.toLowerCase() }; });
var derivedKeys = new Set(derivedFromReasons.map(function(t){ return t.toLowerCase(); }));
var intersect = normalizedAll.filter(function(entry){ return derivedKeys.has(entry.key); }).map(function(entry){ return entry.raw; });
if(!intersect.length){
intersect = derivedFromReasons.slice();
}
overlapArr = Array.from(new Set(intersect));
}
overlapArr = (overlapArr||[]).map(function(t){ return String(t||'').trim(); }).filter(Boolean);
allTags = (allTags||[]).map(function(t){ return String(t||'').trim(); }).filter(Boolean);
nameEl.textContent = nm;
rarityEl.textContent = rarity;
var roleLabel = displayLabel(role);
var roleKey = (roleLabel || role || '').toLowerCase();
var isCommanderRole = roleKey === 'commander';
metaEl.textContent = [roleLabel?('Role: '+roleLabel):'', 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='';
tagListEl.style.display = 'none';
tagListEl.setAttribute('aria-hidden','true');
}
if(overlapsEl){
if(overlapArr && overlapArr.length){
overlapsEl.innerHTML = overlapArr.map(function(o){ var label = displayLabel(o); return '<span class="hcp-ov-chip" title="Overlapping synergy">'+label+'</span>'; }).join('');
} else {
overlapsEl.innerHTML = '';
}
}
if(tagsEl){
if(isCommanderRole){
tagsEl.textContent = '';
tagsEl.style.display = 'none';
} else {
var tagText = allTags.map(displayLabel).join(', ');
// M5: Temporarily append metadata tags for debugging
if(metadataTagsRaw && metadataTagsRaw.trim()){
var metaTags = metadataTagsRaw.split(',').map(function(t){return t.trim();}).filter(Boolean);
if(metaTags.length){
var metaText = metaTags.map(displayLabel).join(', ');
tagText = tagText ? (tagText + ' | META: ' + metaText) : ('META: ' + metaText);
}
}
tagsEl.textContent = tagText;
tagsEl.style.display = tagText ? '' : 'none';
}
}
if(roleEl){
roleEl.textContent = roleLabel || '';
roleEl.style.display = roleLabel ? 'inline-block' : 'none';
}
panel.classList.toggle('is-payoff', role === 'payoff');
panel.classList.toggle('is-commander', isCommanderRole);
var hasDetails = !forceSimple && (
!!roleLabel || !!mana || !!rarity || (reasonsRaw && reasonsRaw.trim()) || (overlapArr && overlapArr.length) || (allTags && allTags.length)
);
panel.classList.toggle('hcp-simple', !hasDetails);
if(rightCol){
rightCol.style.display = hasDetails ? 'flex' : 'none';
}
if(bodyEl){
if(!hasDetails){
bodyEl.style.display = 'flex';
bodyEl.style.flexDirection = 'column';
bodyEl.style.alignItems = 'center';
bodyEl.style.gap = '12px';
} else {
bodyEl.style.display = '';
bodyEl.style.flexDirection = '';
bodyEl.style.alignItems = '';
bodyEl.style.gap = '';
}
}
var fuzzy = encodeURIComponent(nm);
var rawName = nm || '';
var hasBack = rawName.indexOf('//')>-1 || (attr('data-original-name')||'').indexOf('//')>-1;
if(hasBack) hasFlip = true;
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';
lastCard = card;
function renderHoverFace(face){
var desiredVersion='normal'; // Use 'normal' since we only cache small/normal
var currentKey = nm+':'+face+':'+desiredVersion;
var prevFace = imgEl.getAttribute('data-face');
var faceChanged = prevFace && prevFace !== face;
if(imgEl.getAttribute('data-current')!== currentKey){
// For DFC cards, extract the specific face name for cache lookup
// but also send face parameter for Scryfall fallback
var faceName = nm;
var isDFC = nm.indexOf('//')>-1;
if(isDFC){
var faces = nm.split('//');
faceName = (face==='back') ? faces[1].trim() : faces[0].trim();
}
// Use cache-aware API endpoint with the specific face name
// Add face parameter for DFC back faces to help Scryfall fallback
var src='/api/images/'+desiredVersion+'/'+encodeURIComponent(faceName);
if(isDFC && face==='back'){
src += '?face=back';
}
if(faceChanged){ imgEl.style.opacity = 0; }
prefetch(src);
imgEl.src = src;
imgEl.setAttribute('data-current', currentKey);
imgEl.setAttribute('data-face', face);
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')||'';
// Fallback from normal to small if image fails to load
if(cur.indexOf('/api/images/normal/')>-1){
imgEl.src = cur.replace('/api/images/normal/','/api/images/small/');
}
});
}
}
renderHoverFace(chosenFace);
// Add DFC flip button to popup panel ONLY on mobile
var checkFlip = window.__dfcHasTwoFaces || function(){ return false; };
if(hasFlip && imgEl && checkFlip(card) && isMobileMode()){
var imgWrap = imgEl.parentElement; // .hcp-img-wrap
if(imgWrap && !imgWrap.querySelector('.dfc-toggle')){
// Create a custom flip button that flips the ORIGINAL card (lastCard)
// This ensures the popup refreshes with updated tags/themes
var flipBtn = document.createElement('button');
flipBtn.type = 'button';
flipBtn.className = 'dfc-toggle'; // No responsive classes needed - only created on mobile
flipBtn.setAttribute('aria-pressed', 'false');
flipBtn.setAttribute('tabindex', '0');
flipBtn.innerHTML = '<span class="icon" aria-hidden="true" style="font-size:18px;"></span>';
// Flip the ORIGINAL card element, not the popup wrapper
flipBtn.addEventListener('click', function(ev){
ev.stopPropagation();
if(window.__dfcFlipCard && lastCard){
window.__dfcFlipCard(lastCard); // This will trigger popup refresh
}
});
flipBtn.addEventListener('keydown', function(ev){
if(ev.key==='Enter' || ev.key===' ' || ev.key==='f' || ev.key==='F'){
ev.preventDefault();
if(window.__dfcFlipCard && lastCard){
window.__dfcFlipCard(lastCard);
}
}
});
imgWrap.classList.add('dfc-host');
imgWrap.appendChild(flipBtn);
}
}
window.__dfcNotifyHover = hasFlip ? function(cardRef, face){ if(cardRef === lastCard){ renderHoverFace(face); } } : null;
if(evt){ window.__lastPointerEvent = evt; }
if(isMobileMode()){
panel.classList.add('mobile');
panel.style.pointerEvents = 'auto';
panel.style.maxHeight = '80vh';
} else {
panel.classList.remove('mobile');
panel.style.pointerEvents = 'none';
panel.style.maxHeight = '';
panel.style.bottom = 'auto';
}
panel.style.display='block'; panel.setAttribute('aria-hidden','false'); move(evt);
}
function hide(){
panel.style.display='none';
panel.setAttribute('aria-hidden','true');
cancelSchedule();
panel.classList.remove('mobile');
panel.style.pointerEvents = 'none';
panel.style.transform = 'none';
panel.style.bottom = 'auto';
panel.style.maxHeight = '';
window.__dfcNotifyHover = null;
}
document.addEventListener('mousemove', move);
function getCardFromEl(el){
if(!el) return null;
if(el.closest){
var altBtn = el.closest('.alts button[data-card-name]');
if(altBtn){ return altBtn; }
}
// If inside flip button
var btn = el.closest && el.closest('.dfc-toggle');
if(btn) return btn.closest('.card-sample, .commander-cell, .commander-thumb, .commander-card, .card-tile, .candidate-tile, .card-preview, .stack-card');
// For card-tile, ONLY trigger on .img-btn or the image itself (not entire tile)
if(el.closest && el.closest('.card-tile')){
var imgBtn = el.closest('.img-btn');
if(imgBtn) return imgBtn.closest('.card-tile');
// If directly on the image
if(el.matches && (el.matches('img.card-thumb') || el.matches('img[data-card-name]'))){
return el.closest('.card-tile');
}
// Don't trigger on other parts of the tile (buttons, text, etc.)
return null;
}
// Recognized container classes (add .stack-card for finished/random deck thumbnails)
var container = el.closest && el.closest('.card-sample, .commander-cell, .commander-thumb, .commander-card, .candidate-tile, .card-preview, .stack-card');
if(container) return container;
// Image-based detection (any card image carrying data-card-name)
if(el.matches && (el.matches('img.card-thumb') || el.matches('img[data-card-name]') || el.classList.contains('commander-img'))){
var up = el.closest && el.closest('.stack-card');
return up || el; // fall back to the image itself
}
// List view spans (deck summary list mode, finished deck list, etc.)
if(el.hasAttribute && el.hasAttribute('data-card-name')) return el;
return null;
}
document.addEventListener('pointermove', function(e){ window.__lastPointerEvent = e; });
document.addEventListener('pointerover', function(e){
if(isMobileMode()) return;
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){
if(isMobileMode()) return;
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();
}
});
document.addEventListener('click', function(e){
if(!isMobileMode()) return;
if(panel.contains(e.target)) return;
if(e.target.closest && (e.target.closest('.dfc-toggle') || e.target.closest('.hcp-close'))) return;
if(e.target.closest && e.target.closest('button, input, select, textarea, a')) return;
var card = getCardFromEl(e.target);
if(card){
cancelSchedule();
var rect = card.getBoundingClientRect();
var syntheticEvt = { clientX: rect.left + rect.width/2, clientY: rect.top + rect.height/2 };
show(card, syntheticEvt);
} else if(panel.style.display==='block'){
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);
};
window.hoverShowByName = function(name){
try {
var el = document.querySelector('[data-card-name="'+CSS.escape(name)+'"]');
if(el){ window.__hoverShowCard(el.closest('.card-sample, .commander-cell, .commander-thumb, .commander-card, .card-tile, .candidate-tile, .card-preview, .stack-card') || el); }
} catch(_) {}
};
// 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, .commander-thumb'); 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, .commander-thumb'); 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();
})();
<!-- Hover card panel system moved to TypeScript: code/web/static/ts/cardHover.ts -->
</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>

View file

@ -242,7 +242,7 @@
id="filter-cmc-min"
min="0"
max="16"
value="{{ cmc_min if cmc_min is defined else '' }}"
value="{{ cmc_min if cmc_min is not none and cmc_min != '' else '' }}"
placeholder="Min"
style="width:70px;"
onchange="applyFilter()"
@ -253,7 +253,7 @@
id="filter-cmc-max"
min="0"
max="16"
value="{{ cmc_max if cmc_max is defined else '' }}"
value="{{ cmc_max if cmc_max is not none and cmc_max != '' else '' }}"
placeholder="Max"
style="width:70px;"
onchange="applyFilter()"
@ -268,7 +268,7 @@
id="filter-power-min"
min="0"
max="99"
value="{{ power_min if power_min is defined else '' }}"
value="{{ power_min if power_min is not none and power_min != '' else '' }}"
placeholder="Min"
style="width:70px;"
onchange="applyFilter()"
@ -279,7 +279,7 @@
id="filter-power-max"
min="0"
max="99"
value="{{ power_max if power_max is defined else '' }}"
value="{{ power_max if power_max is not none and power_max != '' else '' }}"
placeholder="Max"
style="width:70px;"
onchange="applyFilter()"
@ -294,7 +294,7 @@
id="filter-tough-min"
min="0"
max="99"
value="{{ tough_min if tough_min is defined else '' }}"
value="{{ tough_min if tough_min is not none and tough_min != '' else '' }}"
placeholder="Min"
style="width:70px;"
onchange="applyFilter()"
@ -305,7 +305,7 @@
id="filter-tough-max"
min="0"
max="99"
value="{{ tough_max if tough_max is defined else '' }}"
value="{{ tough_max if tough_max is not none and tough_max != '' else '' }}"
placeholder="Max"
style="width:70px;"
onchange="applyFilter()"

View file

@ -4,7 +4,10 @@
{% set display_label = record.name if '//' in record.name else record.display_name %}
{% set placeholder_pixel = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==" %}
<article class="commander-row" data-commander-slug="{{ record.slug }}" data-hover-simple="true">
<div class="commander-thumb">
<div class="commander-thumb"
data-card-name="{{ record.name }}"
data-original-name="{{ record.name }}"
{% if record.layout %}data-layout="{{ record.layout }}"{% endif %}>
{% set small = record.display_name|card_image('small') %}
{% set normal = record.display_name|card_image('normal') %}
<img
@ -16,8 +19,9 @@
decoding="async"
width="160"
height="223"
data-card-name="{{ record.display_name }}"
data-card-name="{{ record.name }}"
data-original-name="{{ record.name }}"
{% if record.layout %}data-layout="{{ record.layout }}"{% endif %}
data-hover-simple="true"
class="commander-thumb-img"
/>