mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-18 00:20:13 +01:00
feat: locks/replace/compare/permalinks; perf: virtualization, LQIP, caching, diagnostics; add tests, docs, and issue/PR templates (flags OFF)
This commit is contained in:
parent
f8c6b5c07e
commit
721e1884af
41 changed files with 2960 additions and 143 deletions
|
|
@ -6,18 +6,23 @@
|
|||
<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?v=20250826-4" />
|
||||
<!-- Performance hints -->
|
||||
<link rel="preconnect" href="https://api.scryfall.com" crossorigin>
|
||||
<link rel="dns-prefetch" href="https://api.scryfall.com">
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/png" href="/static/favicon.png" />
|
||||
<link rel="shortcut icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/static/favicon.png" />
|
||||
</head>
|
||||
<body>
|
||||
<body data-diag="{% if show_diagnostics %}1{% else %}0{% endif %}" data-virt="{% if virtualize %}1{% else %}0{% endif %}">
|
||||
<header class="top-banner">
|
||||
<div class="top-inner">
|
||||
<h1>MTG Deckbuilder</h1>
|
||||
<div style="display:flex; align-items:center; gap:.5rem">
|
||||
<span id="health-dot" class="health-dot" title="Health"></span>
|
||||
<div id="banner-status" class="banner-status">{% block banner_subtitle %}{% endblock %}</div>
|
||||
<button type="button" class="btn" style="margin-left:.5rem;" title="Open a saved permalink"
|
||||
onclick="(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(_){}})()">Open Permalink…</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
|
@ -261,7 +266,7 @@
|
|||
document.addEventListener('htmx:afterSwap', function() { attachCardHover(); bindAllCardImageRetries(); });
|
||||
})();
|
||||
</script>
|
||||
<script src="/static/app.js?v=20250826-2"></script>
|
||||
<script src="/static/app.js?v=20250826-4"></script>
|
||||
<script>
|
||||
// Show pending toast after full page reloads when actions replace the whole document
|
||||
(function(){
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
<div id="banner-status" hx-swap-oob="true">{% if step %}<span class="muted">{{ step }}{% if i is not none and n is not none %} ({{ i }}/{{ n }}){% endif %}</span>: {% endif %}{% if commander %}<strong>{{ commander }}</strong>{% endif %}{% if tags and tags|length > 0 %} - {{ tags|join(', ') }}{% endif %}</div>
|
||||
<div id="banner-status" hx-swap-oob="true">{% if name %}<strong>{{ name }}</strong>{% elif commander %}<strong>{{ commander }}</strong>{% endif %}{% if tags and tags|length > 0 %} — {{ tags|join(', ') }}{% endif %}</div>
|
||||
|
|
|
|||
18
code/web/templates/build/_new_deck_candidates.html
Normal file
18
code/web/templates/build/_new_deck_candidates.html
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{% if candidates and candidates|length %}
|
||||
<ul style="list-style:none; padding:0; margin:.35rem 0; display:grid; gap:.25rem;" role="listbox" aria-label="Commander suggestions" tabindex="-1">
|
||||
{% for name, score, colors in candidates %}
|
||||
<li>
|
||||
<button type="button" id="cand-{{ loop.index0 }}" class="chip candidate-btn" role="option" data-idx="{{ loop.index0 }}" data-name="{{ name|e }}"
|
||||
hx-get="/build/new/inspect?name={{ name|urlencode }}"
|
||||
hx-target="#newdeck-tags-slot" hx-swap="innerHTML"
|
||||
hx-on="htmx:afterOnLoad: (function(){ try{ var n=this.getAttribute('data-name')||''; var ci = document.querySelector('input[name=commander]'); if(ci){ ci.value=n; try{ ci.selectionStart = ci.selectionEnd = ci.value.length; }catch(_){} } var nm = document.querySelector('input[name=name]'); if(nm && (!nm.value || !nm.value.trim())){ nm.value=n; } }catch(_){ } }).call(this)">
|
||||
{{ name }}
|
||||
</button>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
{% if query %}
|
||||
<div class="muted">No matches for “{{ query }}”.</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
149
code/web/templates/build/_new_deck_modal.html
Normal file
149
code/web/templates/build/_new_deck_modal.html
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="newDeckTitle" style="position:fixed; inset:0; z-index:1000; display:flex; align-items:center; justify-content:center;">
|
||||
<div class="modal-backdrop" style="position:absolute; inset:0; background:rgba(0,0,0,.6);"></div>
|
||||
<div class="modal-content" style="position:relative; max-width:720px; width:clamp(320px, 90vw, 720px); background:#0f1115; border:1px solid var(--border); border-radius:10px; box-shadow:0 10px 30px rgba(0,0,0,.5); padding:1rem;">
|
||||
<div class="modal-header">
|
||||
<h3 id="newDeckTitle">Build a New Deck</h3>
|
||||
</div>
|
||||
{% if error %}
|
||||
<div class="error" role="alert" style="margin:.35rem 0 .5rem 0;">{{ error }}</div>
|
||||
{% endif %}
|
||||
<form hx-post="/build/new" hx-target="#wizard" hx-swap="innerHTML" hx-on="htmx:afterRequest: (function(evt){ try{ if(evt && evt.detail && evt.detail.elt === this){ var m=this.closest('.modal'); if(m){ m.remove(); } } }catch(_){} }).call(this, event)" autocomplete="off">
|
||||
<fieldset>
|
||||
<legend>Basics</legend>
|
||||
<div class="basics-grid" style="display:grid; grid-template-columns: 2fr 1fr; gap:1rem; align-items:start;">
|
||||
<div>
|
||||
<label style="display:block; margin-bottom:.5rem;">
|
||||
<span class="muted">Optional name (used for file names)</span>
|
||||
<input type="text" name="name" placeholder="e.g., Inti Discard Tempo" autocomplete="off" autocapitalize="off" spellcheck="false" />
|
||||
</label>
|
||||
<label style="display:block; margin-bottom:.5rem;">
|
||||
<span>Commander</span>
|
||||
<input type="text" name="commander" required placeholder="Type a commander name" value="{{ form.commander if form else '' }}" autofocus autocomplete="off" autocapitalize="off" spellcheck="false"
|
||||
role="combobox" aria-autocomplete="list" aria-controls="newdeck-candidates"
|
||||
hx-get="/build/new/candidates" hx-trigger="input changed delay:150ms" hx-target="#newdeck-candidates" hx-sync="this:replace" />
|
||||
</label>
|
||||
<small class="muted" style="display:block; margin-top:.25rem;">Start typing to see matches, then select one to load themes.</small>
|
||||
<div id="newdeck-candidates" class="muted" style="font-size:12px; min-height:1.1em;"></div>
|
||||
</div>
|
||||
<div id="newdeck-commander-slot" class="muted" style="max-width:230px;">
|
||||
<em style="font-size:12px;">Pick a commander to preview here.</em>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>Themes</legend>
|
||||
<div id="newdeck-tags-slot" class="muted">
|
||||
<em>Select a commander to see theme recommendations and choices.</em>
|
||||
<input type="hidden" name="primary_tag" />
|
||||
<input type="hidden" name="secondary_tag" />
|
||||
<input type="hidden" name="tertiary_tag" />
|
||||
<input type="hidden" name="tag_mode" value="AND" />
|
||||
</div>
|
||||
<div style="margin-top:.5rem;">
|
||||
<label>Bracket
|
||||
<select name="bracket">
|
||||
{% for b in brackets %}
|
||||
<option value="{{ b.level }}" {% if (form and form.bracket and form.bracket == b.level) or (not form and b.level == 3) %}selected{% endif %}>Bracket {{ b.level }}: {{ b.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
<details style="margin-top:.5rem;">
|
||||
<summary>Advanced options (ideals)</summary>
|
||||
<div style="display:grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap:.5rem; margin-top:.5rem;">
|
||||
{% for key, label in labels.items() %}
|
||||
<label>{{ label }}
|
||||
<input type="number" name="{{ key }}" value="{{ defaults[key] }}" min="0" />
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</details>
|
||||
<div class="modal-footer" style="display:flex; gap:.5rem; justify-content:flex-end; margin-top:1rem;">
|
||||
<button type="button" class="btn" onclick="this.closest('.modal').remove()">Cancel</button>
|
||||
<button type="submit" class="btn-continue">Create</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
var modal = document.currentScript && document.currentScript.previousElementSibling ? document.currentScript.previousElementSibling.previousElementSibling : document.querySelector('.modal');
|
||||
// Prevent Enter in text inputs from submitting the form
|
||||
try {
|
||||
var form = modal ? modal.querySelector('form') : document.querySelector('.modal form');
|
||||
if (form){
|
||||
// Prevent Enter in name field from submitting
|
||||
var nameEl = form.querySelector('input[name="name"]');
|
||||
if (nameEl){ nameEl.addEventListener('keydown', function(e){ if (e.key === 'Enter'){ e.preventDefault(); } }); }
|
||||
// In commander field, Enter picks the first candidate (if any) without closing the modal
|
||||
var cmdEl = form.querySelector('input[name=\"commander\"]');
|
||||
if (cmdEl){
|
||||
function handleEnterNav(e){
|
||||
// Enter selects the highlighted (or first) suggestion
|
||||
var list = document.getElementById('newdeck-candidates');
|
||||
var btns = list ? Array.prototype.slice.call(list.querySelectorAll('button.candidate-btn')) : [];
|
||||
var getActiveIndex = function(){ return btns.findIndex(function(b){ return b.classList.contains('active'); }); };
|
||||
if (!btns.length) return; // nothing to do, but we've already prevented default
|
||||
// Skip if a request is in-flight to avoid fighting with swap timing
|
||||
try{ if (cmdEl.matches('.htmx-request') || list.matches('.htmx-request')) return; }catch(_){ }
|
||||
if (e.key === 'Enter'){
|
||||
var idx = getActiveIndex();
|
||||
var target = btns[(idx >= 0 ? idx : 0)];
|
||||
if (target) { target.click(); }
|
||||
}
|
||||
}
|
||||
// Capture keydown early to prevent submit on Enter (arrows left to default behavior)
|
||||
cmdEl.addEventListener('keydown', function(e){
|
||||
if (e.key === 'Enter'){
|
||||
var list = document.getElementById('newdeck-candidates');
|
||||
var hasBtns = !!(list && list.querySelector('button.candidate-btn'));
|
||||
if (hasBtns){
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleEnterNav(e);
|
||||
}
|
||||
}
|
||||
}, true);
|
||||
// Defensive: also block Enter on keyup (in case a browser tries to submit on keyup)
|
||||
cmdEl.addEventListener('keyup', function(e){ if (e.key === 'Enter'){ e.preventDefault(); e.stopPropagation(); } });
|
||||
// Global fallback: capture keydown at the document level so Enter never slips through when the commander input is focused
|
||||
document.addEventListener('keydown', function(e){
|
||||
try{
|
||||
if (document.activeElement !== cmdEl) return;
|
||||
if (e.key !== 'Enter') return;
|
||||
var list = document.getElementById('newdeck-candidates');
|
||||
var hasBtns = !!(list && list.querySelector('button.candidate-btn'));
|
||||
if (!hasBtns) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleEnterNav(e);
|
||||
}catch(_){ }
|
||||
}, true);
|
||||
// Reset candidate highlight when the list updates
|
||||
document.body.addEventListener('htmx:afterSwap', function(ev){
|
||||
try {
|
||||
var tgt = ev && ev.detail && ev.detail.target ? ev.detail.target : null;
|
||||
if (!tgt) return;
|
||||
if (tgt.id === 'newdeck-candidates'){
|
||||
var first = tgt.querySelector('button.candidate-btn');
|
||||
if (first){
|
||||
// Clear any lingering active classes, then set the first as active for immediate Enter selection
|
||||
tgt.querySelectorAll('button.candidate-btn').forEach(function(b){ b.classList.remove('active'); b.setAttribute('aria-selected','false'); });
|
||||
first.classList.add('active');
|
||||
first.setAttribute('aria-selected','true');
|
||||
try{ cmdEl.setAttribute('aria-activedescendant', first.id || ''); }catch(_){ }
|
||||
}
|
||||
}
|
||||
} catch(_){}
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch(_){ }
|
||||
// Close on Escape
|
||||
function closeModal(){ try{ var m = document.querySelector('.modal'); if(m){ m.remove(); document.removeEventListener('keydown', onKey); } }catch(_){} }
|
||||
function onKey(e){ if (e.key === 'Escape'){ e.preventDefault(); closeModal(); } }
|
||||
document.addEventListener('keydown', onKey);
|
||||
})();
|
||||
</script>
|
||||
105
code/web/templates/build/_new_deck_tags.html
Normal file
105
code/web/templates/build/_new_deck_tags.html
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
{% set pname = commander.name %}
|
||||
<div id="newdeck-commander-slot" hx-swap-oob="true" style="max-width:230px;">
|
||||
<aside class="card-preview" data-card-name="{{ pname }}" style="max-width: 230px;">
|
||||
<a href="https://scryfall.com/search?q={{ pname|urlencode }}" target="_blank" rel="noopener">
|
||||
<img src="https://api.scryfall.com/cards/named?fuzzy={{ pname|urlencode }}&format=image&version=normal" alt="{{ pname }} card image" data-card-name="{{ pname }}" style="width:200px; height:auto; display:block; border-radius:6px;" />
|
||||
</a>
|
||||
</aside>
|
||||
<div class="muted" style="font-size:12px; margin-top:.25rem; max-width: 230px;">{{ pname }}</div>
|
||||
<script>
|
||||
try {
|
||||
var nm = document.querySelector('input[name="name"]');
|
||||
var value = document.querySelector('#newdeck-commander-slot [data-card-name]')?.getAttribute('data-card-name') || '{{ pname|e }}';
|
||||
if (nm && (!nm.value || !nm.value.trim())) { nm.value = value; }
|
||||
} catch(_) {}
|
||||
</script>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{% if tags and tags|length %}
|
||||
<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">
|
||||
<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" checked /> AND
|
||||
</label>
|
||||
<label title="OR treats your themes as a union (broader pool, fills easier).">
|
||||
<input type="radio" name="combine_mode_radio" value="OR" /> OR
|
||||
</label>
|
||||
</div>
|
||||
<button type="button" id="modal-reset-tags" class="chip" style="margin-left:.35rem;">Reset themes</button>
|
||||
<span id="modal-tag-count" class="muted" style="font-size:12px;"></span>
|
||||
</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>
|
||||
</div>
|
||||
<div id="modal-tag-reco" aria-label="Recommended themes" style="display:flex; gap:.35rem; flex-wrap:wrap; margin-bottom:.5rem;">
|
||||
{% for r in recommended %}
|
||||
{% 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" data-tag="{{ r }}" title="{{ tip }}">★ {{ r }}</button>
|
||||
{% endfor %}
|
||||
<button type="button" id="modal-reco-select-all" class="chip" title="Add recommended up to 3">Select all</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div id="modal-tag-list" aria-label="Available themes" style="display:flex; gap:.35rem; flex-wrap:wrap;">
|
||||
{% for t in tags %}
|
||||
<button type="button" class="chip" data-tag="{{ t }}">{{ t }}</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="muted">No theme tags available for this commander.</p>
|
||||
{% endif %}
|
||||
<!-- hidden inputs that the main modal form will submit -->
|
||||
<input type="hidden" name="primary_tag" id="modal_primary_tag" />
|
||||
<input type="hidden" name="secondary_tag" id="modal_secondary_tag" />
|
||||
<input type="hidden" name="tertiary_tag" id="modal_tertiary_tag" />
|
||||
<input type="hidden" name="tag_mode" id="modal_tag_mode" value="AND" />
|
||||
|
||||
<div id="modal-selected-themes" class="muted" style="font-size:12px; margin-top:.5rem;">
|
||||
<em>No themes selected yet.</em>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
var list = document.getElementById('modal-tag-list');
|
||||
var reco = document.getElementById('modal-tag-reco');
|
||||
var selAll = document.getElementById('modal-reco-select-all');
|
||||
var resetBtn = document.getElementById('modal-reset-tags');
|
||||
var p = document.getElementById('modal_primary_tag');
|
||||
var s = document.getElementById('modal_secondary_tag');
|
||||
var t = document.getElementById('modal_tertiary_tag');
|
||||
var mode = document.getElementById('modal_tag_mode');
|
||||
var countEl = document.getElementById('modal-tag-count');
|
||||
var selSummary = document.getElementById('modal-selected-themes');
|
||||
if (!list) return;
|
||||
|
||||
function getSel(){ var a=[]; if(p&&p.value)a.push(p.value); if(s&&s.value)a.push(s.value); if(t&&t.value)a.push(t.value); return a; }
|
||||
function setSel(a){ a = Array.from(new Set(a||[])).filter(Boolean).slice(0,3); if(p) p.value=a[0]||''; if(s) s.value=a[1]||''; if(t) t.value=a[2]||''; updateUI(); }
|
||||
function toggle(tag){ var cur=getSel(); var i=cur.indexOf(tag); if(i>=0){cur.splice(i,1);} else { if(cur.length>=3){cur=cur.slice(1);} cur.push(tag);} setSel(cur); }
|
||||
function updateUI(){
|
||||
try{ if(countEl) countEl.textContent = getSel().length + ' / 3 selected'; }catch(_){ }
|
||||
try{
|
||||
if(selSummary){
|
||||
var sel = getSel();
|
||||
if(!sel.length){ selSummary.innerHTML = '<em>No themes selected yet.</em>'; }
|
||||
else {
|
||||
var parts = [];
|
||||
sel.forEach(function(tag, idx){ parts.push((idx+1) + '. ' + tag); });
|
||||
selSummary.textContent = 'Selected: ' + parts.join(' · ');
|
||||
}
|
||||
}
|
||||
}catch(_){ }
|
||||
function apply(container){ if(!container) return; var chips = container.querySelectorAll('button.chip'); chips.forEach(function(btn){ var tag=btn.dataset.tag||''; var active=getSel().indexOf(tag)>=0; btn.classList.toggle('active', active); btn.setAttribute('aria-pressed', active?'true':'false'); }); }
|
||||
apply(list); apply(reco);
|
||||
}
|
||||
if (resetBtn) resetBtn.addEventListener('click', function(){ setSel([]); });
|
||||
list.querySelectorAll('button.chip').forEach(function(btn){ var tag=btn.dataset.tag||''; btn.addEventListener('click', function(){ toggle(tag); }); });
|
||||
if (reco){ reco.querySelectorAll('button.chip-reco').forEach(function(btn){ var tag=btn.dataset.tag||''; btn.addEventListener('click', function(){ toggle(tag); }); }); }
|
||||
if (selAll){ selAll.addEventListener('click', function(){ try{ var cur=getSel(); var recs = reco? Array.from(reco.querySelectorAll('button.chip-reco')).map(function(b){return b.dataset.tag||'';}).filter(Boolean):[]; var combined=cur.slice(); recs.forEach(function(x){ if(combined.indexOf(x)===-1) combined.push(x); }); setSel(combined.slice(-3)); }catch(_){} }); }
|
||||
document.querySelectorAll('input[name="combine_mode_radio"]').forEach(function(r){ r.addEventListener('change', function(){ if(mode){ mode.value = r.value; } }); });
|
||||
updateUI();
|
||||
})();
|
||||
</script>
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
<section>
|
||||
{% set step_index = 2 %}{% set step_total = 5 %}
|
||||
<h3>Step 2: Tags & Bracket</h3>
|
||||
{# Step phases removed #}
|
||||
<div class="two-col two-col-left-rail">
|
||||
<aside class="card-preview" data-card-name="{{ commander.name }}">
|
||||
<a href="https://scryfall.com/search?q={{ commander.name|urlencode }}" target="_blank" rel="noopener">
|
||||
|
|
@ -8,8 +7,7 @@
|
|||
</a>
|
||||
</aside>
|
||||
<div class="grow" data-skeleton>
|
||||
{% include "build/_stage_navigator.html" %}
|
||||
<div hx-get="/build/banner?step=Tags%20%26%20Bracket&i=2&n=5" hx-trigger="load"></div>
|
||||
<div hx-get="/build/banner" hx-trigger="load"></div>
|
||||
|
||||
<form hx-post="/build/step2" hx-target="#wizard" hx-swap="innerHTML">
|
||||
<input type="hidden" name="commander" value="{{ commander.name }}" />
|
||||
|
|
@ -95,7 +93,7 @@
|
|||
</form>
|
||||
|
||||
<div style="margin-top:.5rem;">
|
||||
<form action="/build" method="get" style="display:inline; margin:0;">
|
||||
<form hx-post="/build/reset-all" hx-target="#wizard" hx-swap="innerHTML" style="display:inline; margin:0;">
|
||||
<button type="submit">Start over</button>
|
||||
</form>
|
||||
</div>
|
||||
|
|
@ -116,6 +114,7 @@
|
|||
var countEl = document.getElementById('tag-count');
|
||||
var orderEl = document.getElementById('tag-order');
|
||||
var commander = '{{ commander.name|e }}';
|
||||
var clearPersisted = '{{ (clear_persisted|default(false)) and "1" or "0" }}' === '1';
|
||||
if (!chipHost) return;
|
||||
|
||||
function storageKey(suffix){ return 'step2-' + (commander || 'unknown') + '-' + suffix; }
|
||||
|
|
@ -158,6 +157,13 @@
|
|||
}
|
||||
function loadPersisted(){
|
||||
try {
|
||||
// If this page load follows a fresh commander confirmation, wipe persisted values.
|
||||
if (clearPersisted){
|
||||
try {
|
||||
localStorage.removeItem(storageKey('tags'));
|
||||
localStorage.removeItem(storageKey('mode'));
|
||||
} catch(_){ }
|
||||
}
|
||||
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); }
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
<section>
|
||||
{% set step_index = 3 %}{% set step_total = 5 %}
|
||||
<h3>Step 3: Ideal Counts</h3>
|
||||
{# Step phases removed #}
|
||||
<div class="two-col two-col-left-rail">
|
||||
<aside class="card-preview" data-card-name="{{ commander|urlencode }}">
|
||||
<a href="https://scryfall.com/search?q={{ commander|urlencode }}" target="_blank" rel="noopener">
|
||||
|
|
@ -8,8 +7,7 @@
|
|||
</a>
|
||||
</aside>
|
||||
<div class="grow" data-skeleton>
|
||||
<div hx-get="/build/banner?step=Ideal%20Counts&i=3&n=5" hx-trigger="load"></div>
|
||||
{% include "build/_stage_navigator.html" %}
|
||||
<div hx-get="/build/banner" hx-trigger="load"></div>
|
||||
|
||||
|
||||
|
||||
|
|
@ -37,7 +35,7 @@
|
|||
</div>
|
||||
</form>
|
||||
<div style="margin-top:.5rem;">
|
||||
<form action="/build" method="get" style="display:inline; margin:0;">
|
||||
<form hx-post="/build/reset-all" hx-target="#wizard" hx-swap="innerHTML" style="display:inline; margin:0;">
|
||||
<button type="submit">Start over</button>
|
||||
</form>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
<section>
|
||||
{% set step_index = 4 %}{% set step_total = 5 %}
|
||||
<h3>Step 4: Review</h3>
|
||||
{# Step phases removed #}
|
||||
<div class="two-col two-col-left-rail">
|
||||
<aside class="card-preview" data-card-name="{{ commander|urlencode }}">
|
||||
<a href="https://scryfall.com/search?q={{ commander|urlencode }}" target="_blank" rel="noopener">
|
||||
|
|
@ -8,8 +7,12 @@
|
|||
</a>
|
||||
</aside>
|
||||
<div class="grow" data-skeleton>
|
||||
<div hx-get="/build/banner?step=Review&i=4&n=5" hx-trigger="load"></div>
|
||||
{% include "build/_stage_navigator.html" %}
|
||||
<div hx-get="/build/banner" hx-trigger="load"></div>
|
||||
{% if locks_restored and locks_restored > 0 %}
|
||||
<div class="muted" style="margin:.35rem 0;">
|
||||
<span class="chip" title="Locks restored from permalink">🔒 {{ locks_restored }} locks restored</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<h4>Chosen Ideals</h4>
|
||||
<ul>
|
||||
{% for key, label in labels.items() %}
|
||||
|
|
@ -27,12 +30,13 @@
|
|||
</label>
|
||||
<a href="/owned" target="_blank" rel="noopener" class="btn">Manage Owned Library</a>
|
||||
</form>
|
||||
<div class="muted" style="font-size:12px; margin-top:-.25rem;">Tip: Locked cards are respected on reruns in Step 5.</div>
|
||||
<div style="margin-top:1rem; display:flex; gap:.5rem;">
|
||||
<form action="/build/step5/start" method="post" hx-post="/build/step5/start" hx-target="#wizard" hx-swap="innerHTML" style="display:inline; margin:0;">
|
||||
<button type="submit" class="btn-continue" data-action="continue">Build Deck</button>
|
||||
</form>
|
||||
<button type="button" class="btn-back" data-action="back" hx-get="/build/step3" hx-target="#wizard" hx-swap="innerHTML">Back</button>
|
||||
<form action="/build" method="get" style="display:inline; margin:0;">
|
||||
<form hx-post="/build/reset-all" hx-target="#wizard" hx-swap="innerHTML" style="display:inline; margin:0;">
|
||||
<button type="submit">Start over</button>
|
||||
</form>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
<section>
|
||||
{% set step_index = 5 %}{% set step_total = 5 %}
|
||||
<h3>Step 5: Build</h3>
|
||||
{# Step phases removed #}
|
||||
<div class="two-col two-col-left-rail">
|
||||
<aside class="card-preview">
|
||||
<a href="https://scryfall.com/search?q={{ commander|urlencode }}" target="_blank" rel="noopener">
|
||||
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=normal" alt="{{ commander }} card image" data-card-name="{{ commander }}" />
|
||||
<a href="https://scryfall.com/search?q={{ commander|urlencode }}" target="_blank" rel="noopener">
|
||||
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=normal" alt="{{ commander }} card image" data-card-name="{{ commander }}" loading="lazy" decoding="async" data-lqip="1"
|
||||
srcset="https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=small 160w, https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=normal 488w, https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=large 672w"
|
||||
sizes="(max-width: 900px) 100vw, 320px" />
|
||||
</a>
|
||||
{% if status and status.startswith('Build complete') %}
|
||||
<div style="margin-top:.75rem; display:flex; gap:.35rem; flex-wrap:wrap;">
|
||||
|
|
@ -24,8 +25,7 @@
|
|||
{% endif %}
|
||||
</aside>
|
||||
<div class="grow" data-skeleton>
|
||||
<div hx-get="/build/banner?step=Build&i=5&n=5" hx-trigger="load"></div>
|
||||
{% include "build/_stage_navigator.html" %}
|
||||
<div hx-get="/build/banner" hx-trigger="load"></div>
|
||||
|
||||
<p>Commander: <strong>{{ commander }}</strong></p>
|
||||
<p>Tags: {{ tags|default([])|join(', ') }}</p>
|
||||
|
|
@ -39,7 +39,7 @@
|
|||
</div>
|
||||
<p>Bracket: {{ bracket }}</p>
|
||||
|
||||
<div style="display:flex; align-items:center; gap:.5rem; flex-wrap:wrap; margin:.25rem 0 .5rem 0;">
|
||||
<div style="display:flex; align-items:center; gap:.5rem; flex-wrap:wrap; margin:.25rem 0 .5rem 0;">
|
||||
{% if i and n %}
|
||||
<span class="chip"><span class="dot"></span> Stage {{ i }}/{{ n }}</span>
|
||||
{% endif %}
|
||||
|
|
@ -48,6 +48,10 @@
|
|||
{% if added_total is not none %}
|
||||
<span class="chip"><span class="dot" style="background: var(--blue-main);"></span> Added {{ added_total }}</span>
|
||||
{% endif %}
|
||||
<span id="locks-chip">{% if locks and locks|length > 0 %}<span class="chip" title="Locked cards">🔒 {{ locks|length }} locked</span>{% endif %}</span>
|
||||
<button type="button" class="btn" style="margin-left:auto;" title="Copy permalink"
|
||||
onclick="(async()=>{try{const r=await fetch('/build/permalink');const j=await r.json();const url=(j.permalink?location.origin+j.permalink:location.href+'#'+btoa(JSON.stringify(j.state||{}))); await navigator.clipboard.writeText(url); toast && toast('Permalink copied');}catch(e){alert('Copied state to console'); console.log(e);}})()">Copy Permalink</button>
|
||||
<button type="button" class="btn" title="Open a saved permalink" onclick="(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(_){}})()">Open Permalink…</button>
|
||||
</div>
|
||||
{% set pct = ((deck_count / 100.0) * 100.0) if deck_count else 0 %}
|
||||
{% set pct_clamped = (pct if pct <= 100 else 100) %}
|
||||
|
|
@ -62,7 +66,31 @@
|
|||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Filters toolbar -->
|
||||
{% if locked_cards is defined and locked_cards %}
|
||||
<details id="locked-section" style="margin-top:.5rem;">
|
||||
<summary>Locked cards (always kept)</summary>
|
||||
<ul id="locked-list" style="list-style:none; padding:0; margin:.35rem 0 0; display:grid; gap:.35rem;">
|
||||
{% for lk in locked_cards %}
|
||||
<li style="display:flex; align-items:center; gap:.5rem; flex-wrap:wrap;">
|
||||
<span class="chip"><span class="dot"></span> {{ lk.name }}</span>
|
||||
<span class="muted">{% if lk.owned %}✔ Owned{% else %}✖ Not owned{% endif %}</span>
|
||||
{% if lk.in_deck %}<span class="muted">• In deck</span>{% else %}<span class="muted">• Will be included on rerun</span>{% endif %}
|
||||
<form hx-post="/build/lock" hx-target="closest li" hx-swap="outerHTML" onsubmit="try{toast('Unlocked {{ lk.name }}');}catch(_){}" style="display:inline; margin-left:auto;">
|
||||
<input type="hidden" name="name" value="{{ lk.name }}" />
|
||||
<input type="hidden" name="locked" value="0" />
|
||||
<input type="hidden" name="from_list" value="1" />
|
||||
<button type="submit" class="btn" title="Unlock" aria-pressed="true">Unlock</button>
|
||||
</form>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</details>
|
||||
{% endif %}
|
||||
|
||||
<!-- Last action chip (oob-updated) -->
|
||||
<div id="last-action" aria-live="polite" style="margin:.25rem 0; min-height:1.5rem;"></div>
|
||||
|
||||
<!-- Filters toolbar -->
|
||||
<div class="cards-toolbar">
|
||||
<input type="text" name="filter_query" placeholder="Filter by name, role, or tag" data-pref="cards:filter_q" />
|
||||
<select name="filter_owned" data-pref="cards:owned">
|
||||
|
|
@ -92,11 +120,11 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sticky build controls on mobile -->
|
||||
<!-- Sticky build controls on mobile -->
|
||||
<div class="build-controls" style="position:sticky; top:0; z-index:5; background:linear-gradient(180deg, rgba(15,17,21,.95), rgba(15,17,21,.85)); border:1px solid var(--border); border-radius:10px; padding:.5rem; margin-top:1rem; display:flex; gap:.5rem; flex-wrap:wrap; align-items:center;">
|
||||
<form hx-post="/build/step5/start" hx-target="#wizard" hx-swap="innerHTML" style="display:inline; margin-right:.5rem; display:flex; align-items:center; gap:.5rem;" onsubmit="try{ toast('Starting build…'); }catch(_){}">
|
||||
<form hx-post="/build/step5/start" hx-target="#wizard" hx-swap="innerHTML" style="display:inline; margin-right:.5rem; display:flex; align-items:center; gap:.5rem;" onsubmit="try{ toast('Restarting build…'); }catch(_){}">
|
||||
<input type="hidden" name="show_skipped" value="{{ '1' if show_skipped else '0' }}" />
|
||||
<button type="submit" class="btn-continue" data-action="continue">Start Build</button>
|
||||
<button type="submit" class="btn-continue" data-action="continue">Restart Build</button>
|
||||
</form>
|
||||
<form hx-post="/build/step5/continue" hx-target="#wizard" hx-swap="innerHTML" style="display:inline; display:flex; align-items:center; gap:.5rem;" onsubmit="try{ toast('Continuing…'); }catch(_){}">
|
||||
<input type="hidden" name="show_skipped" value="{{ '1' if show_skipped else '0' }}" />
|
||||
|
|
@ -106,6 +134,23 @@
|
|||
<input type="hidden" name="show_skipped" value="{{ '1' if show_skipped else '0' }}" />
|
||||
<button type="submit" class="btn-rerun" data-action="rerun" {% if status and status.startswith('Build complete') %}disabled{% endif %}>Rerun Stage</button>
|
||||
</form>
|
||||
<span class="sep"></span>
|
||||
<div class="replace-toggle" role="group" aria-label="Replace toggle">
|
||||
<form hx-post="/build/step5/toggle-replace" hx-target="closest .replace-toggle" hx-swap="outerHTML" onsubmit="return false;" style="display:inline;">
|
||||
<input type="hidden" name="replace" value="{{ '1' if replace_mode else '0' }}" />
|
||||
<label class="muted" style="display:flex; align-items:center; gap:.35rem;" title="When enabled, reruns of this stage will replace its picks with alternatives instead of keeping them.">
|
||||
<input type="checkbox" name="replace_chk" value="1" {% if replace_mode %}checked{% endif %}
|
||||
onchange="try{ const f=this.form; const h=f.querySelector('input[name=replace]'); if(h){ h.value=this.checked?'1':'0'; } f.requestSubmit(); }catch(_){ }" />
|
||||
Replace stage picks
|
||||
</label>
|
||||
</form>
|
||||
</div>
|
||||
<form hx-post="/build/step5/reset-stage" hx-target="#wizard" hx-swap="innerHTML" style="display:inline; display:flex; align-items:center; gap:.5rem;">
|
||||
<button type="submit" class="btn" title="Reset this stage to pre-stage picks">Reset stage</button>
|
||||
</form>
|
||||
<form hx-post="/build/reset-all" hx-target="#wizard" hx-swap="innerHTML" style="display:inline; display:flex; align-items:center; gap:.5rem;">
|
||||
<button type="submit" class="btn" title="Start a brand new build (clears selections)">New build</button>
|
||||
</form>
|
||||
<label class="muted" style="display:flex; align-items:center; gap:.35rem; margin-left: .5rem;">
|
||||
<input type="checkbox" name="__toggle_show_skipped" data-pref="build:show_skipped" {% if show_skipped %}checked{% endif %}
|
||||
onchange="const val=this.checked?'1':'0'; for(const f of this.closest('section').querySelectorAll('form')){ const h=f.querySelector('input[name=show_skipped]'); if(h) h.value=val; }" />
|
||||
|
|
@ -115,7 +160,24 @@
|
|||
</div>
|
||||
|
||||
{% if added_cards is not none %}
|
||||
<h4 style="margin-top:1rem;">Cards added this stage</h4>
|
||||
{% if history is defined and history %}
|
||||
<details style="margin-top:.5rem;">
|
||||
<summary>Stage timeline</summary>
|
||||
<div class="muted" style="font-size:12px; margin:.25rem 0 .35rem 0;">Jump back to a previous stage, then you can continue forward again.</div>
|
||||
<ul style="list-style:none; padding:0; margin:0; display:grid; gap:.25rem;">
|
||||
{% for h in history %}
|
||||
<li style="display:flex; align-items:center; gap:.5rem;">
|
||||
<span class="chip"><span class="dot"></span> {{ h.label }}</span>
|
||||
<form hx-post="/build/step5/rewind" hx-target="#wizard" hx-swap="innerHTML" style="display:inline; margin:0;">
|
||||
<input type="hidden" name="to" value="{{ h.i }}" />
|
||||
<button type="submit" class="btn">Go</button>
|
||||
</form>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</details>
|
||||
{% endif %}
|
||||
<h4 style="margin-top:1rem;">Cards added this stage</h4>
|
||||
{% if skipped and (not added_cards or added_cards|length == 0) %}
|
||||
<div class="muted" style="margin:.25rem 0 .5rem 0;">No cards added in this stage.</div>
|
||||
{% endif %}
|
||||
|
|
@ -127,6 +189,7 @@
|
|||
{% if stage_label and stage_label.startswith('Creatures') %}
|
||||
{% set groups = added_cards|groupby('sub_role') %}
|
||||
{% for g in groups %}
|
||||
{% set group_idx = loop.index0 %}
|
||||
{% set role = g.grouper %}
|
||||
{% if role %}
|
||||
{% set heading = 'Theme: ' + role.title() %}
|
||||
|
|
@ -139,46 +202,81 @@
|
|||
<span class="count">(<span data-count>{{ g.list|length }}</span>)</span>
|
||||
<button type="button" class="toggle" title="Collapse/Expand">Toggle</button>
|
||||
</div>
|
||||
<div class="card-grid group-grid" data-skeleton>
|
||||
{% for c in g.list %}
|
||||
<div class="card-grid group-grid" data-skeleton {% if virtualize %}data-virtualize="1"{% endif %}>
|
||||
{% for c in g.list %}
|
||||
{% set owned = (owned_set is defined and c.name and (c.name|lower in owned_set)) %}
|
||||
<div class="card-tile{% if game_changers and (c.name in game_changers) %} game-changer{% endif %}" data-card-name="{{ c.name }}" data-role="{{ c.role or c.sub_role or '' }}" data-tags="{{ (c.tags|join(', ')) if c.tags else '' }}" data-owned="{{ '1' if owned else '0' }}">
|
||||
<a href="https://scryfall.com/search?q={{ c.name|urlencode }}" target="_blank" rel="noopener">
|
||||
<img src="https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=normal" alt="{{ c.name }} image" width="160" data-card-name="{{ c.name }}" />
|
||||
</a>
|
||||
{% set is_locked = (locks is defined and (c.name|lower in locks)) %}
|
||||
<div class="card-tile{% if game_changers and (c.name in game_changers) %} game-changer{% endif %}{% if is_locked %} locked{% endif %}" data-card-name="{{ c.name }}" data-role="{{ c.role or c.sub_role or '' }}" data-tags="{{ (c.tags|join(', ')) if c.tags else '' }}" data-owned="{{ '1' if owned else '0' }}">
|
||||
<button type="button" class="img-btn" title="{{ 'Unlock this card (kept across reruns)' if is_locked else 'Lock this card (keep across reruns)' }}" aria-pressed="{{ 'true' if is_locked else 'false' }}"
|
||||
hx-post="/build/lock" hx-target="#lock-{{ group_idx }}-{{ loop.index0 }}" hx-swap="innerHTML"
|
||||
hx-vals='{"name": "{{ c.name }}", "locked": "{{ '0' if is_locked else '1' }}"}'
|
||||
hx-on="htmx:afterOnLoad: (function(){try{const tile=this.closest('.card-tile');if(!tile)return;const valsAttr=this.getAttribute('hx-vals')||'{}';const sent=JSON.parse(valsAttr.replace(/"/g,'\"'));const nowLocked=(sent.locked==='1');tile.classList.toggle('locked', nowLocked);const next=(nowLocked?'0':'1');this.setAttribute('hx-vals', JSON.stringify({name: sent.name, locked: next}));}catch(e){}})()">
|
||||
<img class="card-thumb" src="https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=normal" alt="{{ c.name }} image" width="160" data-card-name="{{ c.name }}" loading="lazy" decoding="async" data-lqip="1"
|
||||
srcset="https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=small 160w, https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=normal 488w, https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=large 672w"
|
||||
sizes="160px" />
|
||||
</button>
|
||||
<div class="owned-badge" title="{{ 'Owned' if owned else 'Not owned' }}" aria-label="{{ 'Owned' if owned else 'Not owned' }}">{% if owned %}✔{% else %}✖{% endif %}</div>
|
||||
<div class="name">{{ c.name }}{% if c.count and c.count > 1 %} ×{{ c.count }}{% endif %}</div>
|
||||
<div class="lock-box" id="lock-{{ group_idx }}-{{ loop.index0 }}" style="display:flex; justify-content:center; gap:.25rem; margin-top:.25rem;">
|
||||
<button type="button" class="btn-lock" title="{{ 'Unlock this card (kept across reruns)' if is_locked else 'Lock this card (keep across reruns)' }}" aria-pressed="{{ 'true' if is_locked else 'false' }}"
|
||||
hx-post="/build/lock" hx-target="closest .lock-box" hx-swap="innerHTML"
|
||||
hx-vals='{"name": "{{ c.name }}", "locked": "{{ '0' if is_locked else '1' }}"}'>{{ '🔒 Unlock' if is_locked else '🔓 Lock' }}</button>
|
||||
</div>
|
||||
{% if c.reason %}
|
||||
<div style="display:flex; justify-content:center; margin-top:.25rem;">
|
||||
<div style="display:flex; justify-content:center; margin-top:.25rem; gap:.35rem; flex-wrap:wrap;">
|
||||
<button type="button" class="btn-why" aria-expanded="false">Why?</button>
|
||||
<button type="button" class="btn" hx-get="/build/alternatives" hx-vals='{"name": "{{ c.name }}"}' hx-target="#alts-{{ group_idx }}-{{ loop.index0 }}" hx-swap="innerHTML" title="Suggest alternatives">Alternatives</button>
|
||||
</div>
|
||||
<div class="reason" role="region" aria-label="Reason">{{ c.reason }}</div>
|
||||
{% else %}
|
||||
<div style="display:flex; justify-content:center; margin-top:.25rem;">
|
||||
<button type="button" class="btn" hx-get="/build/alternatives" hx-vals='{"name": "{{ c.name }}"}' hx-target="#alts-{{ group_idx }}-{{ loop.index0 }}" hx-swap="innerHTML" title="Suggest alternatives">Alternatives</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div id="alts-{{ group_idx }}-{{ loop.index0 }}" class="alts" style="margin-top:.25rem;"></div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="card-grid" data-skeleton>
|
||||
<div class="card-grid" data-skeleton {% if virtualize %}data-virtualize="1"{% endif %}>
|
||||
{% for c in added_cards %}
|
||||
{% set owned = (owned_set is defined and c.name and (c.name|lower in owned_set)) %}
|
||||
<div class="card-tile{% if game_changers and (c.name in game_changers) %} game-changer{% endif %}" data-card-name="{{ c.name }}" data-role="{{ c.role or c.sub_role or '' }}" data-tags="{{ (c.tags|join(', ')) if c.tags else '' }}" data-owned="{{ '1' if owned else '0' }}">
|
||||
<a href="https://scryfall.com/search?q={{ c.name|urlencode }}" target="_blank" rel="noopener">
|
||||
<img src="https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=normal" alt="{{ c.name }} image" width="160" data-card-name="{{ c.name }}" />
|
||||
</a>
|
||||
{% set is_locked = (locks is defined and (c.name|lower in locks)) %}
|
||||
<div class="card-tile{% if game_changers and (c.name in game_changers) %} game-changer{% endif %}{% if is_locked %} locked{% endif %}" data-card-name="{{ c.name }}" data-role="{{ c.role or c.sub_role or '' }}" data-tags="{{ (c.tags|join(', ')) if c.tags else '' }}" data-owned="{{ '1' if owned else '0' }}">
|
||||
<button type="button" class="img-btn" title="{{ 'Unlock this card (kept across reruns)' if is_locked else 'Lock this card (keep across reruns)' }}" aria-pressed="{{ 'true' if is_locked else 'false' }}"
|
||||
hx-post="/build/lock" hx-target="#lock-{{ loop.index0 }}" hx-swap="innerHTML"
|
||||
hx-vals='{"name": "{{ c.name }}", "locked": "{{ '0' if is_locked else '1' }}"}'
|
||||
hx-on="htmx:afterOnLoad: (function(){try{const tile=this.closest('.card-tile');if(!tile)return;const valsAttr=this.getAttribute('hx-vals')||'{}';const sent=JSON.parse(valsAttr.replace(/"/g,'\"'));const nowLocked=(sent.locked==='1');tile.classList.toggle('locked', nowLocked);const next=(nowLocked?'0':'1');this.setAttribute('hx-vals', JSON.stringify({name: sent.name, locked: next}));}catch(e){}})()">
|
||||
<img class="card-thumb" src="https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=normal" alt="{{ c.name }} image" width="160" data-card-name="{{ c.name }}" loading="lazy" decoding="async" data-lqip="1"
|
||||
srcset="https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=small 160w, https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=normal 488w, https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=large 672w"
|
||||
sizes="160px" />
|
||||
</button>
|
||||
<div class="owned-badge" title="{{ 'Owned' if owned else 'Not owned' }}" aria-label="{{ 'Owned' if owned else 'Not owned' }}">{% if owned %}✔{% else %}✖{% endif %}</div>
|
||||
<div class="name">{{ c.name }}{% if c.count and c.count > 1 %} ×{{ c.count }}{% endif %}</div>
|
||||
<div class="lock-box" id="lock-{{ loop.index0 }}" style="display:flex; justify-content:center; gap:.25rem; margin-top:.25rem;">
|
||||
<button type="button" class="btn-lock" title="{{ 'Unlock this card (kept across reruns)' if is_locked else 'Lock this card (keep across reruns)' }}" aria-pressed="{{ 'true' if is_locked else 'false' }}"
|
||||
hx-post="/build/lock" hx-target="closest .lock-box" hx-swap="innerHTML"
|
||||
hx-vals='{"name": "{{ c.name }}", "locked": "{{ '0' if is_locked else '1' }}"}'>{{ '🔒 Unlock' if is_locked else '🔓 Lock' }}</button>
|
||||
</div>
|
||||
{% if c.reason %}
|
||||
<div style="display:flex; justify-content:center; margin-top:.25rem;">
|
||||
<div style="display:flex; justify-content:center; margin-top:.25rem; gap:.35rem; flex-wrap:wrap;">
|
||||
<button type="button" class="btn-why" aria-expanded="false">Why?</button>
|
||||
<button type="button" class="btn" hx-get="/build/alternatives" hx-vals='{"name": "{{ c.name }}"}' hx-target="#alts-{{ loop.index0 }}" hx-swap="innerHTML" title="Suggest alternatives">Alternatives</button>
|
||||
</div>
|
||||
<div class="reason" role="region" aria-label="Reason">{{ c.reason }}</div>
|
||||
{% else %}
|
||||
<div style="display:flex; justify-content:center; margin-top:.25rem;">
|
||||
<button type="button" class="btn" hx-get="/build/alternatives" hx-vals='{"name": "{{ c.name }}"}' hx-target="#alts-{{ loop.index0 }}" hx-swap="innerHTML" title="Suggest alternatives">Alternatives</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div id="alts-{{ loop.index0 }}" class="alts" style="margin-top:.25rem;"></div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="muted" style="font-size:12px; margin:.35rem 0 .25rem 0;">Tip: Click a card to lock or unlock it. Locked cards are kept across reruns and won’t be replaced unless you unlock them.</div>
|
||||
<div data-empty hidden role="status" aria-live="polite" class="muted" style="margin:.5rem 0 0;">
|
||||
No cards match your filters.
|
||||
</div>
|
||||
|
|
@ -201,3 +299,67 @@
|
|||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<script>
|
||||
// Sync tile class and image-button toggle after lock button swaps
|
||||
document.addEventListener('htmx:afterSwap', function(ev){
|
||||
try{
|
||||
const tgt = ev.target;
|
||||
if(!tgt) return;
|
||||
// Only act for lock-box updates
|
||||
if(!tgt.classList || !tgt.classList.contains('lock-box')) return;
|
||||
const tile = tgt.closest('.card-tile');
|
||||
if(!tile) return;
|
||||
const lockBtn = tgt.querySelector('.btn-lock');
|
||||
if(lockBtn){
|
||||
const isLocked = (lockBtn.getAttribute('data-locked') === '1');
|
||||
tile.classList.toggle('locked', isLocked);
|
||||
const imgBtn = tile.querySelector('.img-btn');
|
||||
if(imgBtn){
|
||||
try{
|
||||
const valsAttr = imgBtn.getAttribute('hx-vals') || '{}';
|
||||
const cur = JSON.parse(valsAttr.replace(/"/g, '"'));
|
||||
const next = isLocked ? '0' : '1';
|
||||
// Keep name stable; fallback to tile data attribute
|
||||
const nm = cur.name || tile.getAttribute('data-card-name') || '';
|
||||
imgBtn.setAttribute('hx-vals', JSON.stringify({ name: nm, locked: next }));
|
||||
imgBtn.title = 'Click to ' + (isLocked ? 'unlock' : 'lock') + ' this card';
|
||||
try { imgBtn.setAttribute('aria-pressed', isLocked ? 'true' : 'false'); } catch(_){ }
|
||||
}catch(_){/* noop */}
|
||||
}
|
||||
}
|
||||
}catch(_){/* noop */}
|
||||
});
|
||||
// Allow dismissing/auto-clearing the last-action chip
|
||||
document.addEventListener('click', function(ev){
|
||||
try{
|
||||
var t = ev.target;
|
||||
if (!t) return;
|
||||
if (t.matches && t.matches('#last-action .chip')){
|
||||
var c = document.getElementById('last-action');
|
||||
if (c) c.innerHTML = '';
|
||||
}
|
||||
}catch(_){/* noop */}
|
||||
});
|
||||
setTimeout(function(){ try{ var c=document.getElementById('last-action'); if(c && c.firstElementChild){ c.innerHTML=''; } }catch(_){} }, 6000);
|
||||
|
||||
// Keyboard helpers: when a card-tile is focused, L toggles lock, R opens alternatives
|
||||
document.addEventListener('keydown', function(e){
|
||||
try{
|
||||
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
||||
var tag = (e.target && e.target.tagName) ? e.target.tagName.toLowerCase() : '';
|
||||
// Ignore when typing in inputs/selects
|
||||
if (tag === 'input' || tag === 'textarea' || tag === 'select') return;
|
||||
var tile = document.activeElement && document.activeElement.closest ? document.activeElement.closest('.card-tile') : null;
|
||||
if (!tile) return;
|
||||
if (e.key === 'l' || e.key === 'L') {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
var lockFormBtn = tile.querySelector('.lock-box .btn-lock');
|
||||
if (lockFormBtn) { lockFormBtn.click(); }
|
||||
} else if (e.key === 'r' || e.key === 'R') {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
var altBtn = tile.querySelector('button[hx-get="/build/alternatives"]');
|
||||
if (altBtn) { altBtn.click(); }
|
||||
}
|
||||
}catch(_){ }
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -2,24 +2,12 @@
|
|||
{% block banner_subtitle %}Build a Deck{% endblock %}
|
||||
{% block content %}
|
||||
<h2>Build a Deck</h2>
|
||||
<div style="margin:.25rem 0 1rem 0;">
|
||||
<button type="button" class="btn" hx-get="/build/new" hx-target="body" hx-swap="beforeend">Build a New Deck…</button>
|
||||
<span class="muted" style="margin-left:.5rem;">Quick-start wizard (name, commander, themes, ideals)</span>
|
||||
</div>
|
||||
<div id="wizard">
|
||||
{% set step = last_step or 1 %}
|
||||
{% if step == 1 %}
|
||||
<div hx-get="/build/step1" hx-trigger="load" hx-target="#wizard" hx-swap="innerHTML"></div>
|
||||
<div hx-get="/build/banner?step=Build%20a%20Deck&i=1&n=5" hx-trigger="load"></div>
|
||||
{% elif step == 2 %}
|
||||
<div hx-get="/build/step2" hx-trigger="load" hx-target="#wizard" hx-swap="innerHTML"></div>
|
||||
<div hx-get="/build/banner?step=Build%20a%20Deck&i=2&n=5" hx-trigger="load"></div>
|
||||
{% elif step == 3 %}
|
||||
<div hx-get="/build/step3" hx-trigger="load" hx-target="#wizard" hx-swap="innerHTML"></div>
|
||||
<div hx-get="/build/banner?step=Build%20a%20Deck&i=3&n=5" hx-trigger="load"></div>
|
||||
{% elif step == 4 %}
|
||||
<div hx-get="/build/step4" hx-trigger="load" hx-target="#wizard" hx-swap="innerHTML"></div>
|
||||
<div hx-get="/build/banner?step=Build%20a%20Deck&i=4&n=5" hx-trigger="load"></div>
|
||||
{% else %}
|
||||
<div hx-get="/build/step5" hx-trigger="load" hx-target="#wizard" hx-swap="innerHTML"></div>
|
||||
<div hx-get="/build/banner?step=Build%20a%20Deck&i=5&n=5" hx-trigger="load"></div>
|
||||
{% endif %}
|
||||
<noscript><p>Enable JavaScript to use the wizard.</p></noscript>
|
||||
<!-- Wizard content will load here after the modal submit starts the build. -->
|
||||
<noscript><p>Enable JavaScript to build a deck.</p></noscript>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@
|
|||
|
||||
|
||||
{% if summary %}
|
||||
{% include "partials/deck_summary.html" %}
|
||||
{{ render_cached('partials/deck_summary.html', cfg_name, request=request, summary=summary, game_changers=game_changers, owned_set=owned_set) | safe }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
|
|
|
|||
226
code/web/templates/decks/compare.html
Normal file
226
code/web/templates/decks/compare.html
Normal 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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,15 @@
|
|||
<h3 style="margin-top:0">System summary</h3>
|
||||
<div id="sysSummary" class="muted">Loading…</div>
|
||||
</div>
|
||||
<div class="card" style="background:#0f1115; border:1px solid var(--border); border-radius:10px; padding:.75rem; margin-bottom:.75rem">
|
||||
<h3 style="margin-top:0">Performance (local)</h3>
|
||||
<div class="muted" style="margin-bottom:.35rem">Scroll the Step 5 list; this panel shows a rough FPS estimate and virtualization renders.</div>
|
||||
<div style="display:flex; gap:1rem; flex-wrap:wrap">
|
||||
<div><strong>Scroll FPS:</strong> <span id="perf-fps">–</span></div>
|
||||
<div><strong>Visible tiles:</strong> <span id="perf-visible">–</span></div>
|
||||
<div><strong>Render count:</strong> <span id="perf-renders">0</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card" style="background:#0f1115; border:1px solid var(--border); border-radius:10px; padding:.75rem;">
|
||||
<h3 style="margin-top:0">Error triggers</h3>
|
||||
<div class="row" style="display:flex; gap:.5rem; align-items:center">
|
||||
|
|
@ -39,6 +48,40 @@
|
|||
try { fetch('/status/sys', { cache: 'no-store' }).then(function(r){ return r.json(); }).then(render).catch(function(){ el.textContent='Unavailable'; }); } catch(_){ el.textContent='Unavailable'; }
|
||||
}
|
||||
load();
|
||||
// Perf probe: listen to scroll on a card grid if present
|
||||
try{
|
||||
var fpsEl = document.getElementById('perf-fps');
|
||||
var visEl = document.getElementById('perf-visible');
|
||||
var rcEl = document.getElementById('perf-renders');
|
||||
var grid = document.querySelector('.card-grid');
|
||||
var last = performance.now();
|
||||
var frames = 0; var renders = 0;
|
||||
function tick(){
|
||||
frames++;
|
||||
var now = performance.now();
|
||||
if (now - last >= 500){
|
||||
var fps = Math.round((frames * 1000) / (now - last));
|
||||
if (fpsEl) fpsEl.textContent = String(fps);
|
||||
frames = 0; last = now;
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
function updateVisible(){
|
||||
try{
|
||||
if (!grid) return;
|
||||
var tiles = grid.querySelectorAll('.card-tile');
|
||||
var c = 0; tiles.forEach(function(t){ if (t.style.display !== 'none') c++; });
|
||||
if (visEl) visEl.textContent = String(c);
|
||||
}catch(_){ }
|
||||
}
|
||||
if (grid){
|
||||
grid.addEventListener('scroll', updateVisible);
|
||||
var mo = new MutationObserver(function(){ renders++; if (rcEl) rcEl.textContent = String(renders); updateVisible(); });
|
||||
mo.observe(grid, { childList: true, subtree: true, attributes: false });
|
||||
updateVisible();
|
||||
}
|
||||
}catch(_){ }
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
71
code/web/templates/diagnostics/perf.html
Normal file
71
code/web/templates/diagnostics/perf.html
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<section>
|
||||
<h2>Diagnostics: Synthetic Perf Probe</h2>
|
||||
<p class="muted">Scroll the list; we estimate FPS and count re-renders. This page is only available when diagnostics are enabled.</p>
|
||||
<div style="display:flex; gap:1rem; flex-wrap:wrap; margin:.5rem 0 1rem 0;">
|
||||
<div><strong>FPS:</strong> <span id="fps">–</span></div>
|
||||
<div><strong>Visible rows:</strong> <span id="rows">–</span></div>
|
||||
<div><strong>Render count:</strong> <span id="renders">0</span></div>
|
||||
</div>
|
||||
|
||||
<div id="probe" style="height:60vh; overflow:auto; border:1px solid var(--border); border-radius:8px; background:#0f1115;">
|
||||
<ul id="list" style="list-style:none; margin:0; padding:0; display:grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap:8px 12px;">
|
||||
{% for i in range(1,1201) %}
|
||||
<li style="padding:.5rem; border:1px solid var(--border); border-radius:8px; background:#0b0d12;">
|
||||
<div style="display:flex; align-items:center; gap:.5rem;">
|
||||
<div style="width:64px; height:40px; background:#111; border:1px solid var(--border); border-radius:6px;">
|
||||
<img class="card-thumb" alt="Thumb {{ i }}" loading="lazy" decoding="async" data-lqip
|
||||
src="https://api.scryfall.com/cards/named?fuzzy=Lightning%20Bolt&format=image&version=small"
|
||||
width="64" height="40" style="width:64px; height:40px; object-fit:cover; border-radius:6px;" />
|
||||
</div>
|
||||
<div style="display:flex; flex-direction:column; gap:.25rem;">
|
||||
<strong>Row {{ i }}</strong>
|
||||
<small class="muted">Synthetic item for performance testing</small>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
<script>
|
||||
(function(){
|
||||
var probe = document.getElementById('probe');
|
||||
var list = document.getElementById('list');
|
||||
var fpsEl = document.getElementById('fps');
|
||||
var rowsEl = document.getElementById('rows');
|
||||
var rcEl = document.getElementById('renders');
|
||||
var last = performance.now();
|
||||
var frames = 0; var renders = 0;
|
||||
function raf(){
|
||||
frames++;
|
||||
var now = performance.now();
|
||||
if (now - last >= 500){
|
||||
var fps = Math.round((frames * 1000) / (now - last));
|
||||
if (fpsEl) fpsEl.textContent = String(fps);
|
||||
frames = 0; last = now;
|
||||
}
|
||||
requestAnimationFrame(raf);
|
||||
}
|
||||
requestAnimationFrame(raf);
|
||||
function updateVisible(){
|
||||
if (!probe || !list) return;
|
||||
var count = 0;
|
||||
list.querySelectorAll('li').forEach(function(li){
|
||||
// rough: count if within viewport
|
||||
var rect = li.getBoundingClientRect();
|
||||
var pRect = probe.getBoundingClientRect();
|
||||
if (rect.bottom >= pRect.top && rect.top <= pRect.bottom) count++;
|
||||
});
|
||||
if (rowsEl) rowsEl.textContent = String(count);
|
||||
}
|
||||
if (probe){
|
||||
probe.addEventListener('scroll', updateVisible);
|
||||
var mo = new MutationObserver(function(){ renders++; if (rcEl) rcEl.textContent = String(renders); updateVisible(); });
|
||||
mo.observe(list, { childList: true, subtree: true });
|
||||
updateVisible();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
|
@ -70,7 +70,7 @@
|
|||
{% endif %}
|
||||
|
||||
{% if names and names|length %}
|
||||
<div id="owned-box" style="overflow:auto; border:1px solid var(--border); border-radius:8px; padding:.5rem; background:#0f1115; color:#e5e7eb; min-height:240px;">
|
||||
<div id="owned-box" style="overflow:auto; border:1px solid var(--border); border-radius:8px; padding:.5rem; background:#0f1115; color:#e5e7eb; min-height:240px;" {% if virtualize %}data-virtualize="1"{% endif %}>
|
||||
<ul id="owned-grid" style="display:grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); grid-auto-rows:auto; gap:4px 16px; list-style:none; margin:0; padding:0;">
|
||||
{% for n in names %}
|
||||
{% set tags = (tags_by_name.get(n, []) if tags_by_name else []) %}
|
||||
|
|
@ -81,7 +81,9 @@
|
|||
<label class="owned-row" style="cursor:pointer;" tabindex="0">
|
||||
<input type="checkbox" class="sel sr-only" aria-label="Select {{ n }}" />
|
||||
<div class="owned-vstack">
|
||||
<img class="card-thumb" loading="lazy" alt="{{ n }} image" src="https://api.scryfall.com/cards/named?fuzzy={{ n|urlencode }}&format=image&version=small" data-card-name="{{ n }}" {% if tags %}data-tags="{{ (tags or [])|join(', ') }}"{% endif %} />
|
||||
<img class="card-thumb" loading="lazy" decoding="async" alt="{{ n }} image" src="https://api.scryfall.com/cards/named?fuzzy={{ n|urlencode }}&format=image&version=small" data-card-name="{{ n }}" data-lqip="1" {% if tags %}data-tags="{{ (tags or [])|join(', ') }}"{% endif %}
|
||||
srcset="https://api.scryfall.com/cards/named?fuzzy={{ n|urlencode }}&format=image&version=small 160w, https://api.scryfall.com/cards/named?fuzzy={{ n|urlencode }}&format=image&version=normal 488w"
|
||||
sizes="100px" />
|
||||
<span class="card-name"{% if tags %} data-tags="{{ (tags or [])|join(', ') }}"{% endif %}>{{ n }}</span>
|
||||
{% if cols and cols|length %}
|
||||
<div class="mana-group" aria-hidden="true">
|
||||
|
|
|
|||
|
|
@ -81,7 +81,9 @@
|
|||
{% set cnt = c.count if c.count else 1 %}
|
||||
{% set owned = (owned_set is defined and c.name and (c.name|lower in owned_set)) %}
|
||||
<div class="stack-card {% if (game_changers and (c.name in game_changers)) or ('game_changer' in (c.role or '') or 'Game Changer' in (c.role or '')) %}game-changer{% endif %}">
|
||||
<img src="https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=normal" alt="{{ c.name }} image" data-card-name="{{ c.name }}" data-count="{{ cnt }}" data-role="{{ c.role }}" data-tags="{{ (c.tags|join(', ')) if c.tags else '' }}" />
|
||||
<img class="card-thumb" loading="lazy" decoding="async" src="https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=normal" alt="{{ c.name }} image" data-card-name="{{ c.name }}" data-count="{{ cnt }}" data-role="{{ c.role }}" data-tags="{{ (c.tags|join(', ')) if c.tags else '' }}"
|
||||
srcset="https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=small 160w, https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=normal 488w, https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=large 672w"
|
||||
sizes="(max-width: 1200px) 160px, 240px" />
|
||||
<div class="count-badge">{{ cnt }}x</div>
|
||||
<div class="owned-badge" title="{{ 'Owned' if owned else 'Not owned' }}" aria-label="{{ 'Owned' if owned else 'Not owned' }}">{% if owned %}✔{% else %}✖{% endif %}</div>
|
||||
</div>
|
||||
|
|
@ -539,7 +541,7 @@
|
|||
}
|
||||
function highlightNames(names, on){
|
||||
if (!Array.isArray(names) || names.length === 0) return;
|
||||
// List view spans
|
||||
// List view spans
|
||||
try {
|
||||
document.querySelectorAll('#typeview-list [data-card-name]').forEach(function(it){
|
||||
var n = it.getAttribute('data-card-name');
|
||||
|
|
@ -550,7 +552,7 @@
|
|||
if (!on && !match) it.classList.remove('chart-highlight');
|
||||
});
|
||||
} catch(_) {}
|
||||
// Thumbs view images
|
||||
// Thumbs view images
|
||||
try {
|
||||
document.querySelectorAll('#typeview-thumbs [data-card-name]').forEach(function(it){
|
||||
var n = it.getAttribute('data-card-name');
|
||||
|
|
@ -561,6 +563,12 @@
|
|||
if (!on && !match) tile.classList.remove('chart-highlight');
|
||||
});
|
||||
} catch(_) {}
|
||||
// If virtualized lists are enabled, auto-scroll the Step 5 grid to the first match
|
||||
try {
|
||||
if (on && window.scrollCardIntoView && Array.isArray(names) && names.length) {
|
||||
window.scrollCardIntoView(names[0]);
|
||||
}
|
||||
} catch(_) {}
|
||||
}
|
||||
attach();
|
||||
document.addEventListener('htmx:afterSwap', function() { attach(); });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue