mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-23 19:10:13 +01:00
Web/builder: Owned stability+enrichment+exports; prefer-owned toggle & bias; staged build show-skipped; UI polish; docs update
This commit is contained in:
parent
fd7fc01071
commit
625f6abb13
26 changed files with 1618 additions and 229 deletions
|
|
@ -5,7 +5,7 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>MTG Deckbuilder</title>
|
||||
<script src="https://unpkg.com/htmx.org@1.9.12" onerror="var s=document.createElement('script');s.src='/static/vendor/htmx-1.9.12.min.js';document.head.appendChild(s);"></script>
|
||||
<link rel="stylesheet" href="/static/styles.css?v=20250826-1" />
|
||||
<link rel="stylesheet" href="/static/styles.css?v=20250826-3" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="top-banner">
|
||||
|
|
@ -30,6 +30,7 @@
|
|||
<a href="/build">Build</a>
|
||||
<a href="/configs">Build from JSON</a>
|
||||
{% if show_setup %}<a href="/setup">Setup/Tag</a>{% endif %}
|
||||
<a href="/owned">Owned Library</a>
|
||||
<a href="/decks">Finished Decks</a>
|
||||
{% if show_logs %}<a href="/logs">Logs</a>{% endif %}
|
||||
</nav>
|
||||
|
|
|
|||
|
|
@ -14,6 +14,17 @@
|
|||
<li>{{ label }}: <strong>{{ values[key] }}</strong></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<form hx-post="/build/toggle-owned-review" hx-target="#wizard" hx-swap="innerHTML" style="margin:.5rem 0; display:flex; align-items:center; gap:1rem; flex-wrap:wrap;">
|
||||
<label style="display:flex; align-items:center; gap:.35rem;">
|
||||
<input type="checkbox" name="use_owned_only" value="1" {% if owned_only %}checked{% endif %} onchange="this.form.requestSubmit();" />
|
||||
Use only owned cards
|
||||
</label>
|
||||
<label style="display:flex; align-items:center; gap:.35rem;">
|
||||
<input type="checkbox" name="prefer_owned" value="1" {% if prefer_owned %}checked{% endif %} onchange="this.form.requestSubmit();" />
|
||||
Prefer owned cards (allow unowned fallback)
|
||||
</label>
|
||||
<a href="/owned" target="_blank" rel="noopener" class="muted">Manage Owned Library</a>
|
||||
</form>
|
||||
<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">Build Deck</button>
|
||||
|
|
|
|||
|
|
@ -27,6 +27,14 @@
|
|||
|
||||
<p>Commander: <strong>{{ commander }}</strong></p>
|
||||
<p>Tags: {{ tags|default([])|join(', ') }}</p>
|
||||
<div style="margin:.35rem 0; color: var(--muted); display:flex; gap:.5rem; align-items:center; flex-wrap:wrap;">
|
||||
<span>Owned-only: <strong>{{ 'On' if owned_only else 'Off' }}</strong></span>
|
||||
<div style="display:flex;align-items:center;gap:1rem;">
|
||||
<button type="button" hx-get="/build/step4" hx-target="#wizard" hx-swap="innerHTML" style="background:#374151; color:#e5e7eb; border:none; border-radius:6px; padding:.25rem .5rem; cursor:pointer; font-size:12px;" title="Change owned settings in Review">Edit in Review</button>
|
||||
<div>Prefer-owned: <strong>{{ 'On' if prefer_owned else 'Off' }}</strong></div>
|
||||
</div>
|
||||
<span style="margin-left:auto;"><a href="/owned" target="_blank" rel="noopener" class="muted">Manage Owned Library</a></span>
|
||||
</div>
|
||||
<p>Bracket: {{ bracket }}</p>
|
||||
|
||||
{% if i and n %}
|
||||
|
|
@ -41,20 +49,36 @@
|
|||
|
||||
<!-- Controls moved back above the cards as requested -->
|
||||
<div style="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;">
|
||||
<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;">
|
||||
<input type="hidden" name="show_skipped" value="{{ '1' if show_skipped else '0' }}" />
|
||||
<button type="submit">Start Build</button>
|
||||
</form>
|
||||
<form hx-post="/build/step5/continue" hx-target="#wizard" hx-swap="innerHTML" style="display:inline;">
|
||||
<form hx-post="/build/step5/continue" hx-target="#wizard" hx-swap="innerHTML" style="display:inline; display:flex; align-items:center; gap:.5rem;">
|
||||
<input type="hidden" name="show_skipped" value="{{ '1' if show_skipped else '0' }}" />
|
||||
<button type="submit" {% if status and status.startswith('Build complete') %}disabled{% endif %}>Continue</button>
|
||||
</form>
|
||||
<form hx-post="/build/step5/rerun" hx-target="#wizard" hx-swap="innerHTML" style="display:inline;">
|
||||
<form hx-post="/build/step5/rerun" hx-target="#wizard" hx-swap="innerHTML" style="display:inline; display:flex; align-items:center; gap:.5rem;">
|
||||
<input type="hidden" name="show_skipped" value="{{ '1' if show_skipped else '0' }}" />
|
||||
<button type="submit" {% if status and status.startswith('Build complete') %}disabled{% endif %}>Rerun Stage</button>
|
||||
</form>
|
||||
<label class="muted" style="display:flex; align-items:center; gap:.35rem; margin-left: .5rem;">
|
||||
<input type="checkbox" name="__toggle_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; }" />
|
||||
Show skipped stages
|
||||
</label>
|
||||
<button type="button" hx-get="/build/step4" hx-target="#wizard" hx-swap="innerHTML">Back</button>
|
||||
</div>
|
||||
|
||||
{% if added_cards %}
|
||||
{% if added_cards is not none %}
|
||||
<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 %}
|
||||
<div class="muted" style="font-size:12px; margin:.15rem 0 .4rem 0; display:flex; gap:.75rem; align-items:center; flex-wrap:wrap;">
|
||||
<span><span style="display:inline-block; border:1px solid var(--border); background:rgba(17,24,39,.9); color:#e5e7eb; border-radius:12px; font-size:12px; line-height:18px; height:18px; min-width:18px; padding:0 6px; text-align:center;">✔</span> Owned</span>
|
||||
<span><span style="display:inline-block; border:1px solid var(--border); background:rgba(17,24,39,.9); color:#e5e7eb; border-radius:12px; font-size:12px; line-height:18px; height:18px; min-width:18px; padding:0 6px; text-align:center;">✖</span> Not owned</span>
|
||||
</div>
|
||||
|
||||
{% if stage_label and stage_label.startswith('Creatures') %}
|
||||
{% set groups = added_cards|groupby('sub_role') %}
|
||||
{% for g in groups %}
|
||||
|
|
@ -67,10 +91,12 @@
|
|||
<h5 style="margin:.5rem 0 .25rem 0;">{{ heading }}</h5>
|
||||
<div class="card-grid">
|
||||
{% 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 '' }}">
|
||||
<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" />
|
||||
</a>
|
||||
<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>
|
||||
{% if c.reason %}<div class="reason">{{ c.reason }}</div>{% endif %}
|
||||
</div>
|
||||
|
|
@ -80,10 +106,12 @@
|
|||
{% else %}
|
||||
<div class="card-grid">
|
||||
{% 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 '' }}">
|
||||
<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" />
|
||||
</a>
|
||||
<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>
|
||||
{% if c.reason %}<div class="reason">{{ c.reason }}</div>{% endif %}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
<h2>Build from JSON: {{ cfg_name }}</h2>
|
||||
<p class="muted" style="max-width: 70ch;">This page shows the results of a non-interactive build from the selected JSON configuration.</p>
|
||||
{% if commander %}
|
||||
<div class="muted">Commander: <strong data-card-name="{{ commander }}">{{ commander }}</strong>{% if tag_mode %} · Combine: <code>{{ tag_mode }}</code>{% endif %}</div>
|
||||
<div class="muted">Commander: <strong data-card-name="{{ commander }}">{{ commander }}</strong>{% if tag_mode %} · Combine: <code>{{ tag_mode }}</code>{% endif %}{% if use_owned_only %} · Owned-only{% endif %}</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="two-col two-col-left-rail">
|
||||
|
|
|
|||
|
|
@ -15,8 +15,12 @@
|
|||
<summary>Ideal Counts</summary>
|
||||
<pre style="background:#0f1115; border:1px solid var(--border); padding:.75rem; border-radius:8px;">{{ data.ideal_counts | tojson(indent=2) }}</pre>
|
||||
</details>
|
||||
<form method="post" action="/configs/run" style="margin-top:1rem;">
|
||||
<form method="post" action="/configs/run" style="margin-top:1rem; display:flex; align-items:center; gap:.75rem; flex-wrap:wrap;">
|
||||
<input type="hidden" name="name" value="{{ name }}" />
|
||||
<label style="display:flex; align-items:center; gap:.35rem;">
|
||||
<input type="checkbox" name="use_owned_only" value="1" />
|
||||
Use only owned cards
|
||||
</label>
|
||||
<button type="submit">Run Headless</button>
|
||||
<button type="submit" formaction="/configs" formmethod="get" class="btn" style="margin-left:.5rem;">Back</button>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -16,18 +16,20 @@
|
|||
<option value="name-asc">Commander A–Z</option>
|
||||
<option value="name-desc">Commander Z–A</option>
|
||||
</select>
|
||||
<select id="deck-theme" aria-label="Theme">
|
||||
<option value="">All Themes</option>
|
||||
</select>
|
||||
<label for="deck-txt-only" style="display:flex; align-items:center; gap:.25rem;">
|
||||
<input type="checkbox" id="deck-txt-only" /> TXT only
|
||||
</label>
|
||||
<button id="deck-clear" type="button" title="Clear filters">Clear</button>
|
||||
<button id="deck-share" type="button" title="Copy a shareable link">Share</button>
|
||||
<button id="deck-reset-all" type="button" title="Reset filter, sort, and tags">Reset all</button>
|
||||
<button id="deck-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>
|
||||
<span id="deck-live" class="sr-only" aria-live="polite" role="status"></span>
|
||||
</div>
|
||||
<div id="tag-label" class="muted" style="font-size:12px; margin:.15rem 0 .25rem 0;">Theme filters</div>
|
||||
<div id="tag-chips" aria-labelledby="tag-label" style="display:flex; gap:.25rem; flex-wrap:wrap; margin:.25rem 0 .75rem 0;"></div>
|
||||
|
||||
|
||||
{% if items %}
|
||||
<div id="deck-list" role="list" aria-labelledby="decks-heading" style="list-style:none; padding:0; margin:0; display:block;">
|
||||
|
|
@ -82,7 +84,7 @@
|
|||
<li><kbd>Enter</kbd>/<kbd>Space</kbd> opens a focused deck; <kbd>Ctrl</kbd>/<kbd>Shift</kbd>+<kbd>Enter</kbd> opens in a new tab</li>
|
||||
<li><kbd>Arrow ↑/↓</kbd>, <kbd>Home</kbd>, <kbd>End</kbd> navigate rows</li>
|
||||
<li><kbd>Esc</kbd> clears the filter (when focused)</li>
|
||||
<li><kbd>R</kbd> resets all filters, sort, and tags</li>
|
||||
<li><kbd>R</kbd> resets all filters, sort, and theme</li>
|
||||
<li>Use “TXT only” to show only decks that have a TXT export</li>
|
||||
<li>Share copies a link with your current filters</li>
|
||||
</ul>
|
||||
|
|
@ -97,9 +99,9 @@
|
|||
(function(){
|
||||
var input = document.getElementById('deck-filter');
|
||||
var sortSel = document.getElementById('deck-sort');
|
||||
var themeSel = document.getElementById('deck-theme');
|
||||
var clearBtn = document.getElementById('deck-clear');
|
||||
var list = document.getElementById('deck-list');
|
||||
var chips = document.getElementById('tag-chips');
|
||||
var countEl = document.getElementById('deck-count');
|
||||
var shareBtn = document.getElementById('deck-share');
|
||||
var resetAllBtn = document.getElementById('deck-reset-all');
|
||||
|
|
@ -112,15 +114,30 @@
|
|||
var txtOnlyCb = document.getElementById('deck-txt-only');
|
||||
if (!list) return;
|
||||
|
||||
// Build tag chips from data-tags-pipe
|
||||
var tagSet = new Set();
|
||||
// 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')); }
|
||||
panels.forEach(function(p){
|
||||
var raw = p.dataset.tagsPipe || '';
|
||||
raw.split('|').forEach(function(t){ if (t && t.trim()) tagSet.add(t.trim()); });
|
||||
var themeSet = new Set();
|
||||
panels.forEach(function(p){
|
||||
var raw = p.dataset.tagsPipe || '';
|
||||
raw.split('|').forEach(function(t){ t = (t||'').trim(); if (t) themeSet.add(t); });
|
||||
});
|
||||
var activeTags = new Set();
|
||||
// Populate theme dropdown
|
||||
if (themeSel) {
|
||||
// Preserve current selection if any
|
||||
var prev = themeSel.value || '';
|
||||
// Reset to default option
|
||||
themeSel.innerHTML = '<option value="">All Themes</option>';
|
||||
Array.from(themeSet).sort(function(a,b){ return a.localeCompare(b); }).forEach(function(t){
|
||||
var opt = document.createElement('option');
|
||||
opt.value = t; opt.textContent = t; themeSel.appendChild(opt);
|
||||
});
|
||||
if (prev) {
|
||||
// Re-apply previous selection if it exists
|
||||
var has = Array.prototype.some.call(themeSel.options, function(o){ return o.value === prev; });
|
||||
if (has) themeSel.value = prev;
|
||||
}
|
||||
}
|
||||
|
||||
// URL hash <-> state sync helpers
|
||||
function parseHash(){
|
||||
|
|
@ -130,22 +147,24 @@
|
|||
var qp = new URLSearchParams(h);
|
||||
var q = qp.get('q') || '';
|
||||
var sort = qp.get('sort') || '';
|
||||
var tag = qp.get('tag') || '';
|
||||
var tagsStr = qp.get('tags') || '';
|
||||
var tags = tagsStr ? tagsStr.split(',').filter(Boolean).map(function(s){ return decodeURIComponent(s); }) : [];
|
||||
var tags = tagsStr ? tagsStr.split(',').filter(Boolean).map(function(s){ return decodeURIComponent(s); }) : [];
|
||||
if (!tag && tags.length) { tag = tags[0]; }
|
||||
var txt = qp.get('txt');
|
||||
var txtOnly = (txt === '1' || txt === 'true');
|
||||
return { q: q, sort: sort, tags: tags, txt: txtOnly };
|
||||
return { q: q, sort: sort, tag: tag, txt: txtOnly };
|
||||
} catch(_) { return null; }
|
||||
}
|
||||
function updateHashFromState(){
|
||||
try {
|
||||
var q = (input && input.value) ? input.value.trim() : '';
|
||||
var sort = (sortSel && sortSel.value) ? sortSel.value : 'newest';
|
||||
var tags = Array.from(activeTags);
|
||||
var tag = (themeSel && themeSel.value) ? themeSel.value : '';
|
||||
var qp = new URLSearchParams();
|
||||
if (q) qp.set('q', q);
|
||||
if (sort && sort !== 'newest') qp.set('sort', sort);
|
||||
if (tags.length) qp.set('tags', tags.map(function(s){ return encodeURIComponent(s); }).join(','));
|
||||
if (tag) qp.set('tag', tag);
|
||||
if (txtOnlyCb && txtOnlyCb.checked) qp.set('txt', '1');
|
||||
var newHash = qp.toString();
|
||||
var base = location.pathname + location.search;
|
||||
|
|
@ -161,45 +180,16 @@
|
|||
var changed = false;
|
||||
if (typeof s.q === 'string' && input && input.value !== s.q) { input.value = s.q; changed = true; }
|
||||
if (s.sort && sortSel && sortSel.value !== s.sort) { sortSel.value = s.sort; changed = true; }
|
||||
if (Array.isArray(s.tags)) { activeTags = new Set(s.tags); changed = true; }
|
||||
if (typeof s.tag === 'string' && themeSel) {
|
||||
// If the tag isn't present in options, add it for back-compat
|
||||
var exists = Array.prototype.some.call(themeSel.options, function(o){ return o.value === s.tag; });
|
||||
if (s.tag && !exists) { var opt = document.createElement('option'); opt.value = s.tag; opt.textContent = s.tag; themeSel.appendChild(opt); }
|
||||
themeSel.value = s.tag; changed = true;
|
||||
}
|
||||
if (typeof s.txt === 'boolean' && txtOnlyCb) { txtOnlyCb.checked = s.txt; changed = true; }
|
||||
renderChips();
|
||||
applyAll();
|
||||
return changed;
|
||||
}
|
||||
function renderChips(){
|
||||
if (!chips) return;
|
||||
chips.innerHTML = '';
|
||||
Array.from(tagSet).sort(function(a,b){ return a.localeCompare(b); }).forEach(function(t){
|
||||
var btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'chip chip-filter' + (activeTags.has(t) ? ' active' : '');
|
||||
btn.textContent = t;
|
||||
btn.setAttribute('aria-pressed', activeTags.has(t) ? 'true' : 'false');
|
||||
btn.addEventListener('click', function(){
|
||||
if (activeTags.has(t)) activeTags.delete(t); else activeTags.add(t);
|
||||
renderChips();
|
||||
applyAll();
|
||||
});
|
||||
chips.appendChild(btn);
|
||||
});
|
||||
// Reset tags control appears only when any tags are active
|
||||
if (activeTags.size > 0) {
|
||||
var reset = document.createElement('button');
|
||||
reset.type = 'button';
|
||||
reset.id = 'reset-tags';
|
||||
reset.className = 'chip';
|
||||
reset.textContent = 'Reset tags';
|
||||
reset.title = 'Clear selected theme tags';
|
||||
reset.addEventListener('click', function(){
|
||||
activeTags.clear();
|
||||
renderChips();
|
||||
applyAll();
|
||||
if (liveEl) liveEl.textContent = 'Theme tags cleared';
|
||||
});
|
||||
chips.appendChild(reset);
|
||||
}
|
||||
}
|
||||
|
||||
function updateCount(){
|
||||
if (!countEl) return;
|
||||
|
|
@ -218,13 +208,13 @@
|
|||
|
||||
function applyFilter(){
|
||||
var q = (input && input.value || '').toLowerCase();
|
||||
var selTag = (themeSel && themeSel.value) ? themeSel.value : '';
|
||||
panels.forEach(function(row){
|
||||
var hay = (row.dataset.name + ' ' + row.dataset.commander + ' ' + (row.dataset.tags||'')).toLowerCase();
|
||||
var textMatch = hay.indexOf(q) >= 0;
|
||||
var tagsPipe = row.dataset.tagsPipe || '';
|
||||
var tags = tagsPipe ? tagsPipe.split('|').filter(Boolean) : [];
|
||||
var tagMatch = true;
|
||||
activeTags.forEach(function(t){ if (tags.indexOf(t) === -1) tagMatch = false; });
|
||||
var tagMatch = selTag ? (tags.indexOf(selTag) !== -1) : true;
|
||||
var txtOk = true;
|
||||
try { if (txtOnlyCb && txtOnlyCb.checked) { txtOk = (row.dataset.txt === '1'); } } catch(_){ }
|
||||
row.style.display = (textMatch && tagMatch && txtOk) ? '' : 'none';
|
||||
|
|
@ -312,7 +302,7 @@
|
|||
try {
|
||||
if (input) localStorage.setItem('decks-filter', input.value || '');
|
||||
if (sortSel) localStorage.setItem('decks-sort', sortSel.value || 'newest');
|
||||
localStorage.setItem('decks-tags', JSON.stringify(Array.from(activeTags)));
|
||||
if (themeSel) localStorage.setItem('decks-theme', themeSel.value || '');
|
||||
if (txtOnlyCb) localStorage.setItem('decks-txt', txtOnlyCb.checked ? '1' : '0');
|
||||
} catch(_){ }
|
||||
// Update URL hash for shareable state
|
||||
|
|
@ -332,13 +322,13 @@
|
|||
var debouncedApply = debounce(applyAll, 150);
|
||||
if (input) input.addEventListener('input', debouncedApply);
|
||||
if (sortSel) sortSel.addEventListener('change', applyAll);
|
||||
if (themeSel) themeSel.addEventListener('change', applyAll);
|
||||
if (txtOnlyCb) txtOnlyCb.addEventListener('change', applyAll);
|
||||
if (clearBtn) clearBtn.addEventListener('click', function(){
|
||||
if (input) input.value = '';
|
||||
activeTags.clear();
|
||||
if (themeSel) themeSel.value = '';
|
||||
if (sortSel) sortSel.value = 'newest';
|
||||
if (txtOnlyCb) txtOnlyCb.checked = false;
|
||||
renderChips();
|
||||
applyAll();
|
||||
});
|
||||
|
||||
|
|
@ -348,19 +338,18 @@
|
|||
if (input) input.value = '';
|
||||
if (sortSel) sortSel.value = 'newest';
|
||||
if (txtOnlyCb) txtOnlyCb.checked = false;
|
||||
activeTags.clear();
|
||||
renderChips();
|
||||
if (themeSel) themeSel.value = '';
|
||||
// Clear persistence
|
||||
localStorage.removeItem('decks-filter');
|
||||
localStorage.removeItem('decks-sort');
|
||||
localStorage.removeItem('decks-tags');
|
||||
localStorage.removeItem('decks-theme');
|
||||
localStorage.removeItem('decks-txt');
|
||||
// Clear URL hash
|
||||
var base = location.pathname + location.search;
|
||||
history.replaceState(null, '', base);
|
||||
} catch(_){ }
|
||||
applyAll();
|
||||
if (liveEl) liveEl.textContent = 'Filters, sort, and tags reset';
|
||||
if (liveEl) liveEl.textContent = 'Filters, sort, and theme reset';
|
||||
});
|
||||
|
||||
if (shareBtn) shareBtn.addEventListener('click', function(){
|
||||
|
|
@ -385,7 +374,6 @@
|
|||
var hadHash = false;
|
||||
try { hadHash = !!((location.hash || '').replace(/^#/, '')); } catch(_){ }
|
||||
if (hadHash) {
|
||||
renderChips();
|
||||
if (!applyStateFromHash()) { applyAll(); }
|
||||
} else {
|
||||
// Load persisted state
|
||||
|
|
@ -394,11 +382,26 @@
|
|||
if (input) input.value = savedFilter;
|
||||
var savedSort = localStorage.getItem('decks-sort') || 'newest';
|
||||
if (sortSel) sortSel.value = savedSort;
|
||||
var savedTags = JSON.parse(localStorage.getItem('decks-tags') || '[]');
|
||||
if (Array.isArray(savedTags)) savedTags.forEach(function(t){ activeTags.add(t); });
|
||||
var savedTheme = localStorage.getItem('decks-theme') || '';
|
||||
if (themeSel && savedTheme) {
|
||||
var exists = Array.prototype.some.call(themeSel.options, function(o){ return o.value === savedTheme; });
|
||||
if (!exists) { var opt = document.createElement('option'); opt.value = savedTheme; opt.textContent = savedTheme; themeSel.appendChild(opt); }
|
||||
themeSel.value = savedTheme;
|
||||
}
|
||||
// Back-compat: if no savedTheme, try first of old saved tags
|
||||
if (themeSel && !savedTheme) {
|
||||
try {
|
||||
var oldTags = JSON.parse(localStorage.getItem('decks-tags') || '[]');
|
||||
if (Array.isArray(oldTags) && oldTags.length > 0) {
|
||||
var ot = oldTags[0];
|
||||
var ex2 = Array.prototype.some.call(themeSel.options, function(o){ return o.value === ot; });
|
||||
if (!ex2) { var o2 = document.createElement('option'); o2.value = ot; o2.textContent = ot; themeSel.appendChild(o2); }
|
||||
themeSel.value = ot;
|
||||
}
|
||||
} catch(_e){}
|
||||
}
|
||||
if (txtOnlyCb) txtOnlyCb.checked = (localStorage.getItem('decks-txt') === '1');
|
||||
} catch(_){ }
|
||||
renderChips();
|
||||
applyAll();
|
||||
}
|
||||
|
||||
|
|
@ -544,8 +547,6 @@
|
|||
})();
|
||||
</script>
|
||||
<style>
|
||||
.chip-filter { cursor:pointer; user-select:none; }
|
||||
.chip-filter.active { background:#2563eb; color:#fff; border-color:#1d4ed8; }
|
||||
.sr-only{ position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; }
|
||||
mark { background: rgba(251, 191, 36, .35); color: inherit; padding:0 .1rem; border-radius:2px; }
|
||||
#deck-list[role="list"] .panel[role="listitem"] { outline: none; }
|
||||
|
|
|
|||
|
|
@ -31,6 +31,29 @@
|
|||
</div>
|
||||
<div>
|
||||
{% if summary %}
|
||||
{% if owned_set %}
|
||||
{% set ns = namespace(owned=0, total=0) %}
|
||||
{% set tb = summary.type_breakdown %}
|
||||
{% if tb and tb.cards %}
|
||||
{% for t, clist in tb.cards.items() %}
|
||||
{% for c in clist %}
|
||||
{% set cnt = c.count if c.count else 1 %}
|
||||
{% set ns.total = ns.total + cnt %}
|
||||
{% if c.name and (c.name|lower in owned_set) %}
|
||||
{% set ns.owned = ns.owned + cnt %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% set not_owned = (ns.total - ns.owned) %}
|
||||
{% set pct = ( (ns.owned * 100.0 / (ns.total or 1)) | round(1) ) %}
|
||||
<div class="panel" style="margin-bottom:.75rem;">
|
||||
<div style="display:flex; gap:1rem; align-items:center; flex-wrap:wrap;">
|
||||
<div><strong>Ownership</strong></div>
|
||||
<div class="muted">Owned: {{ ns.owned }} • Not owned: {{ not_owned }} • Total: {{ ns.total }} ({{ pct }}%)</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% include "partials/deck_summary.html" %}
|
||||
{% else %}
|
||||
<div class="muted">No summary available.</div>
|
||||
|
|
|
|||
211
code/web/templates/owned/index.html
Normal file
211
code/web/templates/owned/index.html
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<section>
|
||||
<h3>Owned Cards Library</h3>
|
||||
<p class="muted">Upload .txt or .csv lists. We’ll extract names and keep a de-duplicated library for the web UI.</p>
|
||||
|
||||
{% if error %}
|
||||
<div class="error" style="margin:.5rem 0;">{{ error }}</div>
|
||||
{% endif %}
|
||||
{% if notice %}
|
||||
<div class="notice" style="margin:.5rem 0;">{{ notice }}</div>
|
||||
{% endif %}
|
||||
|
||||
<form action="/owned/upload" method="post" enctype="multipart/form-data" style="margin:.5rem 0 1rem 0;">
|
||||
<button type="button" class="btn" onclick="this.nextElementSibling.click();">Upload TXT/CSV</button>
|
||||
<input id="upload-owned" type="file" name="file" accept=".txt,.csv" style="display:none" onchange="this.form.requestSubmit();" />
|
||||
</form>
|
||||
|
||||
<div style="display:flex; gap:.5rem; align-items:center; margin-bottom:.75rem;">
|
||||
<form action="/owned/clear" method="post" style="display:inline;">
|
||||
<button type="submit" {% if count == 0 %}disabled{% endif %}>Clear Library</button>
|
||||
</form>
|
||||
<a href="/owned/export" download class="btn{% if count == 0 %} disabled{% endif %}" {% if count == 0 %}aria-disabled="true" onclick="return false;"{% endif %}>Export TXT</a>
|
||||
<a href="/owned/export.csv" download class="btn{% if count == 0 %} disabled{% endif %}" {% if count == 0 %}aria-disabled="true" onclick="return false;"{% endif %}>Export CSV</a>
|
||||
<span class="muted">{{ count }} unique name{{ '' if count == 1 else 's' }} <span id="shown-count" style="margin-left:.25rem;">{% if count %}• {{ count }} shown{% endif %}</span></span>
|
||||
</div>
|
||||
|
||||
{% if names and names|length %}
|
||||
<div class="filters" style="display:flex; flex-wrap:wrap; gap:8px; margin:.25rem 0 .5rem 0;">
|
||||
<select id="sort-by" style="background:#0f1115; color:#e5e7eb; border:1px solid var(--border); border-radius:6px; padding:.3rem .5rem;">
|
||||
<option value="name">Sort: A → Z</option>
|
||||
<option value="type">Sort: Type</option>
|
||||
<option value="color">Sort: Color</option>
|
||||
<option value="tags">Sort: Tags</option>
|
||||
</select>
|
||||
<select id="filter-type" style="background:#0f1115; color:#e5e7eb; border:1px solid var(--border); border-radius:6px; padding:.3rem .5rem;">
|
||||
<option value="">All Types</option>
|
||||
{% for t in all_types %}<option value="{{ t }}">{{ t }}</option>{% endfor %}
|
||||
</select>
|
||||
<select id="filter-tag" style="background:#0f1115; color:#e5e7eb; border:1px solid var(--border); border-radius:6px; padding:.3rem .5rem; max-width:320px;">
|
||||
<option value="">All Themes</option>
|
||||
{% for t in all_tags %}<option value="{{ t }}">{{ t }}</option>{% endfor %}
|
||||
</select>
|
||||
<select id="filter-color" style="background:#0f1115; color:#e5e7eb; border:1px solid var(--border); border-radius:6px; padding:.3rem .5rem;">
|
||||
<option value="">All Colors</option>
|
||||
{% for c in all_colors %}<option value="{{ c }}">{{ c }}</option>{% endfor %}
|
||||
{% if color_combos and color_combos|length %}
|
||||
<option value="" disabled>──────────</option>
|
||||
{% for code, label in color_combos %}
|
||||
<option value="{{ code }}">{{ label }}</option>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</select>
|
||||
<input id="filter-text" type="search" placeholder="Search name..." style="background:#0f1115; color:#e5e7eb; border:1px solid var(--border); border-radius:6px; padding:.3rem .5rem; flex:1; min-width:200px;" />
|
||||
<button type="button" id="clear-filters">Clear</button>
|
||||
</div>
|
||||
{% 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;">
|
||||
<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 []) %}
|
||||
{% set tline = (type_by_name.get(n, '') if type_by_name else '') %}
|
||||
{% set cols = (colors_by_name.get(n, []) if colors_by_name else []) %}
|
||||
<li style="break-inside: avoid; overflow-wrap:anywhere;" data-type="{{ tline }}" data-tags="{{ (tags or [])|join('|') }}" data-colors="{{ (cols or [])|join('') }}">
|
||||
<span data-card-name="{{ n }}" {% if tags %}data-tags="{{ (tags or [])|join(', ') }}"{% endif %}>{{ n }}</span>
|
||||
{% if cols and cols|length %}
|
||||
<span class="mana-group" aria-hidden="true" style="margin-left:.35rem; display:inline-flex; gap:4px; vertical-align:middle;">
|
||||
{% for c in cols %}
|
||||
<span class="mana mana-{{ c }}" title="{{ c }}"></span>
|
||||
{% endfor %}
|
||||
</span>
|
||||
<span class="sr-only"> Colors: {{ cols|join(', ') }}</span>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="muted">No names yet. Upload a file to get started.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
var grid = document.getElementById('owned-grid');
|
||||
if (!grid) return;
|
||||
var box = document.getElementById('owned-box');
|
||||
var fSort = document.getElementById('sort-by');
|
||||
var fType = document.getElementById('filter-type');
|
||||
var fTag = document.getElementById('filter-tag');
|
||||
var fColor = document.getElementById('filter-color');
|
||||
var fText = document.getElementById('filter-text');
|
||||
var btnClear = document.getElementById('clear-filters');
|
||||
var shownCount = document.getElementById('shown-count');
|
||||
|
||||
// Resize the container to fill the viewport height
|
||||
function sizeBox(){
|
||||
if (!box) return;
|
||||
try {
|
||||
var rect = box.getBoundingClientRect();
|
||||
var margin = 16; // breathing room at bottom
|
||||
var vh = window.innerHeight || document.documentElement.clientHeight || 0;
|
||||
var h = Math.max(240, Math.floor(vh - rect.top - margin));
|
||||
box.style.height = h + 'px';
|
||||
} catch(_){}
|
||||
}
|
||||
function debounce(fn, delay){ var t=null; return function(){ var a=arguments, c=this; if(t) clearTimeout(t); t=setTimeout(function(){ fn.apply(c,a); }, delay); }; }
|
||||
var debouncedSize = debounce(sizeBox, 100);
|
||||
sizeBox();
|
||||
window.addEventListener('resize', debouncedSize);
|
||||
|
||||
function passType(li, val){ if (!val) return true; var t=(li.getAttribute('data-type')||'').toLowerCase(); return t.indexOf(val.toLowerCase())!==-1; }
|
||||
function passTag(li, val){ if (!val) return true; var ts=(li.getAttribute('data-tags')||''); if (!ts) return false; var parts=ts.split('|'); return parts.some(function(x){return x.toLowerCase()===val.toLowerCase();}); }
|
||||
function canonCode(raw){
|
||||
var s = (raw||'').toUpperCase();
|
||||
var order = ['W','U','B','R','G'];
|
||||
var out = [];
|
||||
for (var i=0;i<order.length;i++){
|
||||
var ch = order[i];
|
||||
if (s.indexOf(ch) !== -1) out.push(ch);
|
||||
}
|
||||
if (out.length === 0){
|
||||
// Treat empty or explicit C as colorless
|
||||
if (s.indexOf('C') !== -1 || s === '') return 'C';
|
||||
return '';
|
||||
}
|
||||
return out.join('');
|
||||
}
|
||||
function passColor(li, val){
|
||||
if (!val) return true;
|
||||
var cs=(li.getAttribute('data-colors')||'');
|
||||
var vcode = canonCode(val);
|
||||
var ccode = canonCode(cs);
|
||||
if (!vcode) return true; // if somehow invalid selection
|
||||
return ccode === vcode;
|
||||
}
|
||||
function passText(li, val){ if (!val) return true; var txt=(li.textContent||'').toLowerCase(); return txt.indexOf(val.toLowerCase())!==-1; }
|
||||
|
||||
function updateShownCount(){
|
||||
if (!shownCount) return;
|
||||
var total = 0;
|
||||
Array.prototype.forEach.call(grid.children, function(li){ if (li.style.display !== 'none') total++; });
|
||||
shownCount.textContent = (total > 0 ? '• ' + total + ' shown' : '');
|
||||
}
|
||||
|
||||
function apply(){
|
||||
var vt = fType ? fType.value : '';
|
||||
var vtag = fTag ? fTag.value : '';
|
||||
var vc = fColor ? fColor.value : '';
|
||||
var vx = fText ? fText.value.trim() : '';
|
||||
Array.prototype.forEach.call(grid.children, function(li){
|
||||
var ok = passType(li, vt) && passTag(li, vtag) && passColor(li, vc) && passText(li, vx);
|
||||
li.style.display = ok ? '' : 'none';
|
||||
});
|
||||
resort();
|
||||
updateShownCount();
|
||||
}
|
||||
|
||||
function resort(){
|
||||
if (!fSort) return;
|
||||
var mode = fSort.value || 'name';
|
||||
var lis = Array.prototype.slice.call(grid.children);
|
||||
// Only consider visible items, but keep hidden in place after visible ones to avoid DOM thrash
|
||||
var visible = lis.filter(function(li){ return li.style.display !== 'none'; });
|
||||
var hidden = lis.filter(function(li){ return li.style.display === 'none'; });
|
||||
function byName(a,b){ return (a.textContent||'').toLowerCase().localeCompare((b.textContent||'').toLowerCase()); }
|
||||
function byType(a,b){ return (a.getAttribute('data-type')||'').toLowerCase().localeCompare((b.getAttribute('data-type')||'').toLowerCase()); }
|
||||
function byColor(a,b){ return (a.getAttribute('data-colors')||'').localeCompare((b.getAttribute('data-colors')||'')); }
|
||||
function byTags(a,b){ var ac=(a.getAttribute('data-tags')||'').split('|').filter(Boolean).length; var bc=(b.getAttribute('data-tags')||'').split('|').filter(Boolean).length; return ac-bc || byName(a,b); }
|
||||
var cmp = byName;
|
||||
if (mode === 'type') cmp = byType;
|
||||
else if (mode === 'color') cmp = byColor;
|
||||
else if (mode === 'tags') cmp = byTags;
|
||||
visible.sort(cmp);
|
||||
// Re-append in new order
|
||||
var frag = document.createDocumentFragment();
|
||||
visible.forEach(function(li){ frag.appendChild(li); });
|
||||
hidden.forEach(function(li){ frag.appendChild(li); });
|
||||
grid.appendChild(frag);
|
||||
}
|
||||
|
||||
if (fSort) fSort.addEventListener('change', function(){ resort(); });
|
||||
if (fType) fType.addEventListener('change', apply);
|
||||
if (fTag) fTag.addEventListener('change', apply);
|
||||
if (fColor) fColor.addEventListener('change', apply);
|
||||
if (fText) fText.addEventListener('input', apply);
|
||||
if (btnClear) btnClear.addEventListener('click', function(){ if(fSort)fSort.value='name'; if(fType)fType.value=''; if(fTag)fTag.value=''; if(fColor)fColor.value=''; if(fText)fText.value=''; apply(); });
|
||||
// Initial state
|
||||
updateShownCount();
|
||||
})();
|
||||
</script>
|
||||
<style>
|
||||
.sr-only{ position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; }
|
||||
.mana{ display:inline-block; width:14px; height:14px; border-radius:50%; box-sizing:border-box; }
|
||||
.mana-W{ background:#f9fafb; border:1px solid #d1d5db; }
|
||||
.mana-U{ background:#3b82f6; }
|
||||
.mana-B{ background:#111827; }
|
||||
.mana-R{ background:#ef4444; }
|
||||
.mana-G{ background:#10b981; }
|
||||
.mana-C{ background:#9ca3af; border:1px solid #6b7280; }
|
||||
/* Subtle scrollbar styling for the owned list box */
|
||||
#owned-box{ scrollbar-width: thin; scrollbar-color: rgba(148,163,184,.35) transparent; }
|
||||
#owned-box:hover{ scrollbar-color: rgba(148,163,184,.6) transparent; }
|
||||
#owned-box::-webkit-scrollbar{ width:8px; height:8px; }
|
||||
#owned-box::-webkit-scrollbar-track{ background: transparent; }
|
||||
#owned-box::-webkit-scrollbar-thumb{ background-color: rgba(148,163,184,.35); border-radius:8px; }
|
||||
#owned-box:hover::-webkit-scrollbar-thumb{ background-color: rgba(148,163,184,.6); }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
<hr style="margin:1.25rem 0; border-color: var(--border);" />
|
||||
<h4>Deck Summary</h4>
|
||||
<div class="muted" style="font-size:12px; margin:.15rem 0 .4rem 0;">
|
||||
Legend: <span class="game-changer" style="font-weight:600;">Game Changer</span>
|
||||
<span class="muted" style="opacity:.8;">(green highlight)</span>
|
||||
|
||||
<div class="muted" style="font-size:12px; margin:.15rem 0 .4rem 0; display:flex; gap:.75rem; align-items:center; flex-wrap:wrap;">
|
||||
<span>Legend:</span>
|
||||
<span><span class="game-changer" style="font-weight:600;">Game Changer</span> <span class="muted" style="opacity:.8;">(green highlight)</span></span>
|
||||
<span><span class="owned-flag" style="margin:0 .25rem 0 .1rem;">✔</span>Owned • <span class="owned-flag" style="margin:0 .25rem 0 .1rem;">✖</span>Not owned</span>
|
||||
</div>
|
||||
|
||||
<!-- Card Type Breakdown with names-only list and hover preview -->
|
||||
|
|
@ -29,7 +29,9 @@
|
|||
.stack-card { width: var(--card-w); height: var(--card-h); border-radius:8px; box-shadow: 0 6px 18px rgba(0,0,0,.55); border:1px solid var(--border); background:#0f1115; transition: transform .06s ease, box-shadow .06s ease; position: relative; }
|
||||
.stack-card img { width: var(--card-w); height: var(--card-h); display:block; border-radius:8px; }
|
||||
.stack-card:hover { z-index: 999; transform: translateY(-2px); box-shadow: 0 10px 22px rgba(0,0,0,.6); }
|
||||
.count-badge { position:absolute; top:6px; right:6px; background:rgba(17,24,39,.9); color:#e5e7eb; border:1px solid var(--border); border-radius:12px; font-size:12px; line-height:18px; height:18px; padding:0 6px; pointer-events:none; }
|
||||
.count-badge { position:absolute; top:6px; right:6px; background:rgba(17,24,39,.9); color:#e5e7eb; border:1px solid var(--border); border-radius:12px; font-size:12px; line-height:18px; height:18px; padding:0 6px; pointer-events:none; }
|
||||
.owned-badge { position:absolute; top:6px; left:6px; background:rgba(17,24,39,.9); color:#e5e7eb; border:1px solid var(--border); border-radius:12px; font-size:12px; line-height:18px; height:18px; min-width:18px; padding:0 6px; text-align:center; pointer-events:none; z-index: 2; }
|
||||
.owned-flag { font-size:.95rem; opacity:.9; }
|
||||
</style>
|
||||
<div id="typeview-list" class="typeview">
|
||||
{% for t in tb.order %}
|
||||
|
|
@ -42,9 +44,11 @@
|
|||
{% for c in clist %}
|
||||
<div class="{% 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 %}">
|
||||
{% 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)) %}
|
||||
<span data-card-name="{{ c.name }}" data-count="{{ cnt }}" data-role="{{ c.role }}" data-tags="{{ (c.tags|join(', ')) if c.tags else '' }}">
|
||||
{{ cnt }}x {{ c.name }}
|
||||
</span>
|
||||
<span class="owned-flag" title="{{ 'Owned' if owned else 'Not owned' }}" aria-label="{{ 'Owned' if owned else 'Not owned' }}">{% if owned %}✔{% else %}✖{% endif %}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
|
@ -64,9 +68,11 @@
|
|||
<div class="stack-grid">
|
||||
{% for c in clist %}
|
||||
{% 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 '' }}" />
|
||||
<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>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
|
@ -133,110 +139,115 @@
|
|||
})();
|
||||
</script>
|
||||
|
||||
<!-- Mana Pip Distribution (vertical bars; only deck colors) -->
|
||||
<!-- Mana Overview Row: Pips • Sources • Curve -->
|
||||
<section style="margin-top:1rem;">
|
||||
<h5>Mana Pip Distribution (non-lands)</h5>
|
||||
{% set pd = summary.pip_distribution %}
|
||||
<h5>Mana Overview</h5>
|
||||
{% set deck_colors = summary.colors or [] %}
|
||||
{% if pd %}
|
||||
{% set colors = deck_colors if deck_colors else ['W','U','B','R','G'] %}
|
||||
<div style="display:flex; gap:14px; align-items:flex-end; height:140px;">
|
||||
{% for color in colors %}
|
||||
{% set w = (pd.weights[color] if pd.weights and color in pd.weights else 0) %}
|
||||
{% set pct = (w * 100) | int %}
|
||||
<div style="text-align:center;">
|
||||
<svg width="28" height="120" aria-label="{{ color }} {{ pct }}%">
|
||||
{% set count_val = (pd.counts[color] if pd.counts and color in pd.counts else 0) %}
|
||||
{% set pct_f = (pd.weights[color] * 100) if pd.weights and color in pd.weights else 0 %}
|
||||
<rect x="2" y="2" width="24" height="116" fill="#14171c" stroke="var(--border)" rx="4" ry="4"
|
||||
data-type="pips" data-color="{{ color }}" data-count="{{ '%.1f' % count_val }}" data-pct="{{ '%.1f' % pct_f }}"></rect>
|
||||
{% set h = (pct * 1.0) | int %}
|
||||
{% set bar_h = (h if h>2 else 2) %}
|
||||
{% set y = 118 - bar_h %}
|
||||
<rect x="2" y="{{ y }}" width="24" height="{{ bar_h }}" fill="#3b82f6" rx="4" ry="4"
|
||||
data-type="pips" data-color="{{ color }}" data-count="{{ '%.1f' % count_val }}" data-pct="{{ '%.1f' % pct_f }}"></rect>
|
||||
</svg>
|
||||
<div class="muted" style="margin-top:.25rem;">{{ color }}</div>
|
||||
<div class="mana-row" style="display:grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 16px; align-items: stretch;">
|
||||
<!-- Pips Panel -->
|
||||
<div class="mana-panel" style="border:1px solid var(--border); border-radius:8px; padding:.6rem; background:#0f1115;">
|
||||
<div class="muted" style="margin-bottom:.35rem; font-weight:600;">Mana Pips (non-lands)</div>
|
||||
{% set pd = summary.pip_distribution %}
|
||||
{% if pd %}
|
||||
{% set colors = deck_colors if deck_colors else ['W','U','B','R','G'] %}
|
||||
<div style="display:flex; gap:14px; align-items:flex-end; height:140px;">
|
||||
{% for color in colors %}
|
||||
{% set w = (pd.weights[color] if pd.weights and color in pd.weights else 0) %}
|
||||
{% set pct = (w * 100) | int %}
|
||||
<div style="text-align:center;">
|
||||
<svg width="28" height="120" aria-label="{{ color }} {{ pct }}%">
|
||||
{% set count_val = (pd.counts[color] if pd.counts and color in pd.counts else 0) %}
|
||||
{% set pct_f = (pd.weights[color] * 100) if pd.weights and color in pd.weights else 0 %}
|
||||
<rect x="2" y="2" width="24" height="116" fill="#14171c" stroke="var(--border)" rx="4" ry="4"
|
||||
data-type="pips" data-color="{{ color }}" data-count="{{ '%.1f' % count_val }}" data-pct="{{ '%.1f' % pct_f }}"></rect>
|
||||
{% set h = (pct * 1.0) | int %}
|
||||
{% set bar_h = (h if h>2 else 2) %}
|
||||
{% set y = 118 - bar_h %}
|
||||
<rect x="2" y="{{ y }}" width="24" height="{{ bar_h }}" fill="#3b82f6" rx="4" ry="4"
|
||||
data-type="pips" data-color="{{ color }}" data-count="{{ '%.1f' % count_val }}" data-pct="{{ '%.1f' % pct_f }}"></rect>
|
||||
</svg>
|
||||
<div class="muted" style="margin-top:.25rem;">{{ color }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="muted">No pip data.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="muted">No pip data.</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<!-- Mana Generation (color sources from lands, vertical bars; only deck colors) -->
|
||||
<section style="margin-top:1rem;">
|
||||
<h5>Mana Generation (Color Sources)</h5>
|
||||
{% set mg = summary.mana_generation %}
|
||||
{% set deck_colors = summary.colors or [] %}
|
||||
{% if mg %}
|
||||
{% set colors = deck_colors if deck_colors else ['W','U','B','R','G'] %}
|
||||
{% set ns = namespace(max_src=0) %}
|
||||
{% for color in colors %}
|
||||
{% set val = mg.get(color, 0) %}
|
||||
{% if val > ns.max_src %}{% set ns.max_src = val %}{% endif %}
|
||||
{% endfor %}
|
||||
{% set denom = (ns.max_src if ns.max_src and ns.max_src > 0 else 1) %}
|
||||
<div style="display:flex; gap:14px; align-items:flex-end; height:140px;">
|
||||
{% for color in colors %}
|
||||
{% set val = mg.get(color, 0) %}
|
||||
{% set pct = (val * 100 / denom) | int %}
|
||||
<div style="text-align:center;">
|
||||
<svg width="28" height="120" aria-label="{{ color }} {{ val }}">
|
||||
{% set pct_f = (100.0 * (val / (mg.total_sources or 1))) %}
|
||||
<rect x="2" y="2" width="24" height="116" fill="#14171c" stroke="var(--border)" rx="4" ry="4"
|
||||
data-type="sources" data-color="{{ color }}" data-val="{{ val }}" data-pct="{{ '%.1f' % pct_f }}"></rect>
|
||||
{% set bar_h = (pct if pct>2 else 2) %}
|
||||
{% set y = 118 - bar_h %}
|
||||
<rect x="2" y="{{ y }}" width="24" height="{{ bar_h }}" fill="#10b981" rx="4" ry="4"
|
||||
data-type="sources" data-color="{{ color }}" data-val="{{ val }}" data-pct="{{ '%.1f' % pct_f }}"></rect>
|
||||
</svg>
|
||||
<div class="muted" style="margin-top:.25rem;">{{ color }}</div>
|
||||
<!-- Sources Panel -->
|
||||
<div class="mana-panel" style="border:1px solid var(--border); border-radius:8px; padding:.6rem; background:#0f1115;">
|
||||
<div class="muted" style="margin-bottom:.35rem; font-weight:600;">Mana Sources</div>
|
||||
{% set mg = summary.mana_generation %}
|
||||
{% if mg %}
|
||||
{% set colors = deck_colors if deck_colors else ['W','U','B','R','G'] %}
|
||||
{% set ns = namespace(max_src=0) %}
|
||||
{% for color in colors %}
|
||||
{% set val = mg.get(color, 0) %}
|
||||
{% if val > ns.max_src %}{% set ns.max_src = val %}{% endif %}
|
||||
{% endfor %}
|
||||
{% set denom = (ns.max_src if ns.max_src and ns.max_src > 0 else 1) %}
|
||||
<div style="display:flex; gap:14px; align-items:flex-end; height:140px;">
|
||||
{% for color in colors %}
|
||||
{% set val = mg.get(color, 0) %}
|
||||
{% set pct = (val * 100 / denom) | int %}
|
||||
<div style="text-align:center;">
|
||||
<svg width="28" height="120" aria-label="{{ color }} {{ val }}">
|
||||
{% set pct_f = (100.0 * (val / (mg.total_sources or 1))) %}
|
||||
<rect x="2" y="2" width="24" height="116" fill="#14171c" stroke="var(--border)" rx="4" ry="4"
|
||||
data-type="sources" data-color="{{ color }}" data-val="{{ val }}" data-pct="{{ '%.1f' % pct_f }}"></rect>
|
||||
{% set bar_h = (pct if pct>2 else 2) %}
|
||||
{% set y = 118 - bar_h %}
|
||||
<rect x="2" y="{{ y }}" width="24" height="{{ bar_h }}" fill="#10b981" rx="4" ry="4"
|
||||
data-type="sources" data-color="{{ color }}" data-val="{{ val }}" data-pct="{{ '%.1f' % pct_f }}"></rect>
|
||||
</svg>
|
||||
<div class="muted" style="margin-top:.25rem;">{{ color }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div class="muted" style="margin-top:.25rem;">Total sources: {{ mg.total_sources or 0 }}</div>
|
||||
{% else %}
|
||||
<div class="muted">No mana source data.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="muted" style="margin-top:.25rem;">Total sources: {{ mg.total_sources or 0 }}</div>
|
||||
{% else %}
|
||||
<div class="muted">No mana source data.</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<!-- Mana Curve (vertical bars) -->
|
||||
<section style="margin-top:1rem;">
|
||||
<h5>Mana Curve (non-lands)</h5>
|
||||
{% set mc = summary.mana_curve %}
|
||||
{% if mc %}
|
||||
{% set ts = mc.total_spells or 0 %}
|
||||
{% set denom = (ts if ts and ts > 0 else 1) %}
|
||||
<div style="display:flex; gap:14px; align-items:flex-end; height:140px;">
|
||||
{% for label in ['0','1','2','3','4','5','6+'] %}
|
||||
{% set val = mc.get(label, 0) %}
|
||||
{% set pct = (val * 100 / denom) | int %}
|
||||
<div style="text-align:center;">
|
||||
<svg width="28" height="120" aria-label="{{ label }} {{ val }}">
|
||||
{% set cards = (mc.cards[label] if mc.cards and (label in mc.cards) else []) %}
|
||||
{% set parts = [] %}
|
||||
{% for c in cards %}
|
||||
{% set _ = parts.append(c.name ~ ((" ×" ~ c.count) if c.count and c.count>1 else '')) %}
|
||||
{% endfor %}
|
||||
{% set cards_line = parts|join(' • ') %}
|
||||
{% set pct_f = (100.0 * (val / denom)) %}
|
||||
<rect x="2" y="2" width="24" height="116" fill="#14171c" stroke="var(--border)" rx="4" ry="4"
|
||||
data-type="curve" data-label="{{ label }}" data-val="{{ val }}" data-pct="{{ '%.1f' % pct_f }}" data-cards="{{ cards_line }}"></rect>
|
||||
{% set bar_h = (pct if pct>2 else 2) %}
|
||||
{% set y = 118 - bar_h %}
|
||||
<rect x="2" y="{{ y }}" width="24" height="{{ bar_h }}" fill="#f59e0b" rx="4" ry="4"
|
||||
data-type="curve" data-label="{{ label }}" data-val="{{ val }}" data-pct="{{ '%.1f' % pct_f }}" data-cards="{{ cards_line }}"></rect>
|
||||
</svg>
|
||||
<div class="muted" style="margin-top:.25rem;">{{ label }}</div>
|
||||
<!-- Curve Panel -->
|
||||
<div class="mana-panel" style="border:1px solid var(--border); border-radius:8px; padding:.6rem; background:#0f1115;">
|
||||
<div class="muted" style="margin-bottom:.35rem; font-weight:600;">Mana Curve (non-lands)</div>
|
||||
{% set mc = summary.mana_curve %}
|
||||
{% if mc %}
|
||||
{% set ts = mc.total_spells or 0 %}
|
||||
{% set denom = (ts if ts and ts > 0 else 1) %}
|
||||
<div style="display:flex; gap:14px; align-items:flex-end; height:140px;">
|
||||
{% for label in ['0','1','2','3','4','5','6+'] %}
|
||||
{% set val = mc.get(label, 0) %}
|
||||
{% set pct = (val * 100 / denom) | int %}
|
||||
<div style="text-align:center;">
|
||||
<svg width="28" height="120" aria-label="{{ label }} {{ val }}">
|
||||
{% set cards = (mc.cards[label] if mc.cards and (label in mc.cards) else []) %}
|
||||
{% set parts = [] %}
|
||||
{% for c in cards %}
|
||||
{% set _ = parts.append(c.name ~ ((" ×" ~ c.count) if c.count and c.count>1 else '')) %}
|
||||
{% endfor %}
|
||||
{% set cards_line = parts|join(' • ') %}
|
||||
{% set pct_f = (100.0 * (val / denom)) %}
|
||||
<rect x="2" y="2" width="24" height="116" fill="#14171c" stroke="var(--border)" rx="4" ry="4"
|
||||
data-type="curve" data-label="{{ label }}" data-val="{{ val }}" data-pct="{{ '%.1f' % pct_f }}" data-cards="{{ cards_line }}"></rect>
|
||||
{% set bar_h = (pct if pct>2 else 2) %}
|
||||
{% set y = 118 - bar_h %}
|
||||
<rect x="2" y="{{ y }}" width="24" height="{{ bar_h }}" fill="#f59e0b" rx="4" ry="4"
|
||||
data-type="curve" data-label="{{ label }}" data-val="{{ val }}" data-pct="{{ '%.1f' % pct_f }}" data-cards="{{ cards_line }}"></rect>
|
||||
</svg>
|
||||
<div class="muted" style="margin-top:.25rem;">{{ label }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div class="muted" style="margin-top:.25rem;">Total spells: {{ mc.total_spells or 0 }}</div>
|
||||
{% else %}
|
||||
<div class="muted">No curve data.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="muted" style="margin-top:.25rem;">Total spells: {{ mc.total_spells or 0 }}</div>
|
||||
{% else %}
|
||||
<div class="muted">No curve data.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Test Hand (7 random cards; duplicates allowed only for basic lands) -->
|
||||
|
|
@ -318,16 +329,15 @@
|
|||
var grid = document.getElementById('test-hand-grid');
|
||||
if (!grid) return;
|
||||
grid.innerHTML = '';
|
||||
var unique = compress(hand);
|
||||
unique.forEach(function(it){
|
||||
hand.forEach(function(name){
|
||||
if (!name) return;
|
||||
var div = document.createElement('div');
|
||||
div.className = 'stack-card';
|
||||
if (GC_SET && GC_SET.has(it.name)) {
|
||||
if (GC_SET && GC_SET.has(name)) {
|
||||
div.className += ' game-changer';
|
||||
}
|
||||
div.innerHTML = (
|
||||
'<img src="https://api.scryfall.com/cards/named?fuzzy=' + encodeURIComponent(it.name) + '&format=image&version=normal" alt="' + it.name + '" data-card-name="' + it.name + '" />' +
|
||||
'<div class="count-badge">' + it.count + 'x</div>'
|
||||
'<img src="https://api.scryfall.com/cards/named?fuzzy=' + encodeURIComponent(name) + '&format=image&version=normal" alt="' + name + '" data-card-name="' + name + '" />'
|
||||
);
|
||||
grid.appendChild(div);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue