mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-16 15:40:12 +01:00
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.
552 lines
25 KiB
HTML
552 lines
25 KiB
HTML
<!doctype html>
|
|
<html lang="en" data-theme="{% if default_theme == 'light' %}light-blend{% elif default_theme == 'dark' %}dark{% else %}light-blend{% endif %}">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<meta http-equiv="Content-Security-Policy" content="upgrade-insecure-requests" />
|
|
<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>
|
|
(function(){
|
|
// Pre-CSS theme bootstrapping to avoid flash/mismatch on first paint
|
|
try{
|
|
var root = document.documentElement;
|
|
var KEY = 'mtg:theme';
|
|
var SERVER_DEFAULT = '{{ default_theme }}';
|
|
var params = new URLSearchParams(window.location.search || '');
|
|
var urlTheme = (params.get('theme') || '').toLowerCase();
|
|
var stored = localStorage.getItem(KEY);
|
|
function resolveSystem(){
|
|
var prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
return prefersDark ? 'dark' : 'light-blend';
|
|
}
|
|
function mapTheme(v){
|
|
var x = (v || 'system').toLowerCase();
|
|
if (x === 'system') return resolveSystem();
|
|
if (x === 'light') return 'light-blend';
|
|
return x;
|
|
}
|
|
var initial = urlTheme || ((stored && stored.trim()) ? stored : (SERVER_DEFAULT || 'system'));
|
|
root.setAttribute('data-theme', mapTheme(initial));
|
|
}catch(_){ }
|
|
})();
|
|
</script>
|
|
<script>
|
|
window.__telemetryEndpoint = '/telemetry/events';
|
|
</script>
|
|
<link rel="stylesheet" href="/static/styles.css?v=20250911-1" />
|
|
<link rel="stylesheet" href="/static/shared-components.css?v=20251021-1" />
|
|
<style>
|
|
/* Disable all transitions until page is loaded to prevent sidebar flash */
|
|
.no-transition,
|
|
.no-transition *,
|
|
.no-transition *::before,
|
|
.no-transition *::after {
|
|
transition: none !important;
|
|
animation: none !important;
|
|
}
|
|
</style>
|
|
<!-- Performance hints -->
|
|
<link rel="preconnect" href="https://api.scryfall.com" crossorigin>
|
|
<link rel="dns-prefetch" href="https://api.scryfall.com">
|
|
<!-- Favicon -->
|
|
<link rel="icon" type="image/png" href="/static/favicon.png" />
|
|
<link rel="shortcut icon" href="/favicon.ico" />
|
|
<link rel="apple-touch-icon" href="/static/favicon.png" />
|
|
{% if enable_pwa %}
|
|
<link rel="manifest" href="/static/manifest.webmanifest" />
|
|
{% endif %}
|
|
</head>
|
|
<body class="no-transition" data-diag="{% if show_diagnostics %}1{% else %}0{% endif %}" data-virt="{% if virtualize %}1{% else %}0{% endif %}">
|
|
<header class="top-banner">
|
|
<div class="top-inner">
|
|
<div class="flex-row banner-left">
|
|
<button type="button" id="nav-toggle" class="btn" aria-controls="sidebar" aria-expanded="true" title="Show/Hide navigation" style="background: transparent; color: var(--surface-banner-text); border:1px solid var(--border); flex-shrink: 0;">
|
|
☰ Menu
|
|
</button>
|
|
<h1 style="margin:0; white-space: nowrap;">MTG Deckbuilder</h1>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
<div class="layout">
|
|
<aside id="sidebar" class="sidebar" aria-label="Primary navigation">
|
|
<div class="brand">
|
|
<div class="mana-dots" aria-hidden="true">
|
|
<span class="dot green"></span>
|
|
<span class="dot blue"></span>
|
|
<span class="dot red"></span>
|
|
<span class="dot white"></span>
|
|
<span class="dot black"></span>
|
|
</div>
|
|
</div>
|
|
<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="/cards">All Cards</a>
|
|
{% if show_commanders %}<a href="/commanders">Commanders</a>{% endif %}
|
|
<a href="/decks">Finished Decks</a>
|
|
<a href="/themes/">Themes</a>
|
|
{% if random_ui %}<a href="/random">Random</a>{% endif %}
|
|
{% if show_diagnostics %}<a href="/diagnostics">Diagnostics</a>{% endif %}
|
|
{% if show_logs %}<a href="/logs">Logs</a>{% endif %}
|
|
</nav>
|
|
{% if enable_themes %}
|
|
<div class="sidebar-theme" role="group" aria-label="Theme">
|
|
<label class="sidebar-theme-label" for="theme-select">Theme</label>
|
|
<div class="sidebar-theme-row">
|
|
<select id="theme-select" aria-label="Theme selector">
|
|
<option value="system">System</option>
|
|
<option value="light">Light</option>
|
|
<option value="dark">Dark</option>
|
|
<option value="high-contrast">High contrast</option>
|
|
<option value="cb-friendly">Color-blind</option>
|
|
</select>
|
|
<button type="button" id="theme-reset" class="btn btn-ghost" title="Reset theme preference">Reset</button>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
</aside>
|
|
<main class="content" data-error-surface>
|
|
{% block content %}{% endblock %}
|
|
</main>
|
|
</div>
|
|
<footer class="site-footer" role="contentinfo">
|
|
Card images and data provided by
|
|
<a href="https://scryfall.com" target="_blank" rel="noopener">Scryfall</a>.
|
|
This website is not produced by, endorsed by, supported by, or affiliated with Scryfall or Wizards of the Coast.
|
|
</footer>
|
|
<!-- Card hover, theme badges, and DFC toggle styles moved to tailwind.css 2025-10-21 -->
|
|
<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(){
|
|
// Sidebar toggle and persistence
|
|
try{
|
|
var BODY = document.body;
|
|
var SIDEBAR = document.getElementById('sidebar');
|
|
var TOGGLE = document.getElementById('nav-toggle');
|
|
var KEY = 'mtg:navCollapsed';
|
|
|
|
function apply(collapsed){
|
|
if (collapsed){
|
|
BODY.classList.add('nav-collapsed');
|
|
TOGGLE && TOGGLE.setAttribute('aria-expanded', 'false');
|
|
SIDEBAR && SIDEBAR.setAttribute('aria-hidden', 'true');
|
|
} else {
|
|
BODY.classList.remove('nav-collapsed');
|
|
TOGGLE && TOGGLE.setAttribute('aria-expanded', 'true');
|
|
SIDEBAR && SIDEBAR.setAttribute('aria-hidden', 'false');
|
|
}
|
|
}
|
|
// Initial state: respect saved pref, else collapse on small screens
|
|
var saved = localStorage.getItem(KEY);
|
|
var initialCollapsed = (saved === '1') || (saved === null && (window.innerWidth || 0) < 900);
|
|
apply(initialCollapsed);
|
|
|
|
// Re-enable transitions after page is fully loaded
|
|
// Use longer delay for pages with heavy content (like card browser)
|
|
var enableTransitions = function(){
|
|
BODY.classList.remove('no-transition');
|
|
};
|
|
|
|
if (document.readyState === 'complete') {
|
|
// Already loaded
|
|
setTimeout(enableTransitions, 150);
|
|
} else {
|
|
window.addEventListener('load', function(){
|
|
setTimeout(enableTransitions, 150);
|
|
});
|
|
}
|
|
|
|
if (TOGGLE){
|
|
TOGGLE.addEventListener('click', function(){
|
|
var isCollapsed = BODY.classList.contains('nav-collapsed');
|
|
apply(!isCollapsed);
|
|
try{ localStorage.setItem(KEY, (!isCollapsed) ? '1' : '0'); }catch(_){ }
|
|
});
|
|
}
|
|
// Keep ARIA in sync on resize for first-load default when no pref yet
|
|
window.addEventListener('resize', function(){
|
|
// Do not override if user has an explicit preference saved
|
|
if (localStorage.getItem(KEY) !== null) return;
|
|
apply((window.innerWidth || 0) < 900);
|
|
});
|
|
}catch(_){ }
|
|
|
|
// Suppress sidebar transitions during HTMX partial updates (not full page loads)
|
|
document.addEventListener('htmx:beforeRequest', function(evt){
|
|
// Only suppress for small partial updates (identified by specific IDs)
|
|
var target = evt.detail && evt.detail.target;
|
|
if (target && target.id) {
|
|
var targetId = target.id;
|
|
// List of partial update containers that should suppress sidebar transitions
|
|
var partialContainers = ['similar-cards-container', 'card-list', 'theme-list'];
|
|
if (partialContainers.indexOf(targetId) !== -1 || targetId.indexOf('-container') !== -1) {
|
|
document.body.classList.add('htmx-settling');
|
|
}
|
|
}
|
|
});
|
|
document.addEventListener('htmx:afterSettle', function(){
|
|
document.body.classList.remove('htmx-settling');
|
|
});
|
|
|
|
// Setup/Tagging status poller
|
|
var statusEl;
|
|
function ensureStatusEl(){
|
|
if (!statusEl) statusEl = document.getElementById('banner-status');
|
|
return statusEl;
|
|
}
|
|
function renderSetupStatus(data){
|
|
var el = ensureStatusEl(); if (!el) return;
|
|
if (data && data.running) {
|
|
var msg = (data.message || 'Preparing data...');
|
|
var pct = (typeof data.percent === 'number') ? data.percent : null;
|
|
// Suppress banner if we're effectively finished (>=99%) or message is purely theme catalog refreshed
|
|
var suppress = false;
|
|
if (pct !== null && pct >= 99) suppress = true;
|
|
var lm = (msg || '').toLowerCase();
|
|
if (lm.indexOf('theme catalog refreshed') >= 0) suppress = true;
|
|
if (suppress) {
|
|
if (el.innerHTML) { el.innerHTML=''; el.classList.remove('busy'); }
|
|
return;
|
|
}
|
|
el.innerHTML = '<strong>Setup/Tagging:</strong> ' + msg + ' <a href="/setup/running" style="margin-left:.5rem;">View progress</a>';
|
|
el.classList.add('busy');
|
|
} else if (data && data.phase === 'done') {
|
|
el.innerHTML = '';
|
|
el.classList.remove('busy');
|
|
} else if (data && data.phase === 'error') {
|
|
el.innerHTML = '<span class="error">Setup error.</span>';
|
|
setTimeout(function(){ el.innerHTML = ''; el.classList.remove('busy'); }, 5000);
|
|
} else {
|
|
if (!el.innerHTML.trim()) el.innerHTML = '';
|
|
el.classList.remove('busy');
|
|
}
|
|
}
|
|
function pollStatus(){
|
|
try {
|
|
fetch('/status/setup', { cache: 'no-store' })
|
|
.then(function(r){ return r.json(); })
|
|
.then(renderSetupStatus)
|
|
.catch(function(){ /* noop */ });
|
|
} catch(e) {}
|
|
}
|
|
// Poll every 10 seconds instead of 3 to reduce server load (only for header indicator)
|
|
setInterval(pollStatus, 10000);
|
|
pollStatus();
|
|
|
|
// Expose normalizeCardName for cardImages module
|
|
window.__normalizeCardName = function(raw){
|
|
if(!raw) return raw;
|
|
var m = /(.*?)(\s*-\s*Synergy\s*\(.*\))$/i.exec(raw);
|
|
if(m){ return m[1].trim(); }
|
|
return raw;
|
|
};
|
|
})();
|
|
</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;
|
|
var normalize = (window.__normalizeCardName) ? window.__normalizeCardName : function(raw){
|
|
if(!raw) return raw;
|
|
var m = /(.*?)(\s*-\s*Synergy\s*\(.*\))$/i.exec(raw);
|
|
if(m){ return m[1].trim(); }
|
|
return raw;
|
|
};
|
|
window.__normalizeCardName = normalize;
|
|
function getCardData(card, attr){
|
|
if(!card) return '';
|
|
var val = card.getAttribute(attr);
|
|
if(val) return val;
|
|
var node = card.querySelector && card.querySelector('['+attr+']');
|
|
return node ? node.getAttribute(attr) : '';
|
|
}
|
|
function hasTwoFaces(card){
|
|
if(!card) return false;
|
|
|
|
// Check if card has a layout attribute - this is the source of truth
|
|
var layout = card.getAttribute('data-layout') || '';
|
|
if(layout) {
|
|
// Only these layouts are actual flippable double-faced cards
|
|
var flippableLayouts = ['modal_dfc', 'transform', 'reversible_card', 'flip', 'meld'];
|
|
return flippableLayouts.indexOf(layout) > -1;
|
|
}
|
|
|
|
// Fallback: If no layout data, check if name has // (backwards compatibility)
|
|
// This shouldn't happen if templates properly pass data-layout
|
|
var name = normalize(getCardData(card, 'data-card-name')) + ' ' + normalize(getCardData(card, 'data-original-name'));
|
|
return name.indexOf('//') > -1;
|
|
}
|
|
window.__dfcHasTwoFaces = hasTwoFaces; // Expose globally for popup hover panel
|
|
function keyFor(card){
|
|
var nm = normalize(getCardData(card, 'data-card-name') || getCardData(card, '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 = normalize(getCardData(card, '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>';
|
|
}
|
|
window.__dfcUpdateButton = updateButton;
|
|
function ensureButton(card){
|
|
if(!hasTwoFaces(card)) return;
|
|
if(card.querySelector('.dfc-toggle')) return;
|
|
card.classList.add('dfc-host');
|
|
var resolvedName = getCardData(card, 'data-card-name');
|
|
var resolvedOriginal = getCardData(card, 'data-original-name');
|
|
if(resolvedName && !card.hasAttribute('data-card-name')) card.setAttribute('data-card-name', resolvedName);
|
|
if(resolvedOriginal && !card.hasAttribute('data-original-name')) card.setAttribute('data-original-name', resolvedOriginal);
|
|
applyStoredFace(card);
|
|
var face = card.getAttribute(FACE_ATTR) || 'front';
|
|
var btn = document.createElement('button');
|
|
btn.type='button';
|
|
// Mobile: flip in popup only (flex below md). Desktop: flip in thumbnails only (hidden at md+)
|
|
var inPopup = card.closest && card.closest('#hover-card-panel');
|
|
btn.className = inPopup ? 'dfc-toggle flex md:hidden' : 'dfc-toggle hidden md:flex';
|
|
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);
|
|
if(card.classList.contains('list-row')){
|
|
btn.classList.add('dfc-toggle-inline');
|
|
var slot = card.querySelector('.flip-slot');
|
|
if(slot){
|
|
slot.innerHTML='';
|
|
slot.appendChild(btn);
|
|
slot.removeAttribute('aria-hidden');
|
|
} else {
|
|
var anchor = card.querySelector('.dfc-anchor');
|
|
if(anchor){ anchor.insertAdjacentElement('afterend', btn); }
|
|
else if(card.lastElementChild){ card.insertBefore(btn, card.lastElementChild); }
|
|
else { card.appendChild(btn); }
|
|
}
|
|
} else {
|
|
card.insertBefore(btn, card.firstChild);
|
|
}
|
|
}
|
|
window.__dfcEnsureButton = ensureButton; // Expose for hover panel use
|
|
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); }
|
|
if(window.__dfcNotifyHover){ try{ window.__dfcNotifyHover(card, next); }catch(_){ } }
|
|
}
|
|
window.__dfcFlipCard = function(card){ if(!card) return; flip(card, card.querySelector('.dfc-toggle')); };
|
|
window.__dfcGetFace = function(card){ if(!card) return 'front'; return card.getAttribute(FACE_ATTR) || 'front'; };
|
|
function scan(){
|
|
document.querySelectorAll('.card-sample, .commander-cell, .commander-thumb, .commander-card, .card-tile, .candidate-tile, .stack-card, .card-preview, .owned-row, .list-row').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/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(){
|
|
try{
|
|
var sel = document.getElementById('theme-select');
|
|
var resetBtn = document.getElementById('theme-reset');
|
|
var root = document.documentElement;
|
|
var KEY = 'mtg:theme';
|
|
var SERVER_DEFAULT = '{{ default_theme }}';
|
|
function mapLight(v){ return v === 'light' ? 'light-blend' : v; }
|
|
function resolveSystem(){
|
|
var prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
return prefersDark ? 'dark' : 'light-blend';
|
|
}
|
|
function normalizeUiValue(v){
|
|
var x = (v||'system').toLowerCase();
|
|
if (x === 'light-blend' || x === 'light-slate' || x === 'light-parchment') return 'light';
|
|
return x;
|
|
}
|
|
function apply(val){
|
|
var v = (val || 'system').toLowerCase();
|
|
if (v === 'system') v = resolveSystem();
|
|
v = mapLight(v);
|
|
root.setAttribute('data-theme', v);
|
|
}
|
|
// Optional URL override: ?theme=system|light|dark|high-contrast|cb-friendly
|
|
var params = new URLSearchParams(window.location.search || '');
|
|
var urlTheme = (params.get('theme') || '').toLowerCase();
|
|
if (urlTheme) {
|
|
// Persist the UI value, not the mapped CSS token
|
|
localStorage.setItem(KEY, normalizeUiValue(urlTheme));
|
|
// Clean the URL so reloads don't keep overriding
|
|
try { var u = new URL(window.location.href); u.searchParams.delete('theme'); window.history.replaceState({}, document.title, u.toString()); } catch(_){ }
|
|
}
|
|
// Determine initial selection: URL -> localStorage -> server default -> system
|
|
var stored = localStorage.getItem(KEY);
|
|
var initial = urlTheme || ((stored && stored.trim()) ? stored : (SERVER_DEFAULT || 'system'));
|
|
apply(initial);
|
|
if (sel){
|
|
sel.value = normalizeUiValue(initial);
|
|
sel.addEventListener('change', function(){
|
|
var v = sel.value || 'system';
|
|
localStorage.setItem(KEY, v);
|
|
apply(v);
|
|
});
|
|
}
|
|
if (resetBtn){
|
|
resetBtn.addEventListener('click', function(){
|
|
try{ localStorage.removeItem(KEY); }catch(_){ }
|
|
var v = SERVER_DEFAULT || 'system';
|
|
apply(v);
|
|
if (sel) sel.value = normalizeUiValue(v);
|
|
});
|
|
}
|
|
// React to system changes when set to system
|
|
if (window.matchMedia){
|
|
var mq = window.matchMedia('(prefers-color-scheme: dark)');
|
|
mq.addEventListener && mq.addEventListener('change', function(){
|
|
var cur = localStorage.getItem(KEY) || (SERVER_DEFAULT || 'system');
|
|
if (cur === 'system') apply('system');
|
|
});
|
|
}
|
|
}catch(_){ }
|
|
})();
|
|
</script>
|
|
{% endif %}
|
|
{% if not enable_themes %}
|
|
<script>
|
|
(function(){
|
|
try{
|
|
// Apply THEME env even when the selector is disabled. Resolve 'system' to OS preference.
|
|
var root = document.documentElement;
|
|
var SERVER_DEFAULT = '{{ default_theme }}';
|
|
function resolveSystem(){
|
|
var prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
return prefersDark ? 'dark' : 'light-blend';
|
|
}
|
|
var v = (SERVER_DEFAULT || 'system').toLowerCase();
|
|
if (v === 'system') v = resolveSystem();
|
|
if (v === 'light') v = 'light-blend';
|
|
root.setAttribute('data-theme', v);
|
|
// Track OS changes when using system
|
|
if ((SERVER_DEFAULT||'system').toLowerCase() === 'system' && window.matchMedia){
|
|
var mq = window.matchMedia('(prefers-color-scheme: dark)');
|
|
mq.addEventListener && mq.addEventListener('change', function(){ root.setAttribute('data-theme', resolveSystem()); });
|
|
}
|
|
}catch(_){ }
|
|
})();
|
|
</script>
|
|
{% endif %}
|
|
{% if enable_pwa %}
|
|
<script>
|
|
(function(){
|
|
try{
|
|
if ('serviceWorker' in navigator){
|
|
var ver = '{{ catalog_hash|default("dev") }}';
|
|
var url = '/static/sw.js?v=' + encodeURIComponent(ver);
|
|
navigator.serviceWorker.register(url).then(function(reg){
|
|
window.__pwaStatus = { registered: true, scope: reg.scope, version: ver };
|
|
// Listen for updates (new worker installing)
|
|
if(reg.waiting){ reg.waiting.postMessage({ type: 'SKIP_WAITING' }); }
|
|
reg.addEventListener('updatefound', function(){
|
|
try {
|
|
var nw = reg.installing; if(!nw) return;
|
|
nw.addEventListener('statechange', function(){
|
|
if(nw.state === 'installed' && navigator.serviceWorker.controller){
|
|
// New version available; reload silently for freshness
|
|
try { sessionStorage.setItem('mtg:swUpdated','1'); }catch(_){ }
|
|
window.location.reload();
|
|
}
|
|
});
|
|
}catch(_){ }
|
|
});
|
|
}).catch(function(){ window.__pwaStatus = { registered: false }; });
|
|
}
|
|
}catch(_){ }
|
|
})();
|
|
</script>
|
|
{% endif %}
|
|
<script>
|
|
// Show pending toast after full page reloads when actions replace the whole document
|
|
(function(){
|
|
try{
|
|
var raw = sessionStorage.getItem('mtg:toastAfterReload');
|
|
if (raw){
|
|
sessionStorage.removeItem('mtg:toastAfterReload');
|
|
var data = JSON.parse(raw);
|
|
if (data && data.msg){ window.toast && window.toast(data.msg, data.type||''); }
|
|
}
|
|
}catch(_){ }
|
|
})();
|
|
</script>
|
|
<script>
|
|
<!-- Hover card panel system moved to TypeScript: code/web/static/ts/cardHover.ts -->
|
|
</script>
|
|
</body>
|
|
</html>
|