feat: locks/replace/compare/permalinks; perf: virtualization, LQIP, caching, diagnostics; add tests, docs, and issue/PR templates (flags OFF)

This commit is contained in:
matt 2025-08-28 14:57:22 -07:00
parent f8c6b5c07e
commit 721e1884af
41 changed files with 2960 additions and 143 deletions

View file

@ -0,0 +1,226 @@
{% extends "base.html" %}
{% block banner_subtitle %}Compare Decks{% endblock %}
{% block content %}
<h2>Compare Decks</h2>
<p class="muted">Pick two finished decks to compare. You can get here from Finished Decks or deck view pages.</p>
<form method="get" action="/decks/compare" class="panel" style="display:flex; gap:.5rem; align-items:center; flex-wrap:wrap;">
<label>Deck A
<select name="A" required>
<option value="">Choose…</option>
{% for opt in options %}
<option value="{{ opt.name }}" data-mtime="{{ opt.mtime }}" {% if A == opt.name %}selected{% endif %}>{{ opt.label }}</option>
{% endfor %}
</select>
</label>
<label>Deck B
<select name="B" required>
<option value="">Choose…</option>
{% for opt in options %}
<option value="{{ opt.name }}" data-mtime="{{ opt.mtime }}" {% if B == opt.name %}selected{% endif %}>{{ opt.label }}</option>
{% endfor %}
</select>
</label>
<button type="submit">Compare</button>
<button type="button" id="cmp-swap" class="btn" title="Swap A and B" style="margin-left:.25rem;">Swap A/B</button>
<button type="button" id="cmp-latest" class="btn" title="Pick the latest two decks">Latest two</button>
</form>
{% if diffs %}
<div class="panel" style="margin-top:.75rem;">
<div style="display:flex; gap:1rem; flex-wrap:wrap; align-items:center;">
<div>
<strong>A:</strong> {{ metaA.display or metaA.filename }}
{% if metaA.commander %}<span class="muted">({{ metaA.commander }})</span>{% endif %}
{% if metaA.tags %}<div class="muted">{{ metaA.tags }}</div>{% endif %}
</div>
<div>
<strong>B:</strong> {{ metaB.display or metaB.filename }}
{% if metaB.commander %}<span class="muted">({{ metaB.commander }})</span>{% endif %}
{% if metaB.tags %}<div class="muted">{{ metaB.tags }}</div>{% endif %}
</div>
<div style="margin-left:auto; display:flex; gap:.5rem; align-items:center;">
<button type="button" id="cmp-copy" class="btn" title="Copy a plain-text summary of the diffs">Copy summary</button>
<button type="button" id="cmp-download" class="btn" title="Download a plain-text summary of the diffs">Download .txt</button>
</div>
</div>
</div>
<div class="panel" style="margin-top:.5rem; display:flex; gap:1rem; align-items:center; flex-wrap:wrap;">
<div class="muted">Totals:
<strong id="totA">{{ (diffs.onlyA|length) if diffs.onlyA else 0 }}</strong> only-in-A,
<strong id="totB">{{ (diffs.onlyB|length) if diffs.onlyB else 0 }}</strong> only-in-B,
<strong id="totC">{{ (diffs.changed|length) if diffs.changed else 0 }}</strong> changed
</div>
<label class="muted" style="margin-left:auto; display:flex; align-items:center; gap:.35rem;">
<input type="checkbox" id="cmp-changed-only" /> Changed only
</label>
</div>
<div class="only-panels" style="display:grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-top:.75rem;">
<div class="panel onlyA">
<h3 style="margin-top:0;">Only in A</h3>
{% if diffs.onlyA and diffs.onlyA|length %}
<ul>
{% for n in diffs.onlyA %}<li>{{ n }}</li>{% endfor %}
</ul>
{% else %}
<div class="muted">None</div>
{% endif %}
</div>
<div class="panel onlyB">
<h3 style="margin-top:0;">Only in B</h3>
{% if diffs.onlyB and diffs.onlyB|length %}
<ul>
{% for n in diffs.onlyB %}<li>{{ n }}</li>{% endfor %}
</ul>
{% else %}
<div class="muted">None</div>
{% endif %}
</div>
</div>
<div class="panel" style="margin-top:1rem;">
<h3 style="margin-top:0;">Changed counts</h3>
{% if diffs.changed and diffs.changed|length %}
<ul class="changed-list">
{% for n, a, b in diffs.changed %}
{% set delta = b - a %}
{% if delta > 0 %}
<li class="chg inc" title="Increased in B">▲ {{ n }}: A={{ a }}, B={{ b }} (+{{ delta }})</li>
{% elif delta < 0 %}
<li class="chg dec" title="Decreased in B">▼ {{ n }}: A={{ a }}, B={{ b }} ({{ delta }})</li>
{% else %}
<li class="chg">{{ n }}: A={{ a }}, B={{ b }}</li>
{% endif %}
{% endfor %}
</ul>
{% else %}
<div class="muted">None</div>
{% endif %}
</div>
<script id="cmp-data" type="application/json">{{ {
'aLabel': (metaA.display or metaA.filename),
'bLabel': (metaB.display or metaB.filename),
'onlyA': diffs.onlyA or [],
'onlyB': diffs.onlyB or [],
'changed': diffs.changed or []
} | tojson }}</script>
<script>
(function(){
var copyBtn = document.getElementById('cmp-copy');
var dlBtn = document.getElementById('cmp-download');
var changedOnly = document.getElementById('cmp-changed-only');
var dataEl = document.getElementById('cmp-data');
var data = null;
try { data = JSON.parse((dataEl && dataEl.textContent) ? dataEl.textContent : 'null'); } catch(e) { data = null; }
function buildLines(){
var lines = [];
lines.push('Compare:');
lines.push('A: ' + data.aLabel);
lines.push('B: ' + data.bLabel);
lines.push('');
if (!changedOnly || !changedOnly.checked) {
lines.push('Only in A:');
if (data.onlyA && data.onlyA.length) { data.onlyA.forEach(function(n){ lines.push('- ' + n); }); }
else { lines.push('(none)'); }
lines.push('');
lines.push('Only in B:');
if (data.onlyB && data.onlyB.length) { data.onlyB.forEach(function(n){ lines.push('- ' + n); }); }
else { lines.push('(none)'); }
lines.push('');
}
lines.push('Changed counts:');
if (data.changed && data.changed.length) {
data.changed.forEach(function(row){ lines.push('- ' + row[0] + ': A=' + row[1] + ', B=' + row[2]); });
} else { lines.push('(none)'); }
return lines;
}
if (copyBtn) copyBtn.addEventListener('click', function(){
try{
var txt = buildLines().join('\n');
if (navigator.clipboard && navigator.clipboard.writeText){ navigator.clipboard.writeText(txt); }
else {
var ta = document.createElement('textarea'); ta.value = txt; document.body.appendChild(ta); ta.select(); try{ document.execCommand('copy'); }catch(_){} document.body.removeChild(ta);
}
if (window.toast) window.toast('Copied comparison');
}catch(_){ }
});
if (dlBtn) dlBtn.addEventListener('click', function(){
try{
var txt = buildLines().join('\n');
var blob = new Blob([txt], {type:'text/plain'});
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = 'compare.txt';
document.body.appendChild(a);
a.click();
setTimeout(function(){ try{ URL.revokeObjectURL(url); document.body.removeChild(a); }catch(_){ } }, 0);
}catch(_){ }
});
function applyChangedOnlyFlag(){
try{
var wrap = document.querySelector('.only-panels');
if (!wrap || !changedOnly) return;
wrap.style.display = changedOnly.checked ? 'none' : 'grid';
}catch(_){ }
}
if (changedOnly) {
try {
var saved = localStorage.getItem('compare:changedOnly');
if (saved === '1') { changedOnly.checked = true; }
} catch(_){ }
applyChangedOnlyFlag();
changedOnly.addEventListener('change', function(){
try { localStorage.setItem('compare:changedOnly', this.checked ? '1' : '0'); } catch(_){ }
applyChangedOnlyFlag();
});
}
// Swap A/B
var swapBtn = document.getElementById('cmp-swap');
if (swapBtn) swapBtn.addEventListener('click', function(){
try{
var f = this.closest('form'); if(!f) return;
var a = f.querySelector('select[name="A"]');
var b = f.querySelector('select[name="B"]');
if(!a || !b) return;
var aVal = a.value, bVal = b.value;
a.value = bVal; b.value = aVal;
f.requestSubmit();
}catch(_){ }
});
// Pick latest two by mtime from options metadata
var latestBtn = document.getElementById('cmp-latest');
if (latestBtn) latestBtn.addEventListener('click', function(){
try{
var f = this.closest('form'); if(!f) return;
var a = f.querySelector('select[name="A"]');
var b = f.querySelector('select[name="B"]');
if(!a || !b) return;
var opts = Array.from(a.querySelectorAll('option[value]')).filter(function(o){ return o.value; });
opts.sort(function(x,y){
var mx = parseInt(x.getAttribute('data-mtime') || '0', 10);
var my = parseInt(y.getAttribute('data-mtime') || '0', 10);
return (my - mx);
});
if (opts.length >= 2){
var first = opts[0].value;
var second = opts[1].value;
a.value = first; b.value = second;
f.requestSubmit();
}
}catch(_){ }
});
})();
</script>
<style>
.changed-list { list-style: none; padding-left: 0; }
.changed-list .chg { padding: 2px 0; }
.changed-list .chg.inc { color: #10b981; }
.changed-list .chg.dec { color: #ef4444; }
.only-panels .onlyA h3 { color: #60a5fa; }
.only-panels .onlyB h3 { color: #f59e0b; }
</style>
{% endif %}
{% endblock %}

View file

@ -24,6 +24,10 @@
</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>
<a href="/decks/compare" class="btn" role="button" title="Compare two finished decks">Compare</a>
<button id="deck-compare-selected" type="button" title="Compare two selected decks" disabled>Compare selected</button>
<button id="deck-compare-latest" type="button" title="Pick the latest two decks">Latest two</button>
<button id="deck-open-permalink" type="button" title="Open a saved permalink">Open Permalink…</button>
<button id="deck-reset-all" type="button" title="Reset filter, sort, and theme">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>
@ -34,11 +38,16 @@
{% 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 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>
{% if it.display %}
<strong>{{ it.display }}</strong>
<div class="muted" style="font-size:12px;">Commander: <span data-card-name="{{ it.commander }}">{{ it.commander }}</span></div>
{% else %}
<strong data-card-name="{{ it.commander }}">{{ it.commander }}</strong>
{% endif %}
</div>
{% if it.tags and it.tags|length %}
<div class="muted" style="font-size:12px;">Themes: {{ it.tags|join(', ') }}</div>
@ -50,6 +59,10 @@
</div>
</div>
<div style="display:flex; gap:.35rem; align-items:center;">
<label title="Select deck for comparison" style="display:flex; align-items:center; gap:.25rem;">
<input type="checkbox" class="deck-select" aria-label="Select deck {{ it.name }} for comparison" />
<span class="muted" style="font-size:12px;">Select</span>
</label>
<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>
@ -112,11 +125,22 @@
var helpClose = document.getElementById('deck-help-close');
var helpBackdrop = document.getElementById('deck-help-backdrop');
var txtOnlyCb = document.getElementById('deck-txt-only');
var cmpSelBtn = document.getElementById('deck-compare-selected');
var cmpLatestBtn = document.getElementById('deck-compare-latest');
var openPermalinkBtn = document.getElementById('deck-open-permalink');
if (!list) return;
// Panels and themes discovery from data-tags-pipe
var panels = Array.prototype.slice.call(list.querySelectorAll('.panel'));
function refreshPanels(){ panels = Array.prototype.slice.call(list.querySelectorAll('.panel')); }
// Selection state for compare
var selected = new Set();
function updateCompareButtons(){
if (!cmpSelBtn) return;
var size = selected.size;
cmpSelBtn.disabled = (size !== 2);
if (cmpSelBtn) cmpSelBtn.title = (size === 2 ? 'Compare the two selected decks' : 'Select exactly two decks to enable');
}
var themeSet = new Set();
panels.forEach(function(p){
var raw = p.dataset.tagsPipe || '';
@ -309,6 +333,31 @@
updateHashFromState();
}
// Wire up compare selection checkboxes
function attachSelectHandlers(){
try {
var cbs = Array.prototype.slice.call(list.querySelectorAll('input.deck-select'));
cbs.forEach(function(cb){
// Initialize checked state based on current selection
var row = cb.closest('.panel');
var name = row ? (row.dataset.name || '') : '';
cb.checked = selected.has(name);
// Apply visual state on init
if (row) row.classList.toggle('selected', cb.checked);
cb.addEventListener('change', function(){
if (!name) return;
if (cb.checked) { selected.add(name); }
else { selected.delete(name); }
// Toggle selection highlight
if (row) row.classList.toggle('selected', cb.checked);
updateCompareButtons();
});
});
updateCompareButtons();
} catch(_){}
}
attachSelectHandlers();
// Debounce helper
function debounce(fn, delay){
var timer = null;
@ -332,6 +381,53 @@
applyAll();
});
// Compare selected action
if (cmpSelBtn) cmpSelBtn.addEventListener('click', function(){
try {
if (selected.size !== 2) return;
var arr = Array.from(selected);
var url = '/decks/compare?A=' + encodeURIComponent(arr[0]) + '&B=' + encodeURIComponent(arr[1]);
window.location.href = url;
} catch(_){ }
});
// Latest two (by modified time across all decks, not just visible)
if (cmpLatestBtn) cmpLatestBtn.addEventListener('click', function(){
try {
// Gather all panels (including hidden) and sort by data-mtime desc
var rows = Array.prototype.slice.call(list.querySelectorAll('.panel'));
rows.sort(function(a,b){
var am = parseFloat(a.dataset.mtime || '0');
var bm = parseFloat(b.dataset.mtime || '0');
return bm - am;
});
// Take first two distinct names
var pick = [];
for (var i=0; i<rows.length && pick.length<2; i++){
var nm = rows[i].dataset.name || '';
if (nm && pick.indexOf(nm) === -1) pick.push(nm);
}
if (pick.length === 2){
var url = '/decks/compare?A=' + encodeURIComponent(pick[0]) + '&B=' + encodeURIComponent(pick[1]);
window.location.href = url;
} else {
if (window.toast) window.toast('Need at least two decks');
}
} catch(_){ }
});
// Open permalink prompt
if (openPermalinkBtn) openPermalinkBtn.addEventListener('click', function(){
try{
var token = prompt('Paste a /build/from?state=... URL or token:');
if(!token) return;
var m = token.match(/state=([^&]+)/);
var t = m ? m[1] : token.trim();
if(!t) return;
window.location.href = '/build/from?state=' + encodeURIComponent(t);
}catch(_){ }
});
if (resetAllBtn) resetAllBtn.addEventListener('click', function(){
// Clear UI state
try {
@ -408,6 +504,10 @@
// React to external hash changes
window.addEventListener('hashchange', function(){ applyStateFromHash(); });
// Re-attach selection handlers when list changes order
var observer = new MutationObserver(function(){ attachSelectHandlers(); });
try { observer.observe(list, { childList: true }); } catch(_){ }
// Open deck: keyboard and mouse helpers on panels
function getPanelUrl(p){
try {
@ -551,5 +651,6 @@
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; }
#deck-list .panel.selected { box-shadow: 0 0 0 2px #10b981 inset; border-color: #10b981; }
</style>
{% endblock %}

View file

@ -2,6 +2,9 @@
{% block banner_subtitle %}Finished Decks{% endblock %}
{% block content %}
<h2>Finished Deck</h2>
{% if display_name %}
<div><strong>{{ display_name }}</strong></div>
{% endif %}
<div class="muted">Commander: <strong data-card-name="{{ commander }}">{{ commander }}</strong>{% if tags and tags|length %} • Themes: {{ tags|join(', ') }}{% endif %}</div>
<div class="muted">This view mirrors the end-of-build summary. Use the buttons to download the CSV/TXT exports.</div>
@ -24,6 +27,7 @@
<button type="submit">Download TXT</button>
</form>
{% endif %}
<a href="/decks/compare?A={{ name|urlencode }}" class="btn" role="button" title="Compare this deck with another">Compare…</a>
<form method="get" action="/decks" style="display:inline; margin:0;">
<button type="submit">Back to Finished Decks</button>
</form>
@ -54,7 +58,7 @@
</div>
</div>
{% endif %}
{% include "partials/deck_summary.html" %}
{{ render_cached('partials/deck_summary.html', name, request=request, summary=summary, game_changers=game_changers, owned_set=owned_set) | safe }}
{% else %}
<div class="muted">No summary available.</div>
{% endif %}