mtg_python_deckbuilder/code/web/templates/decks/index.html

554 lines
24 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

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

{% extends "base.html" %}
{% block banner_subtitle %}Finished Decks{% endblock %}
{% block content %}
<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; 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 AZ</option>
<option value="name-desc">Commander ZA</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" role="list" aria-labelledby="decks-heading" style="list-style:none; padding:0; margin:0; display:block;">
{% for it in items %}
<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>
<strong data-card-name="{{ it.commander }}">{{ it.commander }}</strong>
</div>
{% 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; 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" 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 %}
<script>
(function(){
var input = document.getElementById('deck-filter');
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 %}