mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-17 16:10:12 +01:00
Web + backend: propagate tag_mode (AND/OR) end-to-end; AND-mode overlap prioritization for creatures and theme spells; headless configs support tag_mode; add Scryfall attribution footer and configs UI indicators; minor polish. (#and-overlap-pass)
This commit is contained in:
parent
0f73a85a4e
commit
fd7fc01071
15 changed files with 1339 additions and 75 deletions
|
|
@ -5,7 +5,7 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>MTG Deckbuilder</title>
|
||||
<script src="https://unpkg.com/htmx.org@1.9.12" onerror="var s=document.createElement('script');s.src='/static/vendor/htmx-1.9.12.min.js';document.head.appendChild(s);"></script>
|
||||
<link rel="stylesheet" href="/static/styles.css" />
|
||||
<link rel="stylesheet" href="/static/styles.css?v=20250826-1" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="top-banner">
|
||||
|
|
@ -38,6 +38,11 @@
|
|||
{% 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>
|
||||
<style>
|
||||
.card-hover { position: fixed; pointer-events: none; z-index: 9999; display: none; }
|
||||
.card-hover-inner { display:flex; gap:12px; align-items:flex-start; }
|
||||
|
|
@ -45,6 +50,8 @@
|
|||
.card-meta { background: #0f1115; color: #e5e7eb; border: 1px solid var(--border); border-radius: 8px; padding: .5rem .6rem; max-width: 280px; font-size: 12px; line-height: 1.35; box-shadow: 0 6px 18px rgba(0,0,0,.35); }
|
||||
.card-meta .label { color:#94a3b8; text-transform: uppercase; font-size: 10px; letter-spacing: .04em; display:block; margin-bottom:.15rem; }
|
||||
.card-meta .line + .line { margin-top:.35rem; }
|
||||
.site-footer { margin: 12px 16px 0; padding: 8px 12px; border-top: 1px solid var(--border); color: #94a3b8; font-size: 12px; text-align: center; }
|
||||
.site-footer a { color: #cbd5e1; text-decoration: underline; }
|
||||
</style>
|
||||
<script>
|
||||
(function(){
|
||||
|
|
|
|||
|
|
@ -1,28 +1,44 @@
|
|||
<section>
|
||||
<h3>Step 1: Choose a Commander</h3>
|
||||
|
||||
<form id="cmdr-search-form" hx-post="/build/step1" hx-target="#wizard" hx-swap="innerHTML">
|
||||
<label>Search by name</label>
|
||||
<input id="cmdr-search" type="text" name="query" value="{{ query or '' }}" autocomplete="off" />
|
||||
<form id="cmdr-search-form" hx-post="/build/step1" hx-target="#wizard" hx-swap="innerHTML" aria-label="Commander search form" role="search">
|
||||
<label for="cmdr-search">Search by name</label>
|
||||
<span class="input-wrap">
|
||||
<input id="cmdr-search" type="text" name="query" value="{{ query or '' }}" autocomplete="off" aria-describedby="cmdr-help" aria-controls="candidate-grid" placeholder="Type a commander name…" />
|
||||
<button id="cmdr-clear" type="button" class="clear-btn" title="Clear search" aria-label="Clear search" hidden>×</button>
|
||||
</span>
|
||||
<input id="active-name" type="hidden" name="active" value="{{ active or '' }}" />
|
||||
<button type="submit">Search</button>
|
||||
<label style="margin-left:.5rem; font-weight:normal;">
|
||||
<input type="checkbox" name="auto" value="1" {% if auto %}checked{% endif %} /> Auto-select top match (very confident)
|
||||
</label>
|
||||
<span id="search-spinner" class="spinner" aria-hidden="true" hidden style="display:none;"></span>
|
||||
</form>
|
||||
<div class="muted" style="margin:.35rem 0 .5rem 0; font-size:.9rem;">
|
||||
Tip: Press Enter to select the highlighted result, or use Up/Down to navigate. If your query is a full first word (e.g., "vivi"), exact first-word matches are prioritized.
|
||||
<div id="cmdr-help" class="muted" style="margin:.35rem 0 .5rem 0; font-size:.9rem;">
|
||||
Tip: Press Enter to select the highlighted result, or use arrow keys to navigate. If your query is a full first word (e.g., "vivi"), exact first-word matches are prioritized.
|
||||
</div>
|
||||
<div id="selection-live" class="sr-only" aria-live="polite" role="status"></div>
|
||||
<div id="results-live" class="sr-only" aria-live="polite" role="status"></div>
|
||||
<div id="kbd-hint" class="hint" hidden>
|
||||
<span class="hint-text">Use
|
||||
<span class="keys"><kbd>↑</kbd><kbd>↓</kbd></span> to navigate, <kbd>Enter</kbd> to select
|
||||
</span>
|
||||
<button type="button" class="hint-close" title="Dismiss keyboard hint" aria-label="Dismiss">×</button>
|
||||
</div>
|
||||
|
||||
{% if candidates %}
|
||||
<h4>Top matches</h4>
|
||||
<div class="candidate-grid" id="candidate-grid">
|
||||
<h4 style="display:flex; align-items:center; gap:.5rem;">
|
||||
Top matches
|
||||
<small class="muted" aria-live="polite">{% if count is defined %}{{ count }} result{% if count != 1 %}s{% endif %}{% else %}{{ (candidates|length) if candidates else 0 }} results{% endif %}</small>
|
||||
</h4>
|
||||
<div class="candidate-grid" id="candidate-grid" role="list">
|
||||
{% for name, score, colors in candidates %}
|
||||
<div class="candidate-tile" data-card-name="{{ name }}">
|
||||
<div class="candidate-tile{% if active and active == name %} active{% endif %}" data-card-name="{{ name }}" role="listitem" aria-selected="{% if active and active == name %}true{% else %}false{% endif %}">
|
||||
<form hx-post="/build/step1/confirm" hx-target="#wizard" hx-swap="innerHTML">
|
||||
<input type="hidden" name="name" value="{{ name }}" />
|
||||
<button class="img-btn" type="submit" title="Select {{ name }} (score {{ score }})">
|
||||
<img src="https://api.scryfall.com/cards/named?fuzzy={{ name|urlencode }}&format=image&version=normal"
|
||||
alt="{{ name }}" />
|
||||
alt="{{ name }}" loading="lazy" decoding="async" />
|
||||
</button>
|
||||
</form>
|
||||
<div class="meta">
|
||||
|
|
@ -47,6 +63,12 @@
|
|||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if (query is defined and query and (not candidates or (candidates|length == 0))) and not inspect %}
|
||||
<div id="candidate-grid" class="muted" style="margin-top:.5rem;" aria-live="polite">
|
||||
No results for “{{ query }}”. Try a shorter name or a different spelling.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if inspect and inspect.ok %}
|
||||
<div class="two-col two-col-left-rail">
|
||||
<aside class="card-preview card-sm" data-card-name="{{ selected }}">
|
||||
|
|
@ -94,7 +116,58 @@
|
|||
var input = document.getElementById('cmdr-search');
|
||||
var form = document.getElementById('cmdr-search-form');
|
||||
var grid = document.getElementById('candidate-grid');
|
||||
var spinner = document.getElementById('search-spinner');
|
||||
var activeField = document.getElementById('active-name');
|
||||
var selLive = document.getElementById('selection-live');
|
||||
var resultsLive = document.getElementById('results-live');
|
||||
var hint = document.getElementById('kbd-hint');
|
||||
var defaultPlaceholder = (input && input.placeholder) ? input.placeholder : 'Type a commander name…';
|
||||
var clearBtn = document.getElementById('cmdr-clear');
|
||||
var initialDescribedBy = (input && input.getAttribute('aria-describedby')) || '';
|
||||
// Persist auto-select preference
|
||||
try {
|
||||
var autoCb = document.querySelector('input[name="auto"][type="checkbox"]');
|
||||
if (autoCb) {
|
||||
var saved = localStorage.getItem('step1-auto');
|
||||
if (saved === '1' || saved === '0') autoCb.checked = (saved === '1');
|
||||
autoCb.addEventListener('change', function(){ localStorage.setItem('step1-auto', autoCb.checked ? '1' : '0'); });
|
||||
}
|
||||
} catch(_){ }
|
||||
if (!input || !form) return;
|
||||
// Show keyboard hint only when candidates exist and user hasn't dismissed it
|
||||
function showHintIfNeeded() {
|
||||
try {
|
||||
if (!hint) return;
|
||||
var dismissed = localStorage.getItem('step1-hint-dismissed') === '1';
|
||||
var hasTiles = !!(document.getElementById('candidate-grid') && document.getElementById('candidate-grid').querySelector('.candidate-tile'));
|
||||
var shouldShow = !(dismissed || !hasTiles);
|
||||
hint.hidden = !shouldShow;
|
||||
// Link hint to input a11y description only when visible
|
||||
if (input) {
|
||||
var base = initialDescribedBy.trim();
|
||||
var parts = base ? base.split(/\s+/) : [];
|
||||
var idx = parts.indexOf('kbd-hint');
|
||||
if (shouldShow) {
|
||||
if (idx === -1) parts.push('kbd-hint');
|
||||
} else {
|
||||
if (idx !== -1) parts.splice(idx, 1);
|
||||
}
|
||||
if (parts.length) input.setAttribute('aria-describedby', parts.join(' '));
|
||||
else input.removeAttribute('aria-describedby');
|
||||
}
|
||||
} catch(_) { /* noop */ }
|
||||
}
|
||||
showHintIfNeeded();
|
||||
// Close button for hint
|
||||
try {
|
||||
var closeBtn = hint ? hint.querySelector('.hint-close') : null;
|
||||
if (closeBtn) {
|
||||
closeBtn.addEventListener('click', function(){
|
||||
try { localStorage.setItem('step1-hint-dismissed', '1'); } catch(_){}
|
||||
if (hint) hint.hidden = true;
|
||||
});
|
||||
}
|
||||
} catch(_){ }
|
||||
// Debounce live search
|
||||
var t = null;
|
||||
function submit(){
|
||||
|
|
@ -106,15 +179,48 @@
|
|||
input.addEventListener('input', function(){
|
||||
if (t) clearTimeout(t);
|
||||
t = setTimeout(submit, 250);
|
||||
try { if (clearBtn) clearBtn.hidden = !(input && input.value && input.value.length); } catch(_){ }
|
||||
});
|
||||
// Initialize clear visibility
|
||||
try { if (clearBtn) clearBtn.hidden = !(input && input.value && input.value.length); } catch(_){ }
|
||||
if (clearBtn) clearBtn.addEventListener('click', function(){
|
||||
if (!input) return;
|
||||
input.value = '';
|
||||
try { clearBtn.hidden = true; } catch(_){ }
|
||||
if (t) clearTimeout(t);
|
||||
t = setTimeout(submit, 0);
|
||||
try { input.focus(); } catch(_){}
|
||||
});
|
||||
// Focus the search box on load if nothing else is focused
|
||||
try {
|
||||
var ae = document.activeElement;
|
||||
if (input && (!ae || ae === document.body)) { input.focus(); input.select && input.select(); }
|
||||
} catch(_){}
|
||||
// Quick focus: press "/" to focus the search input (unless already typing)
|
||||
document.addEventListener('keydown', function(e){
|
||||
if (e.key !== '/') return;
|
||||
var tag = (e.target && e.target.tagName) ? e.target.tagName.toLowerCase() : '';
|
||||
var isEditable = (tag === 'input' || tag === 'textarea' || tag === 'select' || (e.target && e.target.isContentEditable));
|
||||
if (isEditable) return;
|
||||
if (e.ctrlKey || e.altKey || e.metaKey) return;
|
||||
e.preventDefault();
|
||||
if (input) { input.focus(); try { input.select(); } catch(_){} }
|
||||
});
|
||||
// Keyboard navigation: up/down to move selection, Enter to choose/inspect
|
||||
document.addEventListener('keydown', function(e){
|
||||
// Dismiss hint on first keyboard navigation
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'ArrowLeft' || e.key === 'ArrowRight' || e.key === 'Enter') {
|
||||
try { localStorage.setItem('step1-hint-dismissed', '1'); } catch(_){}
|
||||
if (hint) hint.hidden = true;
|
||||
}
|
||||
if (!grid || !grid.children || grid.children.length === 0) return;
|
||||
var tiles = Array.prototype.slice.call(grid.querySelectorAll('.candidate-tile'));
|
||||
// Ensure something is selected by default
|
||||
var idx = tiles.findIndex(function(el){ return el.classList.contains('active'); });
|
||||
if (idx < 0 && tiles.length > 0) {
|
||||
tiles[0].classList.add('active');
|
||||
try { if (activeField) activeField.value = tiles[0].dataset.cardName || ''; } catch(_){}
|
||||
try { if (selLive) selLive.textContent = 'Selected ' + (tiles[0].dataset.cardName || ''); } catch(_){}
|
||||
idx = 0;
|
||||
}
|
||||
|
||||
|
|
@ -129,8 +235,11 @@
|
|||
function setActive(newIdx) {
|
||||
// Clamp to bounds; wrapping handled by callers
|
||||
newIdx = Math.max(0, Math.min(tiles.length - 1, newIdx));
|
||||
tiles.forEach(function(el){ el.classList.remove('active'); });
|
||||
tiles[newIdx].classList.add('active');
|
||||
tiles.forEach(function(el){ el.classList.remove('active'); el.setAttribute('aria-selected', 'false'); });
|
||||
tiles[newIdx].classList.add('active');
|
||||
tiles[newIdx].setAttribute('aria-selected', 'true');
|
||||
try { if (activeField) activeField.value = tiles[newIdx].dataset.cardName || ''; } catch(_){}
|
||||
try { if (selLive) selLive.textContent = 'Selected ' + (tiles[newIdx].dataset.cardName || ''); } catch(_){}
|
||||
tiles[newIdx].scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
||||
return newIdx;
|
||||
}
|
||||
|
|
@ -178,8 +287,30 @@
|
|||
if (btn) btn.click();
|
||||
}
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
// ESC clears the search field and triggers a refresh
|
||||
if (input && input.value) {
|
||||
input.value = '';
|
||||
if (t) clearTimeout(t);
|
||||
t = setTimeout(submit, 0);
|
||||
}
|
||||
}
|
||||
});
|
||||
// Persist current active on click selection movement too
|
||||
if (grid) {
|
||||
grid.addEventListener('click', function(e){
|
||||
// Dismiss hint on interaction
|
||||
try { localStorage.setItem('step1-hint-dismissed', '1'); } catch(_){}
|
||||
if (hint) hint.hidden = true;
|
||||
var tile = e.target.closest('.candidate-tile');
|
||||
if (!tile) return;
|
||||
grid.querySelectorAll('.candidate-tile').forEach(function(el){ el.classList.remove('active'); el.setAttribute('aria-selected', 'false'); });
|
||||
tile.classList.add('active');
|
||||
tile.setAttribute('aria-selected', 'true');
|
||||
try { if (activeField) activeField.value = tile.dataset.cardName || ''; } catch(_){}
|
||||
try { if (selLive) selLive.textContent = 'Selected ' + (tile.dataset.cardName || ''); } catch(_){}
|
||||
});
|
||||
}
|
||||
// Highlight matched text
|
||||
try {
|
||||
var q = (input.value || '').trim().toLowerCase();
|
||||
|
|
@ -194,6 +325,44 @@
|
|||
});
|
||||
}
|
||||
} catch(_){}
|
||||
// HTMX spinner binding for this form — only show if no results are currently displayed
|
||||
if (window.htmx && form) {
|
||||
form.addEventListener('htmx:beforeRequest', function(){
|
||||
var hasTiles = false;
|
||||
try { hasTiles = !!(grid && grid.querySelector('.candidate-tile')); } catch(_){}
|
||||
if (spinner) spinner.hidden = hasTiles ? true : false;
|
||||
if (!hasTiles && input) input.placeholder = 'Searching…';
|
||||
try { form.setAttribute('aria-busy', 'true'); } catch(_){ }
|
||||
if (resultsLive) resultsLive.textContent = 'Searching…';
|
||||
});
|
||||
form.addEventListener('htmx:afterSwap', function(){
|
||||
if (spinner) spinner.hidden = true; if (input) input.placeholder = defaultPlaceholder;
|
||||
// After swap, if there are no candidate tiles, clear active selection and live text
|
||||
try {
|
||||
var grid2 = document.getElementById('candidate-grid');
|
||||
var hasAny = !!(grid2 && grid2.querySelector('.candidate-tile'));
|
||||
if (!hasAny) {
|
||||
if (activeField) activeField.value = '';
|
||||
if (selLive) selLive.textContent = '';
|
||||
}
|
||||
// Re-evaluate hint visibility post-swap
|
||||
showHintIfNeeded();
|
||||
// Announce results count
|
||||
try {
|
||||
var qNow = (input && input.value) ? input.value.trim() : '';
|
||||
var cnt = 0;
|
||||
if (grid2) cnt = grid2.querySelectorAll('.candidate-tile').length;
|
||||
if (resultsLive) {
|
||||
if (cnt > 0) resultsLive.textContent = cnt + (cnt === 1 ? ' result' : ' results');
|
||||
else if (qNow) resultsLive.textContent = 'No results for "' + qNow + '"';
|
||||
else resultsLive.textContent = '';
|
||||
}
|
||||
} catch(_){ }
|
||||
try { form.removeAttribute('aria-busy'); } catch(_){ }
|
||||
} catch(_){ }
|
||||
});
|
||||
form.addEventListener('htmx:responseError', function(){ if (spinner) spinner.hidden = true; if (input) input.placeholder = defaultPlaceholder; });
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<style>
|
||||
|
|
@ -211,4 +380,15 @@
|
|||
.chip-c { background:#f3f4f6; color:#111827; border-color:#e5e7eb; }
|
||||
mark { background: rgba(251, 191, 36, .35); color: inherit; padding:0 .1rem; border-radius:2px; }
|
||||
.candidate-tile { cursor: pointer; }
|
||||
.sr-only{ position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; }
|
||||
.spinner { display:inline-block; width:16px; height:16px; border:2px solid #93c5fd; border-top-color: transparent; border-radius:50%; animation: spin 0.8s linear infinite; vertical-align:middle; margin-left:.4rem; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
/* Ensure hidden attribute always hides spinner within this fragment */
|
||||
.spinner[hidden] { display: none !important; }
|
||||
.hint { display:flex; align-items:center; gap:.5rem; background:#0b1220; border:1px solid var(--border); color:#cbd5e1; padding:.4rem .6rem; border-radius:8px; margin:.4rem 0 .6rem; }
|
||||
.hint .hint-close { background:transparent; border:0; color:#9aa4b2; font-size:1rem; line-height:1; cursor:pointer; }
|
||||
.hint .keys kbd { background:#1f2937; color:#e5e7eb; padding:.1rem .3rem; border-radius:4px; margin:0 .1rem; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; font-size:.85em; }
|
||||
.input-wrap { position: relative; display:inline-flex; align-items:center; }
|
||||
.clear-btn { position:absolute; right:.35rem; background:transparent; color:#9aa4b2; border:0; cursor:pointer; font-size:1.1rem; line-height:1; padding:.1rem .2rem; }
|
||||
.clear-btn:hover { color:#cbd5e1; }
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -18,30 +18,55 @@
|
|||
<fieldset>
|
||||
<legend>Theme Tags</legend>
|
||||
{% if tags %}
|
||||
<label>Primary
|
||||
<select name="primary_tag">
|
||||
<option value="">-- none --</option>
|
||||
{% for t in tags %}
|
||||
<option value="{{ t }}" {% if t == primary_tag %}selected{% endif %}>{{ t }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>Secondary
|
||||
<select name="secondary_tag">
|
||||
<option value="">-- none --</option>
|
||||
{% for t in tags %}
|
||||
<option value="{{ t }}" {% if t == secondary_tag %}selected{% endif %}>{{ t }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>Tertiary
|
||||
<select name="tertiary_tag">
|
||||
<option value="">-- none --</option>
|
||||
{% for t in tags %}
|
||||
<option value="{{ t }}" {% if t == tertiary_tag %}selected{% endif %}>{{ t }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<input type="hidden" name="primary_tag" id="primary_tag" value="{{ primary_tag or '' }}" />
|
||||
<input type="hidden" name="secondary_tag" id="secondary_tag" value="{{ secondary_tag or '' }}" />
|
||||
<input type="hidden" name="tertiary_tag" id="tertiary_tag" value="{{ tertiary_tag or '' }}" />
|
||||
<input type="hidden" name="tag_mode" id="tag_mode" value="{{ tag_mode or 'AND' }}" />
|
||||
<div class="muted" style="font-size:12px; margin-bottom:.35rem;">Pick up to three themes. Toggle AND/OR to control how themes combine.</div>
|
||||
<div style="display:flex; align-items:center; gap:.5rem; flex-wrap:wrap; margin-bottom:.35rem;">
|
||||
<span class="muted" style="font-size:12px;">Combine</span>
|
||||
<div role="group" aria-label="Combine mode" aria-describedby="combine-help-tip">
|
||||
<label style="margin-right:.35rem;" title="AND prioritizes cards that match multiple of your themes (tighter synergy, smaller pool).">
|
||||
<input type="radio" name="combine_mode_radio" value="AND" {% if (tag_mode or 'AND') == 'AND' %}checked{% endif %} /> AND
|
||||
</label>
|
||||
<label title="OR treats your themes as a union (broader pool, fills easier).">
|
||||
<input type="radio" name="combine_mode_radio" value="OR" {% if tag_mode == 'OR' %}checked{% endif %} /> OR
|
||||
</label>
|
||||
</div>
|
||||
<button type="button" id="reset-tags" class="chip" style="margin-left:.35rem;">Reset themes</button>
|
||||
<span id="tag-count" class="muted" style="font-size:12px;"></span>
|
||||
</div>
|
||||
<div id="combine-help-tip" class="muted" style="font-size:12px; margin:-.15rem 0 .5rem 0;">Tip: Choose OR for a stronger initial theme pool; switch to AND to tighten synergy.</div>
|
||||
<div id="tag-order" class="muted" style="font-size:12px; margin-bottom:.4rem;"></div>
|
||||
{% if recommended and recommended|length %}
|
||||
<div style="display:flex; align-items:center; gap:.5rem; margin:.25rem 0 .35rem 0;">
|
||||
<div class="muted" style="font-size:12px;">Recommended</div>
|
||||
<button type="button" id="reco-why" class="chip" aria-expanded="false" aria-controls="reco-why-panel" title="Why these are recommended?">Why?</button>
|
||||
</div>
|
||||
<div id="reco-why-panel" role="group" aria-label="Why Recommended" aria-hidden="true" style="display:none; border:1px solid #e2e2e2; border-radius:8px; padding:.75rem; margin:-.15rem 0 .5rem 0; background:#f7f7f7; box-shadow:0 2px 8px rgba(0,0,0,.06);">
|
||||
<div style="font-size:12px; color:#222; margin-bottom:.5rem;">Why these themes? <span class="muted" style="color:#555;">Signals from oracle text, color identity, and your local build history.</span></div>
|
||||
<ul style="margin:.25rem 0; padding-left:1.1rem;">
|
||||
{% for r in recommended %}
|
||||
{% set tip = (recommended_reasons[r] if (recommended_reasons is defined and recommended_reasons and recommended_reasons.get(r)) else 'From this commander\'s theme list') %}
|
||||
<li style="font-size:12px; color:#222; line-height:1.35;"><strong>{{ r }}</strong>: <span style="color:#333;">{{ tip }}</span></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
<div id="tag-reco-list" aria-label="Recommended themes" style="display:flex; gap:.35rem; flex-wrap:wrap; margin-bottom:.5rem;">
|
||||
{% for r in recommended %}
|
||||
{% set is_sel_r = (r == (primary_tag or '')) or (r == (secondary_tag or '')) or (r == (tertiary_tag or '')) %}
|
||||
{% set tip = (recommended_reasons[r] if (recommended_reasons is defined and recommended_reasons and recommended_reasons.get(r)) else 'Recommended for this commander') %}
|
||||
<button type="button" class="chip chip-reco{% if is_sel_r %} active{% endif %}" data-tag="{{ r }}" aria-pressed="{% if is_sel_r %}true{% else %}false{% endif %}" title="{{ tip }}">★ {{ r }}</button>
|
||||
{% endfor %}
|
||||
<button type="button" id="reco-select-all" class="chip" title="Add recommended up to 3">Select all</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div id="tag-chip-list" aria-label="Available themes" style="display:flex; gap:.35rem; flex-wrap:wrap;">
|
||||
{% for t in tags %}
|
||||
{% set is_sel = (t == (primary_tag or '')) or (t == (secondary_tag or '')) or (t == (tertiary_tag or '')) %}
|
||||
<button type="button" class="chip{% if is_sel %} active{% endif %}" data-tag="{{ t }}" aria-pressed="{% if is_sel %}true{% else %}false{% endif %}">{{ t }}</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p>No theme tags available for this commander.</p>
|
||||
{% endif %}
|
||||
|
|
@ -75,3 +100,213 @@
|
|||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
var chipHost = document.getElementById('tag-chip-list');
|
||||
var recoHost = document.getElementById('tag-reco-list');
|
||||
var selAll = document.getElementById('reco-select-all');
|
||||
var resetBtn = document.getElementById('reset-tags');
|
||||
var primary = document.getElementById('primary_tag');
|
||||
var secondary = document.getElementById('secondary_tag');
|
||||
var tertiary = document.getElementById('tertiary_tag');
|
||||
var tagMode = document.getElementById('tag_mode');
|
||||
var countEl = document.getElementById('tag-count');
|
||||
var orderEl = document.getElementById('tag-order');
|
||||
var commander = '{{ commander.name|e }}';
|
||||
if (!chipHost) return;
|
||||
|
||||
function storageKey(suffix){ return 'step2-' + (commander || 'unknown') + '-' + suffix; }
|
||||
|
||||
function getSelected(){
|
||||
var arr = [];
|
||||
if (primary && primary.value) arr.push(primary.value);
|
||||
if (secondary && secondary.value) arr.push(secondary.value);
|
||||
if (tertiary && tertiary.value) arr.push(tertiary.value);
|
||||
return arr;
|
||||
}
|
||||
function setSelected(arr){
|
||||
arr = Array.from(new Set(arr || [])).filter(Boolean).slice(0,3);
|
||||
if (primary) primary.value = arr[0] || '';
|
||||
if (secondary) secondary.value = arr[1] || '';
|
||||
if (tertiary) tertiary.value = arr[2] || '';
|
||||
updateCount();
|
||||
persist();
|
||||
updateOrderUI();
|
||||
}
|
||||
function toggleTag(t){
|
||||
var cur = getSelected();
|
||||
var idx = cur.indexOf(t);
|
||||
if (idx >= 0) { cur.splice(idx, 1); }
|
||||
else {
|
||||
if (cur.length >= 3) { cur = cur.slice(1); }
|
||||
cur.push(t);
|
||||
}
|
||||
setSelected(cur);
|
||||
updateChipsState();
|
||||
}
|
||||
function updateCount(){
|
||||
try { if (countEl) countEl.textContent = getSelected().length + ' / 3 selected'; } catch(_){}
|
||||
}
|
||||
function persist(){
|
||||
try {
|
||||
localStorage.setItem(storageKey('tags'), JSON.stringify(getSelected()));
|
||||
if (tagMode) localStorage.setItem(storageKey('mode'), tagMode.value || 'AND');
|
||||
} catch(_){}
|
||||
}
|
||||
function loadPersisted(){
|
||||
try {
|
||||
var savedTags = JSON.parse(localStorage.getItem(storageKey('tags')) || '[]');
|
||||
var savedMode = localStorage.getItem(storageKey('mode')) || (tagMode && tagMode.value) || 'AND';
|
||||
if ((!primary.value && !secondary.value && !tertiary.value) && Array.isArray(savedTags) && savedTags.length){ setSelected(savedTags); }
|
||||
if (tagMode) { tagMode.value = (savedMode === 'OR' ? 'OR' : 'AND'); }
|
||||
// sync radios
|
||||
syncModeRadios();
|
||||
} catch(_){}
|
||||
}
|
||||
function syncModeRadios(){
|
||||
try {
|
||||
var radios = document.querySelectorAll('input[name="combine_mode_radio"]');
|
||||
Array.prototype.forEach.call(radios, function(r){ r.checked = (r.value === (tagMode && tagMode.value || 'AND')); });
|
||||
} catch(_){}
|
||||
}
|
||||
function updateChipsState(){
|
||||
var sel = getSelected();
|
||||
function applyToContainer(container){
|
||||
if (!container) return;
|
||||
var chips = Array.prototype.slice.call(container.querySelectorAll('button.chip'));
|
||||
chips.forEach(function(btn){
|
||||
var t = btn.dataset.tag || '';
|
||||
var active = sel.indexOf(t) >= 0;
|
||||
btn.classList.toggle('active', active);
|
||||
btn.setAttribute('aria-pressed', active ? 'true' : 'false');
|
||||
// update numeric badge for order
|
||||
var old = btn.querySelector('sup.tag-order');
|
||||
if (old) { try { old.remove(); } catch(_){} }
|
||||
if (active){
|
||||
var idx = sel.indexOf(t);
|
||||
if (idx >= 0){
|
||||
var sup = document.createElement('sup');
|
||||
sup.className = 'tag-order';
|
||||
sup.style.marginLeft = '.25rem';
|
||||
sup.style.opacity = '.75';
|
||||
sup.textContent = String(idx + 1);
|
||||
btn.appendChild(sup);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
applyToContainer(chipHost);
|
||||
applyToContainer(recoHost);
|
||||
updateCount();
|
||||
updateOrderUI();
|
||||
updateSelectAllState();
|
||||
}
|
||||
|
||||
function updateOrderUI(){
|
||||
if (!orderEl) return;
|
||||
var sel = getSelected();
|
||||
if (!sel.length){ orderEl.textContent = ''; return; }
|
||||
try {
|
||||
var parts = sel.map(function(t, i){ return (i+1) + '. ' + t; });
|
||||
orderEl.textContent = 'Selected order: ' + parts.join(' • ');
|
||||
} catch(_){ orderEl.textContent = ''; }
|
||||
}
|
||||
|
||||
// bind mode radios
|
||||
Array.prototype.forEach.call(document.querySelectorAll('input[name="combine_mode_radio"]'), function(r){
|
||||
r.addEventListener('change', function(){ if (tagMode) { tagMode.value = r.value; persist(); } });
|
||||
});
|
||||
if (resetBtn) resetBtn.addEventListener('click', function(){ setSelected([]); updateChipsState(); });
|
||||
|
||||
// attach handlers to existing chips
|
||||
Array.prototype.forEach.call(chipHost.querySelectorAll('button.chip'), function(btn){
|
||||
var t = btn.dataset.tag || '';
|
||||
btn.addEventListener('click', function(){ toggleTag(t); });
|
||||
btn.addEventListener('keydown', function(e){
|
||||
if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); toggleTag(t); }
|
||||
else if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
var chips = Array.prototype.slice.call(chipHost.querySelectorAll('button.chip'));
|
||||
var ix = chips.indexOf(e.currentTarget);
|
||||
var next = (e.key === 'ArrowRight') ? chips[Math.min(ix+1, chips.length-1)] : chips[Math.max(ix-1, 0)];
|
||||
if (next) { try { next.focus(); } catch(_){ } }
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// attach handlers to recommended chips and select-all
|
||||
if (recoHost){
|
||||
Array.prototype.forEach.call(recoHost.querySelectorAll('button.chip-reco'), function(btn){
|
||||
var t = btn.dataset.tag || '';
|
||||
btn.addEventListener('click', function(){ toggleTag(t); });
|
||||
});
|
||||
if (selAll){
|
||||
selAll.addEventListener('click', function(){
|
||||
try {
|
||||
var sel = getSelected();
|
||||
var recs = Array.prototype.slice.call(recoHost.querySelectorAll('button.chip-reco')).map(function(b){ return b.dataset.tag || ''; }).filter(Boolean);
|
||||
var combined = sel.slice();
|
||||
recs.forEach(function(t){ if (combined.indexOf(t) === -1) combined.push(t); });
|
||||
combined = combined.slice(-3); // keep last 3
|
||||
setSelected(combined);
|
||||
updateChipsState();
|
||||
updateSelectAllState();
|
||||
} catch(_){ }
|
||||
});
|
||||
}
|
||||
// Why recommended panel toggle
|
||||
var whyBtn = document.getElementById('reco-why');
|
||||
var whyPanel = document.getElementById('reco-why-panel');
|
||||
function setWhy(open){
|
||||
if (!whyBtn || !whyPanel) return;
|
||||
whyBtn.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||
whyPanel.style.display = open ? 'block' : 'none';
|
||||
whyPanel.setAttribute('aria-hidden', open ? 'false' : 'true');
|
||||
}
|
||||
if (whyBtn && whyPanel){
|
||||
whyBtn.addEventListener('click', function(e){
|
||||
e.stopPropagation();
|
||||
var isOpen = whyBtn.getAttribute('aria-expanded') === 'true';
|
||||
setWhy(!isOpen);
|
||||
if (!isOpen){ try { whyPanel.focus && whyPanel.focus(); } catch(_){} }
|
||||
});
|
||||
document.addEventListener('click', function(e){
|
||||
try {
|
||||
var isOpen = whyBtn.getAttribute('aria-expanded') === 'true';
|
||||
if (!isOpen) return;
|
||||
if (whyPanel.contains(e.target) || whyBtn.contains(e.target)) return;
|
||||
setWhy(false);
|
||||
} catch(_){}
|
||||
});
|
||||
document.addEventListener('keydown', function(e){
|
||||
if (e.key === 'Escape'){ setWhy(false); }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function updateSelectAllState(){
|
||||
try {
|
||||
if (!selAll) return;
|
||||
var sel = getSelected();
|
||||
var recs = recoHost ? Array.prototype.slice.call(recoHost.querySelectorAll('button.chip-reco')).map(function(b){ return b.dataset.tag || ''; }).filter(Boolean) : [];
|
||||
var unselected = recs.filter(function(t){ return sel.indexOf(t) === -1; });
|
||||
var atCap = sel.length >= 3;
|
||||
var noNew = unselected.length === 0;
|
||||
var disable = atCap || noNew;
|
||||
selAll.disabled = disable;
|
||||
selAll.setAttribute('aria-disabled', disable ? 'true' : 'false');
|
||||
if (disable){
|
||||
selAll.title = atCap ? 'Already have 3 themes selected' : 'All recommended already selected';
|
||||
} else {
|
||||
selAll.title = 'Add recommended up to 3';
|
||||
}
|
||||
} catch(_){ }
|
||||
}
|
||||
|
||||
// initial: set from template-selected values, then maybe load persisted if none
|
||||
updateChipsState();
|
||||
loadPersisted();
|
||||
updateChipsState();
|
||||
})();
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
<h2>Build from JSON: {{ cfg_name }}</h2>
|
||||
<p class="muted" style="max-width: 70ch;">This page shows the results of a non-interactive build from the selected JSON configuration.</p>
|
||||
{% if commander %}
|
||||
<div class="muted">Commander: <strong data-card-name="{{ commander }}">{{ commander }}</strong></div>
|
||||
<div class="muted">Commander: <strong data-card-name="{{ commander }}">{{ commander }}</strong>{% if tag_mode %} · Combine: <code>{{ tag_mode }}</code>{% endif %}</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="two-col two-col-left-rail">
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
<div class="grid" style="display:grid; grid-template-columns: 200px 1fr; gap:6px; max-width: 920px;">
|
||||
<div>Commander</div><div>{{ data.commander }}</div>
|
||||
<div>Tags</div><div>{{ data.primary_tag }}{% if data.secondary_tag %}, {{ data.secondary_tag }}{% endif %}{% if data.tertiary_tag %}, {{ data.tertiary_tag }}{% endif %}</div>
|
||||
<div>Combine Mode</div><div>{{ (data.tag_mode or data.combine_mode or 'AND') | upper }}</div>
|
||||
<div>Bracket</div><div>{{ data.bracket_level }}</div>
|
||||
</div>
|
||||
</details>
|
||||
|
|
|
|||
|
|
@ -1,21 +1,38 @@
|
|||
{% extends "base.html" %}
|
||||
{% block banner_subtitle %}Finished Decks{% endblock %}
|
||||
{% block content %}
|
||||
<h2>Finished Decks</h2>
|
||||
<h2 id="decks-heading">Finished Decks</h2>
|
||||
<p class="muted">These are exported decklists from previous runs. Open a deck to view the final summary, download CSV/TXT, and inspect card types and curve.</p>
|
||||
|
||||
{% if error %}
|
||||
<div class="error">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<div style="margin:.75rem 0; display:flex; gap:.5rem; align-items:center;">
|
||||
<input type="text" id="deck-filter" placeholder="Filter decks…" style="max-width:280px;" />
|
||||
<div style="margin:.75rem 0; display:flex; gap:.5rem; align-items:center; flex-wrap:wrap;">
|
||||
<input type="text" id="deck-filter" placeholder="Filter decks…" style="max-width:280px;" aria-controls="deck-list" />
|
||||
<select id="deck-sort" aria-label="Sort decks">
|
||||
<option value="newest">Newest</option>
|
||||
<option value="oldest">Oldest</option>
|
||||
<option value="name-asc">Commander A–Z</option>
|
||||
<option value="name-desc">Commander Z–A</option>
|
||||
</select>
|
||||
<label for="deck-txt-only" style="display:flex; align-items:center; gap:.25rem;">
|
||||
<input type="checkbox" id="deck-txt-only" /> TXT only
|
||||
</label>
|
||||
<button id="deck-clear" type="button" title="Clear filters">Clear</button>
|
||||
<button id="deck-share" type="button" title="Copy a shareable link">Share</button>
|
||||
<button id="deck-reset-all" type="button" title="Reset filter, sort, and tags">Reset all</button>
|
||||
<button id="deck-help" type="button" title="Keyboard shortcuts and tips" aria-haspopup="dialog" aria-controls="deck-help-modal">Help</button>
|
||||
<span id="deck-count" class="muted" aria-live="polite"></span>
|
||||
<span id="deck-live" class="sr-only" aria-live="polite" role="status"></span>
|
||||
</div>
|
||||
<div id="tag-label" class="muted" style="font-size:12px; margin:.15rem 0 .25rem 0;">Theme filters</div>
|
||||
<div id="tag-chips" aria-labelledby="tag-label" style="display:flex; gap:.25rem; flex-wrap:wrap; margin:.25rem 0 .75rem 0;"></div>
|
||||
|
||||
{% if items %}
|
||||
<div id="deck-list" style="list-style:none; padding:0; margin:0; display:block;">
|
||||
<div id="deck-list" role="list" aria-labelledby="decks-heading" style="list-style:none; padding:0; margin:0; display:block;">
|
||||
{% for it in items %}
|
||||
<div class="panel" data-name="{{ it.name }}" data-commander="{{ it.commander }}" data-tags="{{ (it.tags|join(' ')) if it.tags else '' }}" style="margin:0 0 .5rem 0;">
|
||||
<div class="panel" role="listitem" tabindex="0" data-name="{{ it.name }}" data-commander="{{ it.commander }}" data-tags="{{ (it.tags|join(' ')) if it.tags else '' }}" data-tags-pipe="{{ (it.tags|join('|')) if it.tags else '' }}" data-mtime="{{ it.mtime if it.mtime is defined else 0 }}" data-txt="{{ '1' if it.txt_path else '0' }}" style="margin:0 0 .5rem 0;">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; gap:.5rem;">
|
||||
<div>
|
||||
<div>
|
||||
|
|
@ -24,17 +41,54 @@
|
|||
{% if it.tags and it.tags|length %}
|
||||
<div class="muted" style="font-size:12px;">Themes: {{ it.tags|join(', ') }}</div>
|
||||
{% endif %}
|
||||
<div class="muted" style="font-size:12px;">
|
||||
{% if it.mtime is defined %}
|
||||
<span title="Modified">{{ it.mtime | int }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex; gap:.35rem;">
|
||||
<div style="display:flex; gap:.35rem; align-items:center;">
|
||||
<form action="/files" method="get" style="display:inline; margin:0;">
|
||||
<input type="hidden" name="path" value="{{ it.path }}" />
|
||||
<button type="submit" title="Download CSV" aria-label="Download CSV for {{ it.commander }}">CSV</button>
|
||||
</form>
|
||||
{% if it.txt_path %}
|
||||
<form action="/files" method="get" style="display:inline; margin:0;">
|
||||
<input type="hidden" name="path" value="{{ it.txt_path }}" />
|
||||
<button type="submit" title="Download TXT" aria-label="Download TXT for {{ it.commander }}">TXT</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<form action="/decks/view" method="get" style="display:inline; margin:0;">
|
||||
<input type="hidden" name="name" value="{{ it.name }}" />
|
||||
<button type="submit">Open</button>
|
||||
<button type="submit" aria-label="Open deck {{ it.commander }}">Open</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div id="deck-empty" class="muted" style="display:none; margin-top:.5rem;">No decks match your filters.</div>
|
||||
<!-- Help modal -->
|
||||
<div id="deck-help-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="deck-help-title" hidden>
|
||||
<div class="modal-backdrop" id="deck-help-backdrop"></div>
|
||||
<div class="modal-content" role="document">
|
||||
<div class="modal-header">
|
||||
<h3 id="deck-help-title" style="margin:0;">Keyboard and tips</h3>
|
||||
<button type="button" id="deck-help-close" aria-label="Close help">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<ul style="margin:.25rem 0 0 1rem;">
|
||||
<li><kbd>/</kbd> focuses the filter</li>
|
||||
<li><kbd>Enter</kbd>/<kbd>Space</kbd> opens a focused deck; <kbd>Ctrl</kbd>/<kbd>Shift</kbd>+<kbd>Enter</kbd> opens in a new tab</li>
|
||||
<li><kbd>Arrow ↑/↓</kbd>, <kbd>Home</kbd>, <kbd>End</kbd> navigate rows</li>
|
||||
<li><kbd>Esc</kbd> clears the filter (when focused)</li>
|
||||
<li><kbd>R</kbd> resets all filters, sort, and tags</li>
|
||||
<li>Use “TXT only” to show only decks that have a TXT export</li>
|
||||
<li>Share copies a link with your current filters</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="muted">No exports yet. Run a build to create one.</div>
|
||||
{% endif %}
|
||||
|
|
@ -42,14 +96,459 @@
|
|||
<script>
|
||||
(function(){
|
||||
var input = document.getElementById('deck-filter');
|
||||
if (!input) return;
|
||||
input.addEventListener('input', function(){
|
||||
var q = (input.value || '').toLowerCase();
|
||||
document.querySelectorAll('#deck-list .panel').forEach(function(row){
|
||||
var hay = (row.dataset.name + ' ' + row.dataset.commander + ' ' + (row.dataset.tags||'')).toLowerCase();
|
||||
row.style.display = hay.indexOf(q) >= 0 ? '' : 'none';
|
||||
var sortSel = document.getElementById('deck-sort');
|
||||
var clearBtn = document.getElementById('deck-clear');
|
||||
var list = document.getElementById('deck-list');
|
||||
var chips = document.getElementById('tag-chips');
|
||||
var countEl = document.getElementById('deck-count');
|
||||
var shareBtn = document.getElementById('deck-share');
|
||||
var resetAllBtn = document.getElementById('deck-reset-all');
|
||||
var liveEl = document.getElementById('deck-live');
|
||||
var emptyEl = document.getElementById('deck-empty');
|
||||
var helpBtn = document.getElementById('deck-help');
|
||||
var helpModal = document.getElementById('deck-help-modal');
|
||||
var helpClose = document.getElementById('deck-help-close');
|
||||
var helpBackdrop = document.getElementById('deck-help-backdrop');
|
||||
var txtOnlyCb = document.getElementById('deck-txt-only');
|
||||
if (!list) return;
|
||||
|
||||
// Build tag chips from data-tags-pipe
|
||||
var tagSet = new Set();
|
||||
var panels = Array.prototype.slice.call(list.querySelectorAll('.panel'));
|
||||
function refreshPanels(){ panels = Array.prototype.slice.call(list.querySelectorAll('.panel')); }
|
||||
panels.forEach(function(p){
|
||||
var raw = p.dataset.tagsPipe || '';
|
||||
raw.split('|').forEach(function(t){ if (t && t.trim()) tagSet.add(t.trim()); });
|
||||
});
|
||||
var activeTags = new Set();
|
||||
|
||||
// URL hash <-> state sync helpers
|
||||
function parseHash(){
|
||||
try {
|
||||
var h = (location.hash || '').replace(/^#/, '');
|
||||
if (!h) return null;
|
||||
var qp = new URLSearchParams(h);
|
||||
var q = qp.get('q') || '';
|
||||
var sort = qp.get('sort') || '';
|
||||
var tagsStr = qp.get('tags') || '';
|
||||
var tags = tagsStr ? tagsStr.split(',').filter(Boolean).map(function(s){ return decodeURIComponent(s); }) : [];
|
||||
var txt = qp.get('txt');
|
||||
var txtOnly = (txt === '1' || txt === 'true');
|
||||
return { q: q, sort: sort, tags: tags, txt: txtOnly };
|
||||
} catch(_) { return null; }
|
||||
}
|
||||
function updateHashFromState(){
|
||||
try {
|
||||
var q = (input && input.value) ? input.value.trim() : '';
|
||||
var sort = (sortSel && sortSel.value) ? sortSel.value : 'newest';
|
||||
var tags = Array.from(activeTags);
|
||||
var qp = new URLSearchParams();
|
||||
if (q) qp.set('q', q);
|
||||
if (sort && sort !== 'newest') qp.set('sort', sort);
|
||||
if (tags.length) qp.set('tags', tags.map(function(s){ return encodeURIComponent(s); }).join(','));
|
||||
if (txtOnlyCb && txtOnlyCb.checked) qp.set('txt', '1');
|
||||
var newHash = qp.toString();
|
||||
var base = location.pathname + location.search;
|
||||
var current = (location.hash || '').replace(/^#/, '');
|
||||
if (current !== newHash) {
|
||||
history.replaceState(null, '', base + (newHash ? ('#' + newHash) : ''));
|
||||
}
|
||||
} catch(_){ }
|
||||
}
|
||||
function applyStateFromHash(){
|
||||
var s = parseHash();
|
||||
if (!s) return false;
|
||||
var changed = false;
|
||||
if (typeof s.q === 'string' && input && input.value !== s.q) { input.value = s.q; changed = true; }
|
||||
if (s.sort && sortSel && sortSel.value !== s.sort) { sortSel.value = s.sort; changed = true; }
|
||||
if (Array.isArray(s.tags)) { activeTags = new Set(s.tags); changed = true; }
|
||||
if (typeof s.txt === 'boolean' && txtOnlyCb) { txtOnlyCb.checked = s.txt; changed = true; }
|
||||
renderChips();
|
||||
applyAll();
|
||||
return changed;
|
||||
}
|
||||
function renderChips(){
|
||||
if (!chips) return;
|
||||
chips.innerHTML = '';
|
||||
Array.from(tagSet).sort(function(a,b){ return a.localeCompare(b); }).forEach(function(t){
|
||||
var btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'chip chip-filter' + (activeTags.has(t) ? ' active' : '');
|
||||
btn.textContent = t;
|
||||
btn.setAttribute('aria-pressed', activeTags.has(t) ? 'true' : 'false');
|
||||
btn.addEventListener('click', function(){
|
||||
if (activeTags.has(t)) activeTags.delete(t); else activeTags.add(t);
|
||||
renderChips();
|
||||
applyAll();
|
||||
});
|
||||
chips.appendChild(btn);
|
||||
});
|
||||
// Reset tags control appears only when any tags are active
|
||||
if (activeTags.size > 0) {
|
||||
var reset = document.createElement('button');
|
||||
reset.type = 'button';
|
||||
reset.id = 'reset-tags';
|
||||
reset.className = 'chip';
|
||||
reset.textContent = 'Reset tags';
|
||||
reset.title = 'Clear selected theme tags';
|
||||
reset.addEventListener('click', function(){
|
||||
activeTags.clear();
|
||||
renderChips();
|
||||
applyAll();
|
||||
if (liveEl) liveEl.textContent = 'Theme tags cleared';
|
||||
});
|
||||
chips.appendChild(reset);
|
||||
}
|
||||
}
|
||||
|
||||
function updateCount(){
|
||||
if (!countEl) return;
|
||||
var total = panels.length;
|
||||
var visible = panels.filter(function(p){ return p.style.display !== 'none'; }).length;
|
||||
countEl.textContent = visible + ' of ' + total + ' decks';
|
||||
if (emptyEl) emptyEl.style.display = (visible === 0 ? '' : 'none');
|
||||
try {
|
||||
if (liveEl) {
|
||||
if (visible === 0) liveEl.textContent = 'No decks match your filters';
|
||||
else liveEl.textContent = 'Showing ' + visible + ' of ' + total + ' decks';
|
||||
}
|
||||
} catch(_){ }
|
||||
return { total: total, visible: visible };
|
||||
}
|
||||
|
||||
function applyFilter(){
|
||||
var q = (input && input.value || '').toLowerCase();
|
||||
panels.forEach(function(row){
|
||||
var hay = (row.dataset.name + ' ' + row.dataset.commander + ' ' + (row.dataset.tags||'')).toLowerCase();
|
||||
var textMatch = hay.indexOf(q) >= 0;
|
||||
var tagsPipe = row.dataset.tagsPipe || '';
|
||||
var tags = tagsPipe ? tagsPipe.split('|').filter(Boolean) : [];
|
||||
var tagMatch = true;
|
||||
activeTags.forEach(function(t){ if (tags.indexOf(t) === -1) tagMatch = false; });
|
||||
var txtOk = true;
|
||||
try { if (txtOnlyCb && txtOnlyCb.checked) { txtOk = (row.dataset.txt === '1'); } } catch(_){ }
|
||||
row.style.display = (textMatch && tagMatch && txtOk) ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
function highlightMatches(){
|
||||
var q = (input && input.value || '').trim();
|
||||
var ql = q.toLowerCase();
|
||||
panels.forEach(function(row){
|
||||
var strong = row.querySelector('strong[data-card-name]');
|
||||
if (!strong) return;
|
||||
var raw = strong.getAttribute('data-card-name') || strong.textContent || '';
|
||||
if (!q) { strong.textContent = raw; return; }
|
||||
var low = raw.toLowerCase();
|
||||
var i = low.indexOf(ql);
|
||||
if (i >= 0) {
|
||||
strong.innerHTML = raw.substring(0, i) + '<mark>' + raw.substring(i, i+q.length) + '</mark>' + raw.substring(i+q.length);
|
||||
} else {
|
||||
strong.textContent = raw;
|
||||
}
|
||||
// Also highlight in Themes: ... line if present
|
||||
try {
|
||||
var themeEl = Array.prototype.slice.call(row.querySelectorAll('.muted')).find(function(el){
|
||||
var t = (el.textContent || '').trim().toLowerCase();
|
||||
return t.startsWith('themes:');
|
||||
});
|
||||
if (themeEl) {
|
||||
if (!themeEl.dataset.raw) { themeEl.dataset.raw = themeEl.textContent || ''; }
|
||||
var base = themeEl.dataset.raw;
|
||||
if (!q) { themeEl.textContent = base; }
|
||||
else {
|
||||
var prefix = 'Themes: ';
|
||||
var rest = base.startsWith(prefix) ? base.substring(prefix.length) : base;
|
||||
var li = rest.toLowerCase().indexOf(ql);
|
||||
if (li >= 0) {
|
||||
themeEl.innerHTML = prefix + rest.substring(0, li) + '<mark>' + rest.substring(li, li+q.length) + '</mark>' + rest.substring(li+q.length);
|
||||
} else {
|
||||
themeEl.textContent = base;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch(_){ }
|
||||
});
|
||||
}
|
||||
|
||||
function applySort(){
|
||||
var mode = (sortSel && sortSel.value) || 'newest';
|
||||
var rows = panels.slice();
|
||||
rows.sort(function(a,b){
|
||||
if (mode === 'newest' || mode === 'oldest'){
|
||||
var am = parseFloat(a.dataset.mtime || '0');
|
||||
var bm = parseFloat(b.dataset.mtime || '0');
|
||||
return (mode === 'newest') ? (bm - am) : (am - bm);
|
||||
} else if (mode === 'name-asc' || mode === 'name-desc'){
|
||||
var ac = (a.dataset.commander || '').toLowerCase();
|
||||
var bc = (b.dataset.commander || '').toLowerCase();
|
||||
var cmp = ac.localeCompare(bc);
|
||||
return (mode === 'name-asc') ? cmp : -cmp;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
// Re-append in new order
|
||||
rows.forEach(function(r){ list.appendChild(r); });
|
||||
refreshPanels();
|
||||
}
|
||||
|
||||
function applyAll(){
|
||||
applyFilter();
|
||||
applySort();
|
||||
highlightMatches();
|
||||
var counts = updateCount();
|
||||
// If focus is on a hidden panel, move to first visible
|
||||
try {
|
||||
var active = document.activeElement;
|
||||
if (active && list.contains(active)) {
|
||||
var p = active.closest('.panel');
|
||||
if (p && p.style.display === 'none') {
|
||||
var firstVis = Array.prototype.slice.call(list.querySelectorAll('.panel')).find(function(el){ return el.style.display !== 'none'; });
|
||||
if (firstVis) firstVis.focus();
|
||||
}
|
||||
}
|
||||
} catch(_){ }
|
||||
// Persist state
|
||||
try {
|
||||
if (input) localStorage.setItem('decks-filter', input.value || '');
|
||||
if (sortSel) localStorage.setItem('decks-sort', sortSel.value || 'newest');
|
||||
localStorage.setItem('decks-tags', JSON.stringify(Array.from(activeTags)));
|
||||
if (txtOnlyCb) localStorage.setItem('decks-txt', txtOnlyCb.checked ? '1' : '0');
|
||||
} catch(_){ }
|
||||
// Update URL hash for shareable state
|
||||
updateHashFromState();
|
||||
}
|
||||
|
||||
// Debounce helper
|
||||
function debounce(fn, delay){
|
||||
var timer = null;
|
||||
return function(){
|
||||
var ctx = this, args = arguments;
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(function(){ fn.apply(ctx, args); }, delay);
|
||||
};
|
||||
}
|
||||
|
||||
var debouncedApply = debounce(applyAll, 150);
|
||||
if (input) input.addEventListener('input', debouncedApply);
|
||||
if (sortSel) sortSel.addEventListener('change', applyAll);
|
||||
if (txtOnlyCb) txtOnlyCb.addEventListener('change', applyAll);
|
||||
if (clearBtn) clearBtn.addEventListener('click', function(){
|
||||
if (input) input.value = '';
|
||||
activeTags.clear();
|
||||
if (sortSel) sortSel.value = 'newest';
|
||||
if (txtOnlyCb) txtOnlyCb.checked = false;
|
||||
renderChips();
|
||||
applyAll();
|
||||
});
|
||||
|
||||
if (resetAllBtn) resetAllBtn.addEventListener('click', function(){
|
||||
// Clear UI state
|
||||
try {
|
||||
if (input) input.value = '';
|
||||
if (sortSel) sortSel.value = 'newest';
|
||||
if (txtOnlyCb) txtOnlyCb.checked = false;
|
||||
activeTags.clear();
|
||||
renderChips();
|
||||
// Clear persistence
|
||||
localStorage.removeItem('decks-filter');
|
||||
localStorage.removeItem('decks-sort');
|
||||
localStorage.removeItem('decks-tags');
|
||||
localStorage.removeItem('decks-txt');
|
||||
// Clear URL hash
|
||||
var base = location.pathname + location.search;
|
||||
history.replaceState(null, '', base);
|
||||
} catch(_){ }
|
||||
applyAll();
|
||||
if (liveEl) liveEl.textContent = 'Filters, sort, and tags reset';
|
||||
});
|
||||
|
||||
if (shareBtn) shareBtn.addEventListener('click', function(){
|
||||
try {
|
||||
// Ensure hash reflects current UI state
|
||||
updateHashFromState();
|
||||
var url = window.location.href;
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(url);
|
||||
} else {
|
||||
var t = document.createElement('input');
|
||||
t.value = url; document.body.appendChild(t); t.select(); try { document.execCommand('copy'); } catch(_){} document.body.removeChild(t);
|
||||
}
|
||||
var prev = shareBtn.textContent;
|
||||
shareBtn.textContent = 'Copied';
|
||||
setTimeout(function(){ shareBtn.textContent = prev; }, 1200);
|
||||
if (liveEl) liveEl.textContent = 'Link copied to clipboard';
|
||||
} catch(_){ }
|
||||
});
|
||||
|
||||
// Initial state: prefer URL hash, fall back to localStorage
|
||||
var hadHash = false;
|
||||
try { hadHash = !!((location.hash || '').replace(/^#/, '')); } catch(_){ }
|
||||
if (hadHash) {
|
||||
renderChips();
|
||||
if (!applyStateFromHash()) { applyAll(); }
|
||||
} else {
|
||||
// Load persisted state
|
||||
try {
|
||||
var savedFilter = localStorage.getItem('decks-filter') || '';
|
||||
if (input) input.value = savedFilter;
|
||||
var savedSort = localStorage.getItem('decks-sort') || 'newest';
|
||||
if (sortSel) sortSel.value = savedSort;
|
||||
var savedTags = JSON.parse(localStorage.getItem('decks-tags') || '[]');
|
||||
if (Array.isArray(savedTags)) savedTags.forEach(function(t){ activeTags.add(t); });
|
||||
if (txtOnlyCb) txtOnlyCb.checked = (localStorage.getItem('decks-txt') === '1');
|
||||
} catch(_){ }
|
||||
renderChips();
|
||||
applyAll();
|
||||
}
|
||||
|
||||
// React to external hash changes
|
||||
window.addEventListener('hashchange', function(){ applyStateFromHash(); });
|
||||
|
||||
// Open deck: keyboard and mouse helpers on panels
|
||||
function getPanelUrl(p){
|
||||
try {
|
||||
var name = p.getAttribute('data-name') || '';
|
||||
if (name) return '/decks/view?name=' + encodeURIComponent(name);
|
||||
var form = p.querySelector('form[action="/decks/view"]');
|
||||
if (form) {
|
||||
var nameInput = form.querySelector('input[name="name"]');
|
||||
if (nameInput && nameInput.value) return '/decks/view?name=' + encodeURIComponent(nameInput.value);
|
||||
}
|
||||
} catch(_){ }
|
||||
return '/decks/view';
|
||||
}
|
||||
function openPanel(p, newTab){
|
||||
if (!p) return;
|
||||
if (newTab) { window.open(getPanelUrl(p), '_blank'); return; }
|
||||
var openForm = p.querySelector('form[action="/decks/view"]');
|
||||
if (openForm) {
|
||||
if (window.htmx) { window.htmx.trigger(openForm, 'submit'); }
|
||||
else if (openForm.submit) { openForm.submit(); }
|
||||
} else { window.location.href = getPanelUrl(p); }
|
||||
}
|
||||
list.addEventListener('dblclick', function(e){
|
||||
var p = e.target.closest('.panel');
|
||||
if (!p) return;
|
||||
// Ignore when double-clicking interactive controls
|
||||
if (e.target.closest('button, a, input, select, textarea, label, form')) return;
|
||||
openPanel(p);
|
||||
});
|
||||
list.addEventListener('keydown', function(e){
|
||||
var p = e.target.closest('.panel[tabindex]');
|
||||
if (!p) return;
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
var newTab = !!(e.ctrlKey || e.metaKey || e.shiftKey);
|
||||
openPanel(p, newTab);
|
||||
}
|
||||
});
|
||||
|
||||
// Arrow key navigation between visible panels
|
||||
document.addEventListener('keydown', function(e){
|
||||
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp' && e.key !== 'Home' && e.key !== 'End') return;
|
||||
var active = document.activeElement;
|
||||
if (!active || !list.contains(active)) return;
|
||||
var vis = Array.prototype.slice.call(list.querySelectorAll('.panel')).filter(function(p){ return p.style.display !== 'none'; });
|
||||
if (!vis.length) return;
|
||||
var idx = vis.indexOf(active.closest('.panel'));
|
||||
if (idx === -1) return;
|
||||
e.preventDefault();
|
||||
var target = null;
|
||||
if (e.key === 'ArrowDown') target = vis[Math.min(idx + 1, vis.length - 1)];
|
||||
else if (e.key === 'ArrowUp') target = vis[Math.max(idx - 1, 0)];
|
||||
else if (e.key === 'Home') target = vis[0];
|
||||
else if (e.key === 'End') target = vis[vis.length - 1];
|
||||
if (target) { try { target.focus(); } catch(_){ } }
|
||||
});
|
||||
|
||||
// ESC clears filter when focused in the filter input
|
||||
if (input) {
|
||||
input.addEventListener('keydown', function(e){
|
||||
if (e.key === 'Escape' && input.value) {
|
||||
input.value = '';
|
||||
debouncedApply();
|
||||
} else if (e.key === 'Enter') {
|
||||
// Open first visible deck when pressing Enter in filter
|
||||
var firstVis = Array.prototype.slice.call(list.querySelectorAll('.panel')).find(function(el){ return el.style.display !== 'none'; });
|
||||
if (firstVis) { e.preventDefault(); openPanel(firstVis, !!(e.ctrlKey||e.metaKey||e.shiftKey)); }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Quick focus: '/' focuses filter when not typing elsewhere
|
||||
document.addEventListener('keydown', function(e){
|
||||
if (e.key !== '/') return;
|
||||
var tag = (e.target && e.target.tagName) ? e.target.tagName.toLowerCase() : '';
|
||||
var isEditable = (tag === 'input' || tag === 'textarea' || tag === 'select' || (e.target && e.target.isContentEditable));
|
||||
if (isEditable) return;
|
||||
if (e.ctrlKey || e.altKey || e.metaKey) return;
|
||||
e.preventDefault();
|
||||
if (input) { input.focus(); try { input.select(); } catch(_){} }
|
||||
});
|
||||
|
||||
// Global shortcut: 'R' to reset all (when not typing)
|
||||
document.addEventListener('keydown', function(e){
|
||||
if ((e.key === 'r' || e.key === 'R') && !(e.ctrlKey || e.altKey || e.metaKey)) {
|
||||
var tag = (e.target && e.target.tagName) ? e.target.tagName.toLowerCase() : '';
|
||||
var isEditable = (tag === 'input' || tag === 'textarea' || tag === 'select' || (e.target && e.target.isContentEditable));
|
||||
if (isEditable) return;
|
||||
if (resetAllBtn) { e.preventDefault(); resetAllBtn.click(); }
|
||||
}
|
||||
});
|
||||
|
||||
// Help modal wiring
|
||||
(function(){
|
||||
if (!helpBtn || !helpModal) return;
|
||||
var prevFocus = null;
|
||||
function openHelp(){
|
||||
prevFocus = document.activeElement;
|
||||
helpModal.hidden = false;
|
||||
try { document.body.dataset.prevOverflow = document.body.style.overflow || ''; document.body.style.overflow = 'hidden'; } catch(_){ }
|
||||
var close = helpClose || helpModal.querySelector('button');
|
||||
if (close) try { close.focus(); } catch(_){ }
|
||||
}
|
||||
function closeHelp(){
|
||||
helpModal.hidden = true;
|
||||
try { document.body.style.overflow = document.body.dataset.prevOverflow || ''; } catch(_){ }
|
||||
if (prevFocus) try { prevFocus.focus(); } catch(_){ }
|
||||
}
|
||||
helpBtn.addEventListener('click', openHelp);
|
||||
if (helpClose) helpClose.addEventListener('click', closeHelp);
|
||||
if (helpBackdrop) helpBackdrop.addEventListener('click', closeHelp);
|
||||
document.addEventListener('keydown', function(e){ if (e.key === 'Escape' && !helpModal.hidden) { e.preventDefault(); closeHelp(); } });
|
||||
document.addEventListener('keydown', function(e){
|
||||
if ((e.key === '?' || (e.shiftKey && e.key === '/')) && !(e.ctrlKey||e.metaKey||e.altKey)){
|
||||
var tag = (e.target && e.target.tagName) ? e.target.tagName.toLowerCase() : '';
|
||||
var isEditable = (tag === 'input' || tag === 'textarea' || tag === 'select' || (e.target && e.target.isContentEditable));
|
||||
if (isEditable) return;
|
||||
e.preventDefault();
|
||||
if (helpModal.hidden) openHelp(); else closeHelp();
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
// Enhance mtime display to human-readable date
|
||||
try {
|
||||
panels.forEach(function(p){
|
||||
var m = parseFloat(p.dataset.mtime || '0');
|
||||
if (!m) return;
|
||||
var el = p.querySelector('[title="Modified"]');
|
||||
if (el) {
|
||||
try { el.textContent = new Date(m * 1000).toLocaleString(); } catch(_){}
|
||||
}
|
||||
});
|
||||
} catch(_){ }
|
||||
|
||||
// (copy button removed)
|
||||
})();
|
||||
</script>
|
||||
<style>
|
||||
.chip-filter { cursor:pointer; user-select:none; }
|
||||
.chip-filter.active { background:#2563eb; color:#fff; border-color:#1d4ed8; }
|
||||
.sr-only{ position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; }
|
||||
mark { background: rgba(251, 191, 36, .35); color: inherit; padding:0 .1rem; border-radius:2px; }
|
||||
#deck-list[role="list"] .panel[role="listitem"] { outline: none; }
|
||||
#deck-list[role="list"] .panel[role="listitem"]:focus { box-shadow: 0 0 0 2px #3b82f6 inset; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue