mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-16 07:30:13 +01:00
392 lines
18 KiB
HTML
392 lines
18 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 id="banner-status" class="banner-status" role="status" aria-live="polite"></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 moved to app.ts (initSetupStatusPoller)
|
|
|
|
// 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>
|
|
<!-- Active nav highlighter moved to app.ts (initActiveNavHighlighter) -->
|
|
<script src="/static/js/components.js?v=20251028-1"></script>
|
|
<script src="/static/js/app.js?v=20251029-2"></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>
|
|
// Theme selector logic moved to app.ts (initThemeSelector)
|
|
// Call it with server-injected values on DOMContentLoaded
|
|
document.addEventListener('DOMContentLoaded', function(){
|
|
if (window.__initThemeSelector) {
|
|
window.__initThemeSelector({{ enable_themes|lower }}, '{{ default_theme }}');
|
|
}
|
|
});
|
|
</script>
|
|
{% endif %}
|
|
{% if not enable_themes %}
|
|
<script>
|
|
// Theme env-only logic moved to app.ts (initThemeEnvOnly)
|
|
// Call it with server-injected values on DOMContentLoaded
|
|
document.addEventListener('DOMContentLoaded', function(){
|
|
if (window.__initThemeEnvOnly) {
|
|
window.__initThemeEnvOnly({{ enable_themes|lower }}, '{{ default_theme }}');
|
|
}
|
|
});
|
|
</script>
|
|
{% endif %}
|
|
{% if enable_pwa %}
|
|
<script>
|
|
// Service worker registration moved to app.ts (initServiceWorker)
|
|
// Call it with server-injected values on DOMContentLoaded
|
|
document.addEventListener('DOMContentLoaded', function(){
|
|
if (window.__initServiceWorker) {
|
|
window.__initServiceWorker({{ enable_pwa|lower }}, '{{ catalog_hash|default("dev") }}');
|
|
}
|
|
});
|
|
</script>
|
|
{% endif %}
|
|
<!-- Toast after reload, setup poller, nav highlighter moved to app.ts -->
|
|
<!-- Hover card panel system moved to cardHover.ts -->
|
|
</body>
|
|
</html>
|