mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-17 08:00:13 +01:00
Web: mobile UI polish; Multi-Copy opt-in + tag filter; banner subtitle inline; New Deck modal refinements; version bump to 2.2.4; update release notes template
This commit is contained in:
parent
ef858e6d6a
commit
0033f07783
14 changed files with 408 additions and 60 deletions
|
|
@ -30,7 +30,7 @@
|
|||
}catch(_){ }
|
||||
})();
|
||||
</script>
|
||||
<link rel="stylesheet" href="/static/styles.css?v=20250828-14" />
|
||||
<link rel="stylesheet" href="/static/styles.css?v=20250902-3" />
|
||||
<!-- Performance hints -->
|
||||
<link rel="preconnect" href="https://api.scryfall.com" crossorigin>
|
||||
<link rel="dns-prefetch" href="https://api.scryfall.com">
|
||||
|
|
@ -45,7 +45,12 @@
|
|||
<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; padding-left: 1rem;">
|
||||
<button type="button" id="nav-toggle" class="btn" aria-controls="sidebar" aria-expanded="true" title="Show/Hide navigation" style="background: transparent; color: var(--surface-banner-text); border:1px solid var(--border);">
|
||||
☰ Menu
|
||||
</button>
|
||||
<h1 style="margin:0;">MTG Deckbuilder</h1>
|
||||
</div>
|
||||
<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>
|
||||
|
|
@ -70,7 +75,7 @@
|
|||
</div>
|
||||
</header>
|
||||
<div class="layout">
|
||||
<aside class="sidebar">
|
||||
<aside id="sidebar" class="sidebar" aria-label="Primary navigation">
|
||||
<div class="brand">
|
||||
<div class="mana-dots" aria-hidden="true">
|
||||
<span class="dot green"></span>
|
||||
|
|
@ -117,9 +122,49 @@
|
|||
.site-footer { margin: 8px 16px; padding: 8px 12px; border-top: 1px solid var(--border); color: #94a3b8; font-size: 12px; text-align: center; }
|
||||
.site-footer a { color: #cbd5e1; text-decoration: underline; }
|
||||
footer.site-footer { flex-shrink: 0; }
|
||||
/* Hide hover preview on narrow screens to avoid covering content */
|
||||
@media (max-width: 900px){
|
||||
.card-hover{ display: none !important; }
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
(function(){
|
||||
// Sidebar toggle and persistence
|
||||
try{
|
||||
var BODY = document.body;
|
||||
var SIDEBAR = document.getElementById('sidebar');
|
||||
var TOGGLE = document.getElementById('nav-toggle');
|
||||
var KEY = 'mtg:navCollapsed';
|
||||
function apply(collapsed){
|
||||
if (collapsed){
|
||||
BODY.classList.add('nav-collapsed');
|
||||
TOGGLE && TOGGLE.setAttribute('aria-expanded', 'false');
|
||||
SIDEBAR && SIDEBAR.setAttribute('aria-hidden', 'true');
|
||||
} else {
|
||||
BODY.classList.remove('nav-collapsed');
|
||||
TOGGLE && TOGGLE.setAttribute('aria-expanded', 'true');
|
||||
SIDEBAR && SIDEBAR.setAttribute('aria-hidden', 'false');
|
||||
}
|
||||
}
|
||||
// Initial state: respect saved pref, else collapse on small screens
|
||||
var saved = localStorage.getItem(KEY);
|
||||
var initialCollapsed = (saved === '1') || (saved === null && (window.innerWidth || 0) < 900);
|
||||
apply(initialCollapsed);
|
||||
if (TOGGLE){
|
||||
TOGGLE.addEventListener('click', function(){
|
||||
var isCollapsed = BODY.classList.contains('nav-collapsed');
|
||||
apply(!isCollapsed);
|
||||
try{ localStorage.setItem(KEY, (!isCollapsed) ? '1' : '0'); }catch(_){ }
|
||||
});
|
||||
}
|
||||
// Keep ARIA in sync on resize for first-load default when no pref yet
|
||||
window.addEventListener('resize', function(){
|
||||
// Do not override if user has an explicit preference saved
|
||||
if (localStorage.getItem(KEY) !== null) return;
|
||||
apply((window.innerWidth || 0) < 900);
|
||||
});
|
||||
}catch(_){ }
|
||||
|
||||
// Setup/Tagging status poller
|
||||
var statusEl;
|
||||
function ensureStatusEl(){
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
<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>
|
||||
<div id="banner-status" class="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>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<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" role="dialog" aria-modal="true" aria-labelledby="newDeckTitle" style="position:fixed; inset:0; z-index:1000; display:flex; align-items:flex-start; justify-content:center; padding:1rem; overflow:auto;">
|
||||
<div class="modal-backdrop" style="position:fixed; 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; max-height:min(92vh, 100%); overflow:auto; -webkit-overflow-scrolling:touch;">
|
||||
<div class="modal-header">
|
||||
<h3 id="newDeckTitle">Build a New Deck</h3>
|
||||
</div>
|
||||
|
|
@ -10,7 +10,7 @@
|
|||
<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 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>
|
||||
|
|
@ -39,6 +39,7 @@
|
|||
<input type="hidden" name="tertiary_tag" />
|
||||
<input type="hidden" name="tag_mode" value="AND" />
|
||||
</div>
|
||||
<div id="newdeck-multicopy-slot" class="muted" style="margin-top:.5rem; min-height:1rem;"></div>
|
||||
<div style="margin-top:.5rem;">
|
||||
<label>Bracket
|
||||
<select name="bracket">
|
||||
|
|
@ -54,6 +55,10 @@
|
|||
<label title="When enabled, the builder will try to auto-complete missing combo partners near the end of the build (respecting owned-only and locks).">
|
||||
<input type="checkbox" name="prefer_combos" id="pref-combos-chk" /> Prioritize combos (auto-complete partners)
|
||||
</label>
|
||||
<div style="margin-top:.35rem;"></div>
|
||||
<label title="When enabled, include a Multi-Copy package for matching archetypes (e.g., tokens/tribal).">
|
||||
<input type="checkbox" name="enable_multicopy" id="pref-mc-chk" /> Enable Multi-Copy package
|
||||
</label>
|
||||
<div id="pref-combos-config" style="margin-top:.5rem; padding:.5rem; border:1px solid var(--border); border-radius:8px; display:none;">
|
||||
<div style="display:flex; gap:1rem; align-items:center; flex-wrap:wrap;">
|
||||
<label>
|
||||
|
|
@ -95,14 +100,42 @@
|
|||
|
||||
<script>
|
||||
(function(){
|
||||
// Backdrop click to close
|
||||
try{
|
||||
var modal = document.currentScript && document.currentScript.previousElementSibling ? document.currentScript.previousElementSibling.previousElementSibling : document.querySelector('.modal');
|
||||
var backdrop = modal ? modal.querySelector('.modal-backdrop') : null;
|
||||
if (backdrop){ backdrop.addEventListener('click', function(){ try{ modal.remove(); }catch(_){} }); }
|
||||
}catch(_){ }
|
||||
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
|
||||
// Form-level Enter: if suggestions exist, pick the first by default
|
||||
form.addEventListener('keydown', function(e){
|
||||
try{
|
||||
if (e.key !== 'Enter' || e.shiftKey || e.altKey || e.ctrlKey || e.metaKey) return;
|
||||
var list = document.getElementById('newdeck-candidates');
|
||||
var first = list && list.querySelector('button.candidate-btn');
|
||||
if (first){
|
||||
e.preventDefault(); e.stopPropagation(); first.click();
|
||||
} else {
|
||||
// No suggestions: allow normal form submit
|
||||
}
|
||||
}catch(_){ }
|
||||
}, true);
|
||||
// Name field: only block Enter if suggestions exist; otherwise allow submit
|
||||
var nameEl = form.querySelector('input[name="name"]');
|
||||
if (nameEl){ nameEl.addEventListener('keydown', function(e){ if (e.key === 'Enter'){ e.preventDefault(); } }); }
|
||||
if (nameEl){
|
||||
nameEl.addEventListener('keydown', function(e){
|
||||
if (e.key !== 'Enter') return;
|
||||
try{
|
||||
var list = document.getElementById('newdeck-candidates');
|
||||
var hasBtns = !!(list && list.querySelector('button.candidate-btn'));
|
||||
if (hasBtns){ e.preventDefault(); e.stopPropagation(); }
|
||||
}catch(_){ }
|
||||
});
|
||||
}
|
||||
// In commander field, Enter picks the first candidate (if any) without closing the modal
|
||||
var cmdEl = form.querySelector('input[name=\"commander\"]');
|
||||
if (cmdEl){
|
||||
|
|
@ -186,4 +219,62 @@
|
|||
sync();
|
||||
} catch(_){}
|
||||
})();
|
||||
|
||||
// Integrated Multi-Copy: fetch suggestions once commander + tags are present
|
||||
(function(){
|
||||
function fetchMulti(){
|
||||
try{
|
||||
var slot = document.getElementById('newdeck-multicopy-slot');
|
||||
var form = document.querySelector('.modal form');
|
||||
if (!slot || !form) return;
|
||||
var enable = form.querySelector('#pref-mc-chk');
|
||||
if (!enable || !enable.checked){ slot.innerHTML = ''; return; }
|
||||
var cmd = form.querySelector('input[name="commander"]').value.trim();
|
||||
var p = form.querySelector('input[name="primary_tag"]').value.trim();
|
||||
var s = form.querySelector('input[name="secondary_tag"]').value.trim();
|
||||
var t = form.querySelector('input[name="tertiary_tag"]').value.trim();
|
||||
var mode = form.querySelector('input[name="tag_mode"]').value.trim() || 'AND';
|
||||
if (!cmd) { slot.innerHTML = ''; return; }
|
||||
// Only fetch if at least one tag is present (to avoid noise)
|
||||
if (!(p || s || t)) { slot.innerHTML = ''; return; }
|
||||
var params = new URLSearchParams({ commander: cmd, tag_mode: mode });
|
||||
if (p) params.append('primary_tag', p); if (s) params.append('secondary_tag', s); if (t) params.append('tertiary_tag', t);
|
||||
fetch('/build/new/multicopy?' + params.toString(), { headers: { 'HX-Request': 'true' } })
|
||||
.then(function(r){ return r.text(); })
|
||||
.then(function(html){ slot.innerHTML = html; })
|
||||
.catch(function(){ slot.innerHTML = ''; });
|
||||
}catch(_){ }
|
||||
}
|
||||
// Listen for OOB updates to the tags slot to trigger fetch
|
||||
document.body.addEventListener('htmx:afterSwap', function(ev){
|
||||
try{
|
||||
var tgt = ev && ev.detail && ev.detail.target ? ev.detail.target : null;
|
||||
if (tgt && tgt.id === 'newdeck-tags-slot'){ fetchMulti(); }
|
||||
}catch(_){ }
|
||||
});
|
||||
// Respond to explicit tag-change signal from the tags partial
|
||||
try{ document.addEventListener('newdeck:tagsChanged', fetchMulti); }catch(_){ }
|
||||
// Also debounce on commander input changes
|
||||
try{
|
||||
var cmdEl = document.querySelector('.modal form input[name="commander"]');
|
||||
var timer;
|
||||
if (cmdEl){ cmdEl.addEventListener('input', function(){ clearTimeout(timer); timer = setTimeout(fetchMulti, 300); }); }
|
||||
}catch(_){ }
|
||||
// React to preference toggle
|
||||
try{
|
||||
var mcChk = document.querySelector('.modal form #pref-mc-chk');
|
||||
if (mcChk){ mcChk.addEventListener('change', fetchMulti); }
|
||||
}catch(_){ }
|
||||
})();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Modal responsive tweaks (scoped) */
|
||||
@media (max-width: 720px){
|
||||
.modal .basics-grid{ grid-template-columns: 1fr !important; }
|
||||
#newdeck-commander-slot{ max-width: 100% !important; }
|
||||
#newdeck-commander-slot aside.card-preview{ max-width: 100% !important; }
|
||||
#newdeck-commander-slot img{ width: 100% !important; max-width: 260px; height: auto; margin: 0 auto; display: block; }
|
||||
.modal .modal-content{ width: min(95vw, 560px) !important; }
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
59
code/web/templates/build/_new_deck_multicopy.html
Normal file
59
code/web/templates/build/_new_deck_multicopy.html
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
{% if items and items|length %}
|
||||
<fieldset id="mc-integrated" style="margin-top:.75rem;">
|
||||
<legend>Optional: Multi-Copy package</legend>
|
||||
<div class="muted" style="font-size:12px; margin-bottom:.35rem;">We detected a viable multi-copy archetype for your commander/themes. Choose one or skip.</div>
|
||||
<div style="display:grid; gap:.5rem;">
|
||||
{% for it in items %}
|
||||
<label class="mc-option" style="display:grid; grid-template-columns: auto 1fr; gap:.5rem; align-items:flex-start; padding:.5rem; border:1px solid var(--border); border-radius:8px; background:#0b0d12;">
|
||||
<input type="radio" name="multi_choice_id" value="{{ it.id }}" {% if loop.first %}checked{% endif %} />
|
||||
<div>
|
||||
<div><strong>{{ it.name }}</strong> {% if it.printed_cap %}<span class="muted">(Cap: {{ it.printed_cap }})</span>{% endif %}</div>
|
||||
{% if it.reasons %}
|
||||
<div class="muted" style="font-size:12px;">Signals: {{ ', '.join(it.reasons) }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% set first = items[0] %}
|
||||
{% set cap = first.printed_cap %}
|
||||
{% set rec = first.rec_window if first.rec_window else (20,30) %}
|
||||
<div id="mc-count-row" class="mc-count" style="display:flex; align-items:center; gap:.5rem; flex-wrap:wrap; margin-top:.5rem;">
|
||||
<label>Copies <input type="number" min="1" name="multi_count" value="{{ first.default_count or 25 }}" style="width:6rem; margin-left:.35rem;"></label>
|
||||
{% if cap %}
|
||||
<small class="muted">Max {{ cap }}</small>
|
||||
{% else %}
|
||||
<small class="muted">Suggested {{ rec[0] }}–{{ rec[1] }}</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div id="mc-thrum-row" style="margin-top:.35rem;">
|
||||
<label title="Adds 1 copy of Thrumming Stone if applicable.">
|
||||
<input type="checkbox" name="multi_thrumming" value="1" {% if first.thrumming_stone_synergy %}checked{% endif %} /> Include Thrumming Stone
|
||||
</label>
|
||||
</div>
|
||||
<div class="muted" style="font-size:12px; margin-top:.35rem;">You can leave this unselected to skip multi-copy for this build.</div>
|
||||
</fieldset>
|
||||
<script>
|
||||
(function(){
|
||||
var root = document.currentScript && document.currentScript.previousElementSibling ? document.currentScript.previousElementSibling : document;
|
||||
var container = root.querySelector ? root : document;
|
||||
var fieldset = container.querySelector('#mc-integrated');
|
||||
if (!fieldset) return;
|
||||
function updateForChoice(){
|
||||
try{
|
||||
var checked = fieldset.querySelector('input[name="multi_choice_id"]:checked');
|
||||
var count = fieldset.querySelector('input[name="multi_count"]');
|
||||
if (!checked || !count) return;
|
||||
// Use label text to parse Cap when present
|
||||
var label = checked.closest('label.mc-option');
|
||||
var capEl = label && label.querySelector('.muted');
|
||||
var m = capEl && capEl.textContent && capEl.textContent.match(/Cap:\s*(\d+)/);
|
||||
if (m){ var cap = parseInt(m[1],10); count.max = String(cap); if (parseInt(count.value||'0',10) > cap) count.value = String(cap); }
|
||||
else { count.removeAttribute('max'); }
|
||||
}catch(_){}
|
||||
}
|
||||
fieldset.querySelectorAll('input[name="multi_choice_id"]').forEach(function(r){ r.addEventListener('change', updateForChoice); });
|
||||
updateForChoice();
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
|
|
@ -94,6 +94,8 @@
|
|||
}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);
|
||||
// Notify parent modal so it can refresh multi-copy suggestions
|
||||
try{ document.dispatchEvent(new CustomEvent('newdeck:tagsChanged')); }catch(_){ }
|
||||
}
|
||||
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); }); });
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@
|
|||
</aside>
|
||||
<div class="grow" data-skeleton>
|
||||
<div hx-get="/build/banner" hx-trigger="load"></div>
|
||||
<div hx-get="/build/multicopy/check" hx-trigger="load" hx-swap="afterend"></div>
|
||||
|
||||
<form hx-post="/build/step2" hx-target="#wizard" hx-swap="innerHTML">
|
||||
<input type="hidden" name="commander" value="{{ commander.name }}" />
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@
|
|||
<div class="grow" data-skeleton>
|
||||
<div hx-get="/build/banner" hx-trigger="load"></div>
|
||||
|
||||
<div hx-get="/build/multicopy/check" hx-trigger="load" hx-swap="afterend"></div>
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@
|
|||
</aside>
|
||||
<div class="grow" data-skeleton>
|
||||
<div hx-get="/build/banner" hx-trigger="load"></div>
|
||||
<div hx-get="/build/multicopy/check" hx-trigger="load" hx-swap="afterend"></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>
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@
|
|||
</aside>
|
||||
<div class="grow" data-skeleton>
|
||||
<div hx-get="/build/banner" hx-trigger="load"></div>
|
||||
<div hx-get="/build/multicopy/check" hx-trigger="load" hx-swap="afterend"></div>
|
||||
|
||||
|
||||
<p>Commander: <strong>{{ commander }}</strong></p>
|
||||
<p>Tags: {{ tags|default([])|join(', ') }}</p>
|
||||
|
|
@ -137,7 +137,7 @@
|
|||
</div>
|
||||
|
||||
<!-- 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;">
|
||||
<div class="build-controls" style="position:sticky; 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('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">Restart Build</button>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue