mtg_python_deckbuilder/code/web/static/app.js

1201 lines
52 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* Core app enhancements: tokens, toasts, shortcuts, state, skeletons */
(function(){
// Design tokens fallback (in case CSS variables missing in older browsers)
// No-op here since styles.css defines variables; kept for future JS reads.
// State persistence helpers (localStorage + URL hash)
var state = {
get: function(key, def){
try { var v = localStorage.getItem('mtg:'+key); return v !== null ? JSON.parse(v) : def; } catch(e){ return def; }
},
set: function(key, val){
try { localStorage.setItem('mtg:'+key, JSON.stringify(val)); } catch(e){}
},
inHash: function(obj){
// Merge obj into location.hash as query-like params
try {
var params = new URLSearchParams((location.hash||'').replace(/^#/, ''));
Object.keys(obj||{}).forEach(function(k){ params.set(k, obj[k]); });
location.hash = params.toString();
} catch(e){}
},
readHash: function(){
try { return new URLSearchParams((location.hash||'').replace(/^#/, '')); } catch(e){ return new URLSearchParams(); }
}
};
window.__mtgState = state;
// Toast system
var toastHost;
function ensureToastHost(){
if (!toastHost){
toastHost = document.createElement('div');
toastHost.className = 'toast-host';
document.body.appendChild(toastHost);
}
return toastHost;
}
function toast(msg, type, opts){
ensureToastHost();
var t = document.createElement('div');
t.className = 'toast' + (type ? ' '+type : '');
t.setAttribute('role','status');
t.setAttribute('aria-live','polite');
t.textContent = '';
if (typeof msg === 'string') { t.textContent = msg; }
else if (msg && msg.nodeType === 1) { t.appendChild(msg); }
toastHost.appendChild(t);
var delay = (opts && opts.duration) || 2600;
setTimeout(function(){ t.classList.add('hide'); setTimeout(function(){ t.remove(); }, 300); }, delay);
return t;
}
window.toast = toast;
function toastHTML(html, type, opts){
var container = document.createElement('div');
container.innerHTML = html;
return toast(container, type, opts);
}
window.toastHTML = toastHTML;
var telemetryEndpoint = (function(){
if (typeof window.__telemetryEndpoint === 'string' && window.__telemetryEndpoint.trim()){
return window.__telemetryEndpoint.trim();
}
return '/telemetry/events';
})();
var telemetry = {
send: function(eventName, data){
if (!telemetryEndpoint || !eventName) return;
var payload;
try {
payload = JSON.stringify({ event: eventName, data: data || {}, ts: Date.now() });
} catch(_){ return; }
try {
if (navigator.sendBeacon){
var blob = new Blob([payload], { type: 'application/json' });
navigator.sendBeacon(telemetryEndpoint, blob);
} else if (window.fetch){
fetch(telemetryEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: payload,
keepalive: true,
}).catch(function(){ /* noop */ });
}
} catch(_){ }
}
};
window.appTelemetry = telemetry;
// Global HTMX error handling => toast
document.addEventListener('htmx:responseError', function(e){
var detail = e.detail || {}; var xhr = detail.xhr || {};
var rid = (xhr.getResponseHeader && xhr.getResponseHeader('X-Request-ID')) || '';
var payload = (function(){ try { return JSON.parse(xhr.responseText || '{}'); } catch(_){ return {}; } })();
var status = payload.status || xhr.status || '';
var msg = payload.detail || payload.message || 'Action failed';
var path = payload.path || (e && e.detail && e.detail.path) || '';
var html = ''+
'<div style="display:flex; align-items:center; gap:.5rem">'+
'<span style="font-weight:600">'+String(msg)+'</span>'+ (status? ' <span class="muted">('+status+')</span>' : '')+
(rid ? '<button class="btn small" style="margin-left:auto" type="button" data-copy-error>Copy details</button>' : '')+
'</div>'+
(rid ? '<div class="muted" style="font-size:11px; margin-top:2px">Request-ID: <code>'+rid+'</code></div>' : '');
var t = toastHTML(html, 'error', { duration: 7000 });
// Wire Copy
var btn = t.querySelector('[data-copy-error]');
if (btn){
btn.addEventListener('click', function(){
var lines = [
'Error: '+String(msg),
'Status: '+String(status),
'Path: '+String(path || (xhr.responseURL||'')),
'Request-ID: '+String(rid)
];
try { navigator.clipboard.writeText(lines.join('\n')); btn.textContent = 'Copied'; setTimeout(function(){ btn.textContent = 'Copy details'; }, 1200); } catch(_){ }
});
}
// Optional inline banner if a surface is available
try {
var target = e && e.target;
var surface = (target && target.closest && target.closest('[data-error-surface]')) || document.querySelector('[data-error-surface]');
if (surface){
var banner = document.createElement('div');
banner.className = 'inline-error-banner';
banner.innerHTML = '<strong>'+String(msg)+'</strong>' + (rid? ' <span class="muted">(Request-ID: '+rid+')</span>' : '');
surface.prepend(banner);
setTimeout(function(){ banner.remove(); }, 8000);
}
} catch(_){ }
});
document.addEventListener('htmx:sendError', function(){ toast('Network error', 'error', { duration: 4000 }); });
// Keyboard shortcuts
var keymap = {
' ': function(){ var el = document.querySelector('[data-action="continue"], .btn-continue'); if (el) el.click(); },
'r': function(){ var el = document.querySelector('[data-action="rerun"], .btn-rerun'); if (el) el.click(); },
'b': function(){ var el = document.querySelector('[data-action="back"], .btn-back'); if (el) el.click(); },
'l': function(){ var el = document.querySelector('[data-action="toggle-logs"], .btn-logs'); if (el) el.click(); },
};
document.addEventListener('keydown', function(e){
if (e.target && (/input|textarea|select/i).test(e.target.tagName)) return; // don't hijack inputs
var k = e.key.toLowerCase();
// If focus is inside a card tile, defer 'r'/'l' to tile-scoped handlers (Alternatives/Lock)
try {
var active = document.activeElement;
if (active && active.closest && active.closest('.card-tile') && (k === 'r' || k === 'l')) {
return;
}
} catch(_) { /* noop */ }
if (keymap[k]){ e.preventDefault(); keymap[k](); }
});
// Focus ring visibility for keyboard nav
function addFocusVisible(){
var hadKeyboardEvent = false;
function onKeyDown(){ hadKeyboardEvent = true; }
function onPointer(){ hadKeyboardEvent = false; }
function onFocus(e){ if (hadKeyboardEvent) e.target.classList.add('focus-visible'); }
function onBlur(e){ e.target.classList.remove('focus-visible'); }
window.addEventListener('keydown', onKeyDown, true);
window.addEventListener('mousedown', onPointer, true);
window.addEventListener('pointerdown', onPointer, true);
window.addEventListener('touchstart', onPointer, true);
document.addEventListener('focusin', onFocus);
document.addEventListener('focusout', onBlur);
}
addFocusVisible();
// Skeleton utility: defer placeholders until the request lasts long enough to be noticeable
var SKELETON_DELAY_DEFAULT = 400;
var skeletonTimers = new WeakMap();
function gatherSkeletons(root){
if (!root){ return []; }
var list = [];
var scope = (root.nodeType === 9) ? root.documentElement : root;
if (scope && scope.matches && scope.hasAttribute('data-skeleton')){
list.push(scope);
}
if (scope && scope.querySelectorAll){
scope.querySelectorAll('[data-skeleton]').forEach(function(el){
if (list.indexOf(el) === -1){ list.push(el); }
});
}
return list;
}
function scheduleSkeleton(el){
var delayAttr = parseInt(el.getAttribute('data-skeleton-delay') || '', 10);
var delay = isNaN(delayAttr) ? SKELETON_DELAY_DEFAULT : Math.max(0, delayAttr);
clearSkeleton(el, false);
var timer = setTimeout(function(){
el.classList.add('is-loading');
el.setAttribute('aria-busy', 'true');
skeletonTimers.set(el, null);
}, delay);
skeletonTimers.set(el, timer);
}
function clearSkeleton(el, removeBusy){
var timer = skeletonTimers.get(el);
if (typeof timer === 'number'){
clearTimeout(timer);
}
skeletonTimers.delete(el);
el.classList.remove('is-loading');
if (removeBusy !== false){ el.removeAttribute('aria-busy'); }
}
function showSkeletons(context){
gatherSkeletons(context || document).forEach(function(el){ scheduleSkeleton(el); });
}
function hideSkeletons(context){
gatherSkeletons(context || document).forEach(function(el){ clearSkeleton(el); });
}
window.skeletons = { show: showSkeletons, hide: hideSkeletons };
document.addEventListener('htmx:beforeRequest', function(e){
var detail = e && e.detail ? e.detail : {};
var target = detail.target || detail.elt || e.target;
showSkeletons(target);
});
document.addEventListener('htmx:afterSwap', function(e){
var detail = e && e.detail ? e.detail : {};
var target = detail.target || detail.elt || e.target;
hideSkeletons(target);
});
document.addEventListener('htmx:afterRequest', function(e){
var detail = e && e.detail ? e.detail : {};
var target = detail.target || detail.elt || e.target;
hideSkeletons(target);
});
// Commander catalog image lazy loader
(function(){
var PLACEHOLDER_PIXEL = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==';
var observer = null;
var supportsIO = 'IntersectionObserver' in window;
function ensureObserver(){
if (observer || !supportsIO) return observer;
observer = new IntersectionObserver(function(entries){
entries.forEach(function(entry){
if (entry.isIntersecting || entry.intersectionRatio > 0){
var img = entry.target;
load(img);
if (observer) observer.unobserve(img);
}
});
}, { rootMargin: '160px 0px', threshold: 0.05 });
return observer;
}
function load(img){
if (!img || img.__lazyLoaded) return;
var src = img.getAttribute('data-lazy-src');
if (src){ img.setAttribute('src', src); }
var srcset = img.getAttribute('data-lazy-srcset');
if (srcset){ img.setAttribute('srcset', srcset); }
var sizes = img.getAttribute('data-lazy-sizes');
if (sizes){ img.setAttribute('sizes', sizes); }
img.classList.remove('is-placeholder');
img.removeAttribute('data-lazy-image');
img.removeAttribute('data-lazy-src');
img.removeAttribute('data-lazy-srcset');
img.removeAttribute('data-lazy-sizes');
img.__lazyLoaded = true;
}
function prime(img){
if (!img || img.__lazyPrimed) return;
var desired = img.getAttribute('data-lazy-src');
if (!desired) return;
img.__lazyPrimed = true;
var placeholder = img.getAttribute('data-lazy-placeholder') || PLACEHOLDER_PIXEL;
img.setAttribute('loading', 'lazy');
img.setAttribute('decoding', 'async');
img.classList.add('is-placeholder');
img.removeAttribute('srcset');
img.removeAttribute('sizes');
img.setAttribute('src', placeholder);
if (supportsIO){
ensureObserver().observe(img);
} else {
var loader = window.requestIdleCallback || window.requestAnimationFrame || function(cb){ return setTimeout(cb, 0); };
loader(function(){ load(img); });
}
}
function collect(scope){
if (!scope) scope = document;
if (scope === document){
return Array.prototype.slice.call(document.querySelectorAll('[data-lazy-image]'));
}
if (scope.matches && scope.hasAttribute && scope.hasAttribute('data-lazy-image')){
return [scope];
}
if (scope.querySelectorAll){
return Array.prototype.slice.call(scope.querySelectorAll('[data-lazy-image]'));
}
return [];
}
function process(scope){
collect(scope).forEach(function(img){
if (img.__lazyLoaded) return;
prime(img);
});
}
if (document.readyState === 'loading'){
document.addEventListener('DOMContentLoaded', function(){ process(document); });
} else {
process(document);
}
document.addEventListener('htmx:afterSwap', function(evt){
var target = evt && evt.detail ? evt.detail.target : null;
process(target || document);
});
})();
var htmxCache = (function(){
var store = new Map();
function ttlFor(elt){
var raw = parseInt((elt && elt.getAttribute && elt.getAttribute('data-hx-cache-ttl')) || '', 10);
if (isNaN(raw) || raw <= 0){ return 30000; }
return Math.max(1000, raw);
}
function buildKey(detail, elt){
if (!detail) detail = {};
if (elt && elt.getAttribute){
var explicit = elt.getAttribute('data-hx-cache-key');
if (explicit && explicit.trim()){ return explicit.trim(); }
}
var verb = (detail.verb || 'GET').toUpperCase();
var path = detail.path || '';
var params = detail.parameters && Object.keys(detail.parameters).length ? JSON.stringify(detail.parameters) : '';
return verb + ' ' + path + ' ' + params;
}
function set(key, html, ttl, meta){
if (!key || typeof html !== 'string') return;
store.set(key, {
key: key,
html: html,
expires: Date.now() + (ttl || 30000),
meta: meta || {},
});
}
function get(key){
if (!key) return null;
var entry = store.get(key);
if (!entry) return null;
if (entry.expires && entry.expires <= Date.now()){
store.delete(key);
return null;
}
return entry;
}
function applyCached(elt, detail, entry){
if (!entry) return;
var target = detail && detail.target ? detail.target : elt;
if (!target) return;
dispatchHtmx(target, 'htmx:beforeSwap', { elt: elt, target: target, cache: true, cacheKey: entry.key });
var swapSpec = '';
try { swapSpec = (elt && elt.getAttribute && elt.getAttribute('hx-swap')) || ''; } catch(_){ }
swapSpec = (swapSpec || 'innerHTML').toLowerCase();
if (swapSpec.indexOf('outer') === 0){
if (target.outerHTML !== undefined){
target.outerHTML = entry.html;
}
} else if (target.innerHTML !== undefined){
target.innerHTML = entry.html;
}
if (window.htmx && typeof window.htmx.process === 'function'){
window.htmx.process(target);
}
dispatchHtmx(target, 'htmx:afterSwap', { elt: elt, target: target, cache: true, cacheKey: entry.key });
dispatchHtmx(target, 'htmx:afterRequest', { elt: elt, target: target, cache: true, cacheKey: entry.key });
}
function prefetch(url, opts){
if (!url) return;
opts = opts || {};
var key = opts.key || ('GET ' + url);
if (get(key)) return;
try {
fetch(url, {
headers: { 'HX-Request': 'true', 'Accept': 'text/html' },
cache: 'no-store',
}).then(function(resp){
if (!resp.ok) throw new Error('prefetch failed');
return resp.text();
}).then(function(html){
set(key, html, opts.ttl || opts.cacheTtl || 30000, { url: url, prefetch: true });
telemetry.send('htmx.cache.prefetch', { key: key, url: url });
}).catch(function(){ /* noop */ });
} catch(_){ }
}
return {
set: set,
get: get,
apply: applyCached,
buildKey: buildKey,
ttlFor: ttlFor,
prefetch: prefetch,
};
})();
window.htmxCache = htmxCache;
document.addEventListener('htmx:configRequest', function(e){
var detail = e && e.detail ? e.detail : {};
var elt = detail.elt;
if (!elt || !elt.getAttribute || !elt.hasAttribute('data-hx-cache')) return;
var verb = (detail.verb || 'GET').toUpperCase();
if (verb !== 'GET') return;
var key = htmxCache.buildKey(detail, elt);
elt.__hxCacheKey = key;
elt.__hxCacheTTL = htmxCache.ttlFor(elt);
detail.headers = detail.headers || {};
try { detail.headers['X-HTMX-Cache-Key'] = key; } catch(_){ }
});
document.addEventListener('htmx:beforeRequest', function(e){
var detail = e && e.detail ? e.detail : {};
var elt = detail.elt;
if (!elt || !elt.__hxCacheKey) return;
var entry = htmxCache.get(elt.__hxCacheKey);
if (entry){
telemetry.send('htmx.cache.hit', { key: elt.__hxCacheKey, path: detail.path || '' });
e.preventDefault();
htmxCache.apply(elt, detail, entry);
} else {
telemetry.send('htmx.cache.miss', { key: elt.__hxCacheKey, path: detail.path || '' });
}
});
document.addEventListener('htmx:afterSwap', function(e){
var detail = e && e.detail ? e.detail : {};
var elt = detail.elt;
if (!elt || !elt.__hxCacheKey) return;
try {
var xhr = detail.xhr;
var status = xhr && xhr.status ? xhr.status : 0;
if (status >= 200 && status < 300 && xhr && typeof xhr.responseText === 'string'){
var ttl = elt.__hxCacheTTL || 30000;
htmxCache.set(elt.__hxCacheKey, xhr.responseText, ttl, { path: detail.path || '' });
telemetry.send('htmx.cache.store', { key: elt.__hxCacheKey, path: detail.path || '', ttl: ttl });
}
} catch(_){ }
elt.__hxCacheKey = null;
elt.__hxCacheTTL = null;
});
(function(){
function handlePrefetch(evt){
try {
var el = evt.target && evt.target.closest ? evt.target.closest('[data-hx-prefetch]') : null;
if (!el || el.__hxPrefetched) return;
var url = el.getAttribute('data-hx-prefetch');
if (!url) return;
el.__hxPrefetched = true;
var key = el.getAttribute('data-hx-cache-key') || el.getAttribute('data-hx-prefetch-key') || ('GET ' + url);
var ttlAttr = parseInt((el.getAttribute('data-hx-cache-ttl') || el.getAttribute('data-hx-prefetch-ttl') || ''), 10);
var ttl = isNaN(ttlAttr) ? 30000 : Math.max(1000, ttlAttr);
htmxCache.prefetch(url, { key: key, ttl: ttl });
} catch(_){ }
}
document.addEventListener('pointerenter', handlePrefetch, true);
document.addEventListener('focusin', handlePrefetch, true);
})();
// Centralized HTMX debounce helper (applies to inputs tagged with data-hx-debounce)
var hxDebounceGroups = new Map();
function dispatchHtmx(el, evtName, detail){
if (!el) return;
if (window.htmx && typeof window.htmx.trigger === 'function'){
window.htmx.trigger(el, evtName, detail);
} else {
try { el.dispatchEvent(new CustomEvent(evtName, { bubbles: true, detail: detail })); } catch(_){ }
}
}
function bindHtmxDebounce(el){
if (!el || el.__hxDebounceBound) return;
el.__hxDebounceBound = true;
var delayRaw = parseInt(el.getAttribute('data-hx-debounce') || '', 10);
var delay = isNaN(delayRaw) ? 250 : Math.max(0, delayRaw);
var eventsAttr = el.getAttribute('data-hx-debounce-events') || 'input';
var events = eventsAttr.split(',').map(function(v){ return v.trim(); }).filter(Boolean);
if (!events.length){ events = ['input']; }
var trigger = el.getAttribute('data-hx-debounce-trigger') || 'debouncedinput';
var group = el.getAttribute('data-hx-debounce-group') || '';
var flushAttr = (el.getAttribute('data-hx-debounce-flush') || '').toLowerCase();
var flushOnBlur = (flushAttr === 'blur') || (flushAttr === '1') || (flushAttr === 'true');
function clearTimer(){
if (el.__hxDebounceTimer){
clearTimeout(el.__hxDebounceTimer);
el.__hxDebounceTimer = null;
}
}
function schedule(){
clearTimer();
if (group){
var prev = hxDebounceGroups.get(group);
if (prev && prev !== el && prev.__hxDebounceTimer){
clearTimeout(prev.__hxDebounceTimer);
prev.__hxDebounceTimer = null;
}
hxDebounceGroups.set(group, el);
}
el.__hxDebounceTimer = setTimeout(function(){
el.__hxDebounceTimer = null;
dispatchHtmx(el, trigger);
}, delay);
}
events.forEach(function(evt){
el.addEventListener(evt, schedule, { passive: true });
});
if (flushOnBlur){
el.addEventListener('blur', function(){
if (el.__hxDebounceTimer){
clearTimer();
dispatchHtmx(el, trigger);
}
});
}
el.addEventListener('htmx:beforeRequest', clearTimer);
}
function initHtmxDebounce(root){
var scope = root || document;
if (scope === document){ scope = document.body || document; }
if (!scope) return;
var seen = new Set();
function collect(candidate){
if (!candidate || seen.has(candidate)) return;
seen.add(candidate);
bindHtmxDebounce(candidate);
}
if (scope.matches && scope.hasAttribute && scope.hasAttribute('data-hx-debounce')){
collect(scope);
}
if (scope.querySelectorAll){
scope.querySelectorAll('[data-hx-debounce]').forEach(collect);
}
}
window.initHtmxDebounce = initHtmxDebounce;
// Example: persist "show skipped" toggle if present
document.addEventListener('change', function(e){
var el = e.target;
if (el && el.matches('[data-pref]')){
var key = el.getAttribute('data-pref');
var val = (el.type === 'checkbox') ? !!el.checked : el.value;
state.set(key, val);
state.inHash((function(o){ o[key] = val; return o; })({}));
}
});
// On load, initialize any data-pref elements
document.addEventListener('DOMContentLoaded', function(){
document.querySelectorAll('[data-pref]').forEach(function(el){
var key = el.getAttribute('data-pref');
var saved = state.get(key, undefined);
if (typeof saved !== 'undefined'){
if (el.type === 'checkbox') el.checked = !!saved; else el.value = saved;
}
});
hydrateProgress(document);
syncShowSkipped(document);
initCardFilters(document);
initVirtualization(document);
initHtmxDebounce(document);
initMustHaveControls(document);
});
// Hydrate progress bars with width based on data-pct
function hydrateProgress(root){
(root || document).querySelectorAll('.progress[data-pct]')
.forEach(function(p){
var pct = parseInt(p.getAttribute('data-pct') || '0', 10);
if (isNaN(pct) || pct < 0) pct = 0; if (pct > 100) pct = 100;
var bar = p.querySelector('.bar'); if (!bar) return;
// Animate width for a bit of delight
requestAnimationFrame(function(){ bar.style.width = pct + '%'; });
});
}
// Keep hidden inputs for show_skipped in sync with the sticky checkbox
function syncShowSkipped(root){
var cb = (root || document).querySelector('input[name="__toggle_show_skipped"][data-pref]');
if (!cb) return;
var val = cb.checked ? '1' : '0';
(root || document).querySelectorAll('section form').forEach(function(f){
var h = f.querySelector('input[name="show_skipped"]');
if (h) h.value = val;
});
}
document.addEventListener('htmx:afterSwap', function(e){
hydrateProgress(e.target);
syncShowSkipped(e.target);
initCardFilters(e.target);
initVirtualization(e.target);
initHtmxDebounce(e.target);
initMustHaveControls(e.target);
});
// Scroll a card-tile into view (cooperates with virtualization by re-rendering first)
function scrollCardIntoView(name){
if (!name) return;
try{
var section = document.querySelector('section');
var grid = section && section.querySelector('.card-grid');
if (!grid) return;
// If virtualized, force a render around the approximate match by searching stored children
var target = grid.querySelector('.card-tile[data-card-name="'+CSS.escape(name)+'"]');
if (!target) {
// Trigger a render update and try again
grid.dispatchEvent(new Event('scroll')); // noop but can refresh
target = grid.querySelector('.card-tile[data-card-name="'+CSS.escape(name)+'"]');
}
if (target) {
target.scrollIntoView({ block: 'center', behavior: 'smooth' });
target.focus && target.focus();
}
}catch(_){}
}
window.scrollCardIntoView = scrollCardIntoView;
// --- Card grid filters, reasons, and collapsible groups ---
function initCardFilters(root){
var section = (root || document).querySelector('section');
if (!section) return;
var toolbar = section.querySelector('.cards-toolbar');
if (!toolbar) return; // nothing to do
var q = toolbar.querySelector('input[name="filter_query"]');
var ownedSel = toolbar.querySelector('select[name="filter_owned"]');
var showReasons = toolbar.querySelector('input[name="show_reasons"]');
var collapseGroups = toolbar.querySelector('input[name="collapse_groups"]');
var resultsEl = toolbar.querySelector('[data-results]');
var emptyEl = section.querySelector('[data-empty]');
var sortSel = toolbar.querySelector('select[name="filter_sort"]');
var chipOwned = toolbar.querySelector('[data-chip-owned="owned"]');
var chipNot = toolbar.querySelector('[data-chip-owned="not"]');
var chipAll = toolbar.querySelector('[data-chip-owned="all"]');
var chipClear = toolbar.querySelector('[data-chip-clear]');
function getVal(el){ return el ? (el.type === 'checkbox' ? !!el.checked : (el.value||'')) : ''; }
// Read URL hash on first init to hydrate controls
try {
var params = window.__mtgState.readHash();
if (params){
var hv = params.get('q'); if (q && hv !== null) q.value = hv;
hv = params.get('owned'); if (ownedSel && hv) ownedSel.value = hv;
hv = params.get('showreasons'); if (showReasons && hv !== null) showReasons.checked = (hv === '1');
hv = params.get('collapse'); if (collapseGroups && hv !== null) collapseGroups.checked = (hv === '1');
hv = params.get('sort'); if (sortSel && hv) sortSel.value = hv;
}
} catch(_){}
function apply(){
var query = (getVal(q)+ '').toLowerCase().trim();
var ownedMode = (getVal(ownedSel) || 'all');
var showR = !!getVal(showReasons);
var collapse = !!getVal(collapseGroups);
var sortMode = (getVal(sortSel) || 'az');
// Toggle reasons visibility via section class
section.classList.toggle('hide-reasons', !showR);
// Collapse or expand all groups if toggle exists; when not collapsed, restore per-group stored state
section.querySelectorAll('.group').forEach(function(wrapper){
var grid = wrapper.querySelector('.group-grid'); if (!grid) return;
var key = wrapper.getAttribute('data-group-key');
if (collapse){
grid.setAttribute('data-collapsed','1');
} else {
// restore stored
if (key){
var stored = state.get('cards:group:'+key, null);
if (stored === true){ grid.setAttribute('data-collapsed','1'); }
else { grid.removeAttribute('data-collapsed'); }
} else {
grid.removeAttribute('data-collapsed');
}
}
});
// Filter tiles
var tiles = section.querySelectorAll('.card-grid .card-tile');
var visible = 0;
tiles.forEach(function(tile){
var name = (tile.getAttribute('data-card-name')||'').toLowerCase();
var role = (tile.getAttribute('data-role')||'').toLowerCase();
var tags = (tile.getAttribute('data-tags')||'').toLowerCase();
var tagsSlug = (tile.getAttribute('data-tags-slug')||'').toLowerCase();
var owned = tile.getAttribute('data-owned') === '1';
var text = name + ' ' + role + ' ' + tags + ' ' + tagsSlug;
var qOk = !query || text.indexOf(query) !== -1;
var oOk = (ownedMode === 'all') || (ownedMode === 'owned' && owned) || (ownedMode === 'not' && !owned);
var show = qOk && oOk;
tile.style.display = show ? '' : 'none';
if (show) visible++;
});
// Sort within each grid
function keyFor(tile){
var name = (tile.getAttribute('data-card-name')||'');
var owned = tile.getAttribute('data-owned') === '1' ? 1 : 0;
var gc = tile.classList.contains('game-changer') ? 1 : 0;
return { name: name.toLowerCase(), owned: owned, gc: gc };
}
section.querySelectorAll('.card-grid').forEach(function(grid){
var arr = Array.prototype.slice.call(grid.querySelectorAll('.card-tile'));
arr.sort(function(a,b){
var ka = keyFor(a), kb = keyFor(b);
if (sortMode === 'owned'){
if (kb.owned !== ka.owned) return kb.owned - ka.owned;
if (kb.gc !== ka.gc) return kb.gc - ka.gc; // gc next
return ka.name.localeCompare(kb.name);
} else if (sortMode === 'gc'){
if (kb.gc !== ka.gc) return kb.gc - ka.gc;
if (kb.owned !== ka.owned) return kb.owned - ka.owned;
return ka.name.localeCompare(kb.name);
}
// default AZ
return ka.name.localeCompare(kb.name);
});
arr.forEach(function(el){ grid.appendChild(el); });
});
// Update group counts based on visible tiles within each group
section.querySelectorAll('.group').forEach(function(wrapper){
var grid = wrapper.querySelector('.group-grid');
var count = 0;
if (grid){
grid.querySelectorAll('.card-tile').forEach(function(t){ if (t.style.display !== 'none') count++; });
}
var cEl = wrapper.querySelector('[data-count]');
if (cEl) cEl.textContent = count;
});
if (resultsEl) resultsEl.textContent = String(visible);
if (emptyEl) emptyEl.hidden = (visible !== 0);
// Persist prefs
if (q && q.hasAttribute('data-pref')) state.set(q.getAttribute('data-pref'), q.value);
if (ownedSel && ownedSel.hasAttribute('data-pref')) state.set(ownedSel.getAttribute('data-pref'), ownedSel.value);
if (showReasons && showReasons.hasAttribute('data-pref')) state.set(showReasons.getAttribute('data-pref'), !!showReasons.checked);
if (collapseGroups && collapseGroups.hasAttribute('data-pref')) state.set(collapseGroups.getAttribute('data-pref'), !!collapseGroups.checked);
if (sortSel && sortSel.hasAttribute('data-pref')) state.set(sortSel.getAttribute('data-pref'), sortSel.value);
// Update URL hash for shareability
try { window.__mtgState.inHash({ q: query, owned: ownedMode, showreasons: showR ? 1 : 0, collapse: collapse ? 1 : 0, sort: sortMode }); } catch(_){ }
}
// Wire events
if (q) q.addEventListener('input', apply);
if (ownedSel) ownedSel.addEventListener('change', apply);
if (showReasons) showReasons.addEventListener('change', apply);
if (collapseGroups) collapseGroups.addEventListener('change', apply);
if (chipOwned) chipOwned.addEventListener('click', function(){ if (ownedSel){ ownedSel.value = 'owned'; } apply(); });
if (chipNot) chipNot.addEventListener('click', function(){ if (ownedSel){ ownedSel.value = 'not'; } apply(); });
if (chipAll) chipAll.addEventListener('click', function(){ if (ownedSel){ ownedSel.value = 'all'; } apply(); });
if (chipClear) chipClear.addEventListener('click', function(){ if (q) q.value=''; if (ownedSel) ownedSel.value='all'; apply(); });
// Individual group toggles
section.querySelectorAll('.group-header .toggle').forEach(function(btn){
btn.addEventListener('click', function(){
var wrapper = btn.closest('.group');
var grid = wrapper && wrapper.querySelector('.group-grid');
if (!grid) return;
var key = wrapper.getAttribute('data-group-key');
var willCollapse = !grid.getAttribute('data-collapsed');
if (willCollapse) grid.setAttribute('data-collapsed','1'); else grid.removeAttribute('data-collapsed');
if (key){ state.set('cards:group:'+key, !!willCollapse); }
// ARIA
btn.setAttribute('aria-expanded', willCollapse ? 'false' : 'true');
});
});
// Per-card reason toggle: delegate clicks on .btn-why
section.addEventListener('click', function(e){
var t = e.target;
if (!t || !t.classList || !t.classList.contains('btn-why')) return;
e.preventDefault();
var tile = t.closest('.card-tile');
if (!tile) return;
var globalHidden = section.classList.contains('hide-reasons');
if (globalHidden){
// Force-show overrides global hidden
var on = tile.classList.toggle('force-show');
if (on) tile.classList.remove('force-hide');
t.textContent = on ? 'Hide why' : 'Why?';
} else {
// Hide this tile only
var off = tile.classList.toggle('force-hide');
if (off) tile.classList.remove('force-show');
t.textContent = off ? 'Show why' : 'Hide why';
}
});
// Initial apply on hydrate
apply();
// Keyboard helpers: '/' focuses query, Esc clears
function onKey(e){
// avoid when typing in inputs
if (e.target && (/input|textarea|select/i).test(e.target.tagName)) return;
if (e.key === '/'){
if (q){ e.preventDefault(); q.focus(); q.select && q.select(); }
} else if (e.key === 'Escape'){
if (q && q.value){ q.value=''; apply(); }
}
}
document.addEventListener('keydown', onKey);
}
// --- Lightweight virtualization (feature-flagged via data-virtualize) ---
function initVirtualization(root){
try{
var body = document.body || document.documentElement;
var DIAG = !!(body && body.getAttribute('data-diag') === '1');
// Global diagnostics aggregator
var GLOBAL = (function(){
if (!DIAG) return null;
if (window.__virtGlobal) return window.__virtGlobal;
var store = { grids: [], summaryEl: null };
function ensure(){
if (!store.summaryEl){
var el = document.createElement('div');
el.id = 'virt-global-diag';
el.style.position = 'fixed';
el.style.right = '8px';
el.style.bottom = '8px';
el.style.background = 'rgba(17,24,39,.85)';
el.style.border = '1px solid var(--border)';
el.style.padding = '.25rem .5rem';
el.style.borderRadius = '6px';
el.style.fontSize = '12px';
el.style.color = '#cbd5e1';
el.style.zIndex = '50';
el.style.boxShadow = '0 4px 12px rgba(0,0,0,.35)';
el.style.cursor = 'default';
// Hidden by default; toggle with 'v'
el.style.display = 'none';
document.body.appendChild(el);
store.summaryEl = el;
}
return store.summaryEl;
}
function update(){
var el = ensure(); if (!el) return;
var g = store.grids;
var total = 0, visible = 0, lastMs = 0;
for (var i=0;i<g.length;i++){
total += g[i].total||0;
visible += (g[i].end||0) - (g[i].start||0);
lastMs = Math.max(lastMs, g[i].lastMs||0);
}
el.textContent = 'virt sum: grids '+g.length+' • visible '+visible+'/'+total+' • last '+lastMs.toFixed ? lastMs.toFixed(1) : String(lastMs)+'ms';
}
function register(gridId, ref){
store.grids.push({ id: gridId, ref: ref });
update();
return {
set: function(stats){
for (var i=0;i<store.grids.length;i++){
if (store.grids[i].id === gridId){
store.grids[i] = Object.assign({ id: gridId, ref: ref }, stats);
break;
}
}
update();
},
toggle: function(){ var el = ensure(); el.style.display = (el.style.display === 'none' ? '' : 'none'); }
};
}
window.__virtGlobal = { register: register, toggle: function(){ var el = ensure(); el.style.display = (el.style.display === 'none' ? '' : 'none'); } };
return window.__virtGlobal;
})();
// Support card grids and other scroll containers (e.g., #owned-box)
var grids = (root || document).querySelectorAll('.card-grid[data-virtualize="1"], #owned-box[data-virtualize="1"]');
if (!grids.length) return;
grids.forEach(function(grid){
if (grid.__virtBound) return;
grid.__virtBound = true;
// Basic windowing: assumes roughly similar tile heights; uses sentinel measurements.
var container = grid;
container.style.position = container.style.position || 'relative';
var wrapper = document.createElement('div');
wrapper.className = 'virt-wrapper';
// Ensure wrapper itself is a grid to preserve multi-column layout inside
// when the container (e.g., .card-grid) is virtualized.
wrapper.style.display = 'grid';
// Move children into a fragment store (for owned, children live under UL)
var source = container;
// If this is the owned box, use the UL inside as the source list
var ownedGrid = container.id === 'owned-box' ? container.querySelector('#owned-grid') : null;
if (ownedGrid) { source = ownedGrid; }
var all = Array.prototype.slice.call(source.children);
// Threshold: skip virtualization for small grids to avoid scroll jitter at end-of-list.
// Empirically flicker was reported when reaching the bottom of short grids (e.g., < 80 tiles)
// due to dynamic height adjustments (image loads + padding recalcs). Keeping full DOM
// is cheaper than the complexity for small sets.
var MIN_VIRT_ITEMS = 80;
if (all.length < MIN_VIRT_ITEMS){
// Mark as processed so we don't attempt again on HTMX swaps.
return; // children remain in place; no virtualization applied.
}
var store = document.createElement('div');
store.style.display = 'none';
all.forEach(function(n){ store.appendChild(n); });
var padTop = document.createElement('div');
var padBottom = document.createElement('div');
padTop.style.height = '0px'; padBottom.style.height = '0px';
// For owned, keep the UL but render into it; otherwise append wrapper to container
if (ownedGrid){
ownedGrid.innerHTML = '';
ownedGrid.appendChild(padTop);
ownedGrid.appendChild(wrapper);
ownedGrid.appendChild(padBottom);
ownedGrid.appendChild(store);
} else {
container.appendChild(wrapper);
container.appendChild(padBottom);
container.appendChild(store);
}
var rowH = container.id === 'owned-box' ? 160 : 240; // estimate tile height
var perRow = 1;
// Optional diagnostics overlay
var diagBox = null; var lastRenderAt = 0; var lastRenderMs = 0;
var renderCount = 0; var measureCount = 0; var swapCount = 0;
var gridId = (container.id || container.className || 'grid') + '#' + Math.floor(Math.random()*1e6);
var globalReg = DIAG && GLOBAL ? GLOBAL.register(gridId, container) : null;
function fmt(n){ try{ return (Math.round(n*10)/10).toFixed(1); }catch(_){ return String(n); } }
function ensureDiag(){
if (!DIAG) return null;
if (diagBox) return diagBox;
diagBox = document.createElement('div');
diagBox.className = 'virt-diag';
diagBox.style.position = 'sticky';
diagBox.style.top = '0';
diagBox.style.zIndex = '5';
diagBox.style.background = 'rgba(17,24,39,.85)';
diagBox.style.border = '1px solid var(--border)';
diagBox.style.padding = '.25rem .5rem';
diagBox.style.borderRadius = '6px';
diagBox.style.fontSize = '12px';
diagBox.style.margin = '0 0 .35rem 0';
diagBox.style.color = '#cbd5e1';
diagBox.style.display = 'none'; // hidden until toggled
// Controls
var controls = document.createElement('div');
controls.style.display = 'flex';
controls.style.gap = '.35rem';
controls.style.alignItems = 'center';
controls.style.marginBottom = '.25rem';
var title = document.createElement('div'); title.textContent = 'virt diag'; title.style.fontWeight = '600'; title.style.fontSize = '11px'; title.style.color = '#9ca3af';
var btnCopy = document.createElement('button'); btnCopy.type = 'button'; btnCopy.textContent = 'Copy'; btnCopy.className = 'btn small';
btnCopy.addEventListener('click', function(){ try{ var payload = {
id: gridId, rowH: rowH, perRow: perRow, start: start, end: end, total: total,
renderCount: renderCount, measureCount: measureCount, swapCount: swapCount,
lastRenderMs: lastRenderMs, lastRenderAt: lastRenderAt
}; navigator.clipboard.writeText(JSON.stringify(payload, null, 2)); btnCopy.textContent = 'Copied'; setTimeout(function(){ btnCopy.textContent = 'Copy'; }, 1200); }catch(_){ }
});
var btnHide = document.createElement('button'); btnHide.type = 'button'; btnHide.textContent = 'Hide'; btnHide.className = 'btn small';
btnHide.addEventListener('click', function(){ diagBox.style.display = 'none'; });
controls.appendChild(title); controls.appendChild(btnCopy); controls.appendChild(btnHide);
diagBox.appendChild(controls);
var text = document.createElement('div'); text.className = 'virt-diag-text'; diagBox.appendChild(text);
var host = (container.id === 'owned-box') ? container : container.parentElement || container;
host.insertBefore(diagBox, host.firstChild);
return diagBox;
}
function measure(){
try {
measureCount++;
// create a temp tile to measure if none
var probe = store.firstElementChild || all[0];
if (probe){
var fake = probe.cloneNode(true);
fake.style.position = 'absolute'; fake.style.visibility = 'hidden'; fake.style.pointerEvents = 'none';
(ownedGrid || container).appendChild(fake);
var rect = fake.getBoundingClientRect();
rowH = Math.max(120, Math.ceil(rect.height) + 16);
(ownedGrid || container).removeChild(fake);
}
// Estimate perRow via computed styles of grid
var style = window.getComputedStyle(ownedGrid || container);
var cols = style.getPropertyValue('grid-template-columns');
// Mirror grid settings onto the wrapper so its children still flow in columns
try {
if (cols && cols.trim()) wrapper.style.gridTemplateColumns = cols;
var gap = style.getPropertyValue('gap') || style.getPropertyValue('grid-gap');
if (gap && gap.trim()) wrapper.style.gap = gap;
// Inherit justify/align if present
var ji = style.getPropertyValue('justify-items');
if (ji && ji.trim()) wrapper.style.justifyItems = ji;
var ai = style.getPropertyValue('align-items');
if (ai && ai.trim()) wrapper.style.alignItems = ai;
} catch(_) {}
perRow = Math.max(1, (cols && cols.split ? cols.split(' ').filter(function(x){return x && (x.indexOf('px')>-1 || x.indexOf('fr')>-1 || x.indexOf('minmax(')>-1);}).length : 1));
} catch(_){}
}
measure();
var total = all.length;
var start = 0, end = 0;
function render(){
var t0 = DIAG ? performance.now() : 0;
var scroller = container;
var vh = scroller.clientHeight || window.innerHeight;
var scrollTop = scroller.scrollTop;
// If container isnt scrollable, use window scroll offset
var top = scrollTop || (scroller.getBoundingClientRect().top < 0 ? -scroller.getBoundingClientRect().top : 0);
var rowsInView = Math.ceil(vh / rowH) + 2; // overscan
var rowStart = Math.max(0, Math.floor(top / rowH) - 1);
var rowEnd = Math.min(Math.ceil((top / rowH)) + rowsInView, Math.ceil(total / perRow));
var newStart = rowStart * perRow;
var newEnd = Math.min(total, rowEnd * perRow);
if (newStart === start && newEnd === end) return; // no change
start = newStart; end = newEnd;
// Padding
var beforeRows = Math.floor(start / perRow);
var afterRows = Math.ceil((total - end) / perRow);
padTop.style.height = (beforeRows * rowH) + 'px';
padBottom.style.height = (afterRows * rowH) + 'px';
// Render visible children
wrapper.innerHTML = '';
for (var i = start; i < end; i++) {
var node = all[i];
if (node) wrapper.appendChild(node);
}
if (DIAG){
var box = ensureDiag();
if (box){
var dt = performance.now() - t0; lastRenderMs = dt; renderCount++; lastRenderAt = Date.now();
var vis = end - start; var rowsTotal = Math.ceil(total / perRow);
var textEl = box.querySelector('.virt-diag-text');
var msg = 'range ['+start+'..'+end+') of '+total+' • vis '+vis+' • rows ~'+rowsTotal+' • perRow '+perRow+' • rowH '+rowH+'px • render '+fmt(dt)+'ms • renders '+renderCount+' • measures '+measureCount+' • swaps '+swapCount;
textEl.textContent = msg;
// Health hint
var bad = (dt > 33) || (vis > 300);
var warn = (!bad) && ((dt > 16) || (vis > 200));
box.style.borderColor = bad ? '#ef4444' : (warn ? '#f59e0b' : 'var(--border)');
box.style.boxShadow = bad ? '0 0 0 1px rgba(239,68,68,.35)' : (warn ? '0 0 0 1px rgba(245,158,11,.25)' : 'none');
if (globalReg && globalReg.set){ globalReg.set({ total: total, start: start, end: end, lastMs: dt }); }
}
}
}
function onScroll(){ render(); }
function onResize(){ measure(); render(); }
container.addEventListener('scroll', onScroll);
window.addEventListener('resize', onResize);
// Initial size; ensure container is scrollable for our logic
if (!container.style.maxHeight) container.style.maxHeight = '70vh';
container.style.overflow = container.style.overflow || 'auto';
render();
// Re-render after filters resort or HTMX swaps
document.addEventListener('htmx:afterSwap', function(ev){ if (container.contains(ev.target)) { swapCount++; all = Array.prototype.slice.call(store.children).concat(Array.prototype.slice.call(wrapper.children)); total = all.length; measure(); render(); } });
// Keyboard toggle for overlays: 'v'
if (DIAG && !window.__virtHotkeyBound){
window.__virtHotkeyBound = true;
document.addEventListener('keydown', function(e){
try{
if (e.target && (/input|textarea|select/i).test(e.target.tagName)) return;
if (e.key && e.key.toLowerCase() === 'v'){
e.preventDefault();
// Toggle all virt-diag boxes and the global summary
var shown = null;
document.querySelectorAll('.virt-diag').forEach(function(b){ if (shown === null) shown = (b.style.display === 'none'); b.style.display = shown ? '' : 'none'; });
if (GLOBAL && GLOBAL.toggle) GLOBAL.toggle();
}
}catch(_){ }
});
}
});
}catch(_){ }
}
function setTileState(tile, type, active){
if (!tile) return;
var attr = 'data-must-' + type;
tile.setAttribute(attr, active ? '1' : '0');
tile.classList.toggle('must-' + type, !!active);
var selector = '.must-have-btn.' + (type === 'include' ? 'include' : 'exclude');
try {
var btn = tile.querySelector(selector);
if (btn){
btn.setAttribute('data-active', active ? '1' : '0');
btn.setAttribute('aria-pressed', active ? 'true' : 'false');
btn.classList.toggle('is-active', !!active);
}
} catch(_){ }
}
function restoreMustHaveState(tile, state){
if (!tile || !state) return;
setTileState(tile, 'include', state.include ? 1 : 0);
setTileState(tile, 'exclude', state.exclude ? 1 : 0);
}
function applyLocalMustHave(tile, type, enabled){
if (!tile) return;
if (type === 'include'){
setTileState(tile, 'include', enabled ? 1 : 0);
if (enabled){ setTileState(tile, 'exclude', 0); }
} else if (type === 'exclude'){
setTileState(tile, 'exclude', enabled ? 1 : 0);
if (enabled){ setTileState(tile, 'include', 0); }
}
}
function sendMustHaveRequest(tile, type, enabled, cardName, prevState){
if (!window.htmx){
restoreMustHaveState(tile, prevState);
tile.setAttribute('data-must-pending', '0');
toast('Offline: cannot update preference', 'error', { duration: 4000 });
return;
}
var summaryTarget = document.getElementById('include-exclude-summary');
var ajaxOptions = {
source: tile,
target: summaryTarget || tile,
swap: summaryTarget ? 'outerHTML' : 'none',
values: {
card_name: cardName,
list_type: type,
enabled: enabled ? '1' : '0',
},
};
var xhr;
try {
xhr = window.htmx.ajax('POST', '/build/must-haves/toggle', ajaxOptions);
} catch(_){
restoreMustHaveState(tile, prevState);
tile.setAttribute('data-must-pending', '0');
toast('Unable to submit preference update', 'error', { duration: 4500 });
telemetry.send('must_have.toggle_error', { card: cardName, list: type, status: 'exception' });
return;
}
if (!xhr || !xhr.addEventListener){
tile.setAttribute('data-must-pending', '0');
return;
}
xhr.addEventListener('load', function(evt){
tile.setAttribute('data-must-pending', '0');
var request = evt && evt.currentTarget ? evt.currentTarget : xhr;
var status = request.status || 0;
if (status >= 400){
restoreMustHaveState(tile, prevState);
var msg = 'Failed to update preference';
try {
var data = JSON.parse(request.responseText || '{}');
if (data && data.error) msg = data.error;
} catch(_){ }
toast(msg, 'error', { duration: 5000 });
telemetry.send('must_have.toggle_error', { card: cardName, list: type, status: status });
return;
}
var message;
if (enabled){
message = (type === 'include') ? 'Pinned as must include' : 'Pinned as must exclude';
} else {
message = (type === 'include') ? 'Removed must include' : 'Removed must exclude';
}
toast(message + ': ' + cardName, 'success', { duration: 2400 });
telemetry.send('must_have.toggle', {
card: cardName,
list: type,
enabled: enabled,
requestId: request.getResponseHeader ? request.getResponseHeader('X-Request-ID') : null,
});
});
xhr.addEventListener('error', function(){
tile.setAttribute('data-must-pending', '0');
restoreMustHaveState(tile, prevState);
toast('Network error updating preference', 'error', { duration: 5000 });
telemetry.send('must_have.toggle_error', { card: cardName, list: type, status: 'network' });
});
}
function initMustHaveControls(root){
var scope = root && root.querySelectorAll ? root : document;
if (scope === document && document.body) scope = document.body;
if (!scope || !scope.querySelectorAll) return;
scope.querySelectorAll('.must-have-btn').forEach(function(btn){
if (!btn || btn.__mustHaveBound) return;
btn.__mustHaveBound = true;
var active = btn.getAttribute('data-active') === '1';
btn.setAttribute('aria-pressed', active ? 'true' : 'false');
btn.addEventListener('click', function(ev){
ev.preventDefault();
var tile = btn.closest('.card-tile');
if (!tile) return;
if (tile.getAttribute('data-must-pending') === '1') return;
var type = btn.getAttribute('data-toggle');
if (!type) return;
var prevState = {
include: tile.getAttribute('data-must-include') === '1',
exclude: tile.getAttribute('data-must-exclude') === '1',
};
var nextEnabled = !(type === 'include' ? prevState.include : prevState.exclude);
var label = btn.getAttribute('data-card-label') || btn.getAttribute('data-card-name') || tile.getAttribute('data-card-name') || '';
tile.setAttribute('data-must-pending', '1');
applyLocalMustHave(tile, type, nextEnabled);
sendMustHaveRequest(tile, type, nextEnabled, label, prevState);
});
});
}
// LQIP blur/fade-in for thumbnails marked with data-lqip
document.addEventListener('DOMContentLoaded', function(){
try{
document.querySelectorAll('img[data-lqip]')
.forEach(function(img){
img.classList.add('lqip');
img.addEventListener('load', function(){ img.classList.add('loaded'); }, { once: true });
});
}catch(_){ }
});
})();