mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-20 17:40:13 +01:00
Web UI: setup progress + logs folding, Finished Decks library, commander search UX (debounce, keyboard, highlights, color chips), ranking fixes (first-word priority, substring include), optional auto-select; setup start reliability (POST+GET), force runs, status with percent/ETA/timestamps; stepwise builder with added stage reporting and sidecar summaries; keyboard grid wrap-around; restrict commander search to eligible rows
This commit is contained in:
parent
8fa040a05a
commit
0f73a85a4e
43 changed files with 4515 additions and 105 deletions
152
code/web/templates/base.html
Normal file
152
code/web/templates/base.html
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<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" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="top-banner">
|
||||
<div class="top-inner">
|
||||
<h1>MTG Deckbuilder</h1>
|
||||
<div id="banner-status" class="banner-status">{% block banner_subtitle %}{% endblock %}</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="layout">
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<div class="mana-dots" aria-hidden="true">
|
||||
<span class="dot green"></span>
|
||||
<span class="dot blue"></span>
|
||||
<span class="dot red"></span>
|
||||
<span class="dot white"></span>
|
||||
<span class="dot black"></span>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="nav">
|
||||
<a href="/">Home</a>
|
||||
<a href="/build">Build</a>
|
||||
<a href="/configs">Build from JSON</a>
|
||||
{% if show_setup %}<a href="/setup">Setup/Tag</a>{% endif %}
|
||||
<a href="/decks">Finished Decks</a>
|
||||
{% if show_logs %}<a href="/logs">Logs</a>{% endif %}
|
||||
</nav>
|
||||
</aside>
|
||||
<main class="content">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</div>
|
||||
<style>
|
||||
.card-hover { position: fixed; pointer-events: none; z-index: 9999; display: none; }
|
||||
.card-hover-inner { display:flex; gap:12px; align-items:flex-start; }
|
||||
.card-hover img { width: 320px; height: auto; display: block; border-radius: 8px; box-shadow: 0 6px 18px rgba(0,0,0,.55); border: 1px solid var(--border); background:#0f1115; }
|
||||
.card-meta { background: #0f1115; color: #e5e7eb; border: 1px solid var(--border); border-radius: 8px; padding: .5rem .6rem; max-width: 280px; font-size: 12px; line-height: 1.35; box-shadow: 0 6px 18px rgba(0,0,0,.35); }
|
||||
.card-meta .label { color:#94a3b8; text-transform: uppercase; font-size: 10px; letter-spacing: .04em; display:block; margin-bottom:.15rem; }
|
||||
.card-meta .line + .line { margin-top:.35rem; }
|
||||
</style>
|
||||
<script>
|
||||
(function(){
|
||||
// Setup/Tagging status poller
|
||||
var statusEl;
|
||||
function ensureStatusEl(){
|
||||
if (!statusEl) statusEl = document.getElementById('banner-status');
|
||||
return statusEl;
|
||||
}
|
||||
function renderSetupStatus(data){
|
||||
var el = ensureStatusEl(); if (!el) return;
|
||||
if (data && data.running) {
|
||||
var msg = (data.message || 'Preparing data...');
|
||||
el.innerHTML = '<strong>Setup/Tagging:</strong> ' + msg + ' <a href="/setup/running" style="margin-left:.5rem;">View progress</a>';
|
||||
el.classList.add('busy');
|
||||
} else if (data && data.phase === 'done') {
|
||||
el.innerHTML = '<span class="muted">Setup complete.</span>';
|
||||
setTimeout(function(){ el.innerHTML = ''; el.classList.remove('busy'); }, 3000);
|
||||
} else if (data && data.phase === 'error') {
|
||||
el.innerHTML = '<span class="error">Setup error.</span>';
|
||||
setTimeout(function(){ el.innerHTML = ''; el.classList.remove('busy'); }, 5000);
|
||||
} else {
|
||||
if (!el.innerHTML.trim()) el.innerHTML = '';
|
||||
el.classList.remove('busy');
|
||||
}
|
||||
}
|
||||
function pollStatus(){
|
||||
try {
|
||||
fetch('/status/setup', { cache: 'no-store' })
|
||||
.then(function(r){ return r.json(); })
|
||||
.then(renderSetupStatus)
|
||||
.catch(function(){ /* noop */ });
|
||||
} catch(e) {}
|
||||
}
|
||||
setInterval(pollStatus, 3000);
|
||||
pollStatus();
|
||||
|
||||
function ensureCard() {
|
||||
var pop = document.getElementById('card-hover');
|
||||
if (!pop) {
|
||||
pop = document.createElement('div');
|
||||
pop.id = 'card-hover';
|
||||
pop.className = 'card-hover';
|
||||
var inner = document.createElement('div');
|
||||
inner.className = 'card-hover-inner';
|
||||
var img = document.createElement('img');
|
||||
img.alt = 'Card preview';
|
||||
var meta = document.createElement('div');
|
||||
meta.className = 'card-meta';
|
||||
inner.appendChild(img);
|
||||
inner.appendChild(meta);
|
||||
pop.appendChild(inner);
|
||||
document.body.appendChild(pop);
|
||||
}
|
||||
return pop;
|
||||
}
|
||||
var cardPop = ensureCard();
|
||||
function positionCard(e) {
|
||||
var x = e.clientX + 16, y = e.clientY + 16;
|
||||
cardPop.style.display = 'block';
|
||||
cardPop.style.left = x + 'px';
|
||||
cardPop.style.top = y + 'px';
|
||||
var rect = cardPop.getBoundingClientRect();
|
||||
var vw = window.innerWidth || document.documentElement.clientWidth;
|
||||
var vh = window.innerHeight || document.documentElement.clientHeight;
|
||||
if (x + rect.width + 8 > vw) cardPop.style.left = (e.clientX - rect.width - 16) + 'px';
|
||||
if (y + rect.height + 8 > vh) cardPop.style.top = (e.clientY - rect.height - 16) + 'px';
|
||||
}
|
||||
function attachCardHover() {
|
||||
document.querySelectorAll('[data-card-name]').forEach(function(el) {
|
||||
if (el.__cardHoverBound) return; // avoid duplicate bindings
|
||||
el.__cardHoverBound = true;
|
||||
el.addEventListener('mouseenter', function(e) {
|
||||
var img = cardPop.querySelector('img');
|
||||
var meta = cardPop.querySelector('.card-meta');
|
||||
var q = encodeURIComponent(el.getAttribute('data-card-name'));
|
||||
img.src = 'https://api.scryfall.com/cards/named?fuzzy=' + q + '&format=image&version=normal';
|
||||
var role = el.getAttribute('data-role') || '';
|
||||
var tags = el.getAttribute('data-tags') || '';
|
||||
if (role || tags) {
|
||||
var html = '';
|
||||
if (role) {
|
||||
html += '<div class="line"><span class="label">Role</span>' + role.replace(/</g,'<') + '</div>';
|
||||
}
|
||||
if (tags) {
|
||||
html += '<div class="line"><span class="label">Themes</span>' + tags.replace(/</g,'<') + '</div>';
|
||||
}
|
||||
meta.innerHTML = html;
|
||||
meta.style.display = '';
|
||||
} else {
|
||||
meta.style.display = 'none';
|
||||
meta.innerHTML = '';
|
||||
}
|
||||
positionCard(e);
|
||||
});
|
||||
el.addEventListener('mousemove', positionCard);
|
||||
el.addEventListener('mouseleave', function() { cardPop.style.display = 'none'; });
|
||||
});
|
||||
}
|
||||
attachCardHover();
|
||||
document.addEventListener('htmx:afterSwap', function() { attachCardHover(); });
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
1
code/web/templates/build/_banner_subtitle.html
Normal file
1
code/web/templates/build/_banner_subtitle.html
Normal file
|
|
@ -0,0 +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>
|
||||
214
code/web/templates/build/_step1.html
Normal file
214
code/web/templates/build/_step1.html
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
<section>
|
||||
<h3>Step 1: Choose a Commander</h3>
|
||||
|
||||
<form id="cmdr-search-form" hx-post="/build/step1" hx-target="#wizard" hx-swap="innerHTML">
|
||||
<label>Search by name</label>
|
||||
<input id="cmdr-search" type="text" name="query" value="{{ query or '' }}" autocomplete="off" />
|
||||
<button type="submit">Search</button>
|
||||
<label style="margin-left:.5rem; font-weight:normal;">
|
||||
<input type="checkbox" name="auto" value="1" {% if auto %}checked{% endif %} /> Auto-select top match (very confident)
|
||||
</label>
|
||||
</form>
|
||||
<div class="muted" style="margin:.35rem 0 .5rem 0; font-size:.9rem;">
|
||||
Tip: Press Enter to select the highlighted result, or use Up/Down to navigate. If your query is a full first word (e.g., "vivi"), exact first-word matches are prioritized.
|
||||
</div>
|
||||
|
||||
{% if candidates %}
|
||||
<h4>Top matches</h4>
|
||||
<div class="candidate-grid" id="candidate-grid">
|
||||
{% for name, score, colors in candidates %}
|
||||
<div class="candidate-tile" data-card-name="{{ name }}">
|
||||
<form hx-post="/build/step1/confirm" hx-target="#wizard" hx-swap="innerHTML">
|
||||
<input type="hidden" name="name" value="{{ name }}" />
|
||||
<button class="img-btn" type="submit" title="Select {{ name }} (score {{ score }})">
|
||||
<img src="https://api.scryfall.com/cards/named?fuzzy={{ name|urlencode }}&format=image&version=normal"
|
||||
alt="{{ name }}" />
|
||||
</button>
|
||||
</form>
|
||||
<div class="meta">
|
||||
<div class="name"><span class="name-text">{{ name }}</span></div>
|
||||
<div class="score">
|
||||
match {{ score }}%
|
||||
{% if colors %}
|
||||
<span class="colors" style="margin-left:.25rem;">
|
||||
{% for c in colors %}
|
||||
<span class="chip chip-{{ c|lower }}" title="{{ c }}">{{ c }}</span>
|
||||
{% endfor %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<form hx-post="/build/step1/inspect" hx-target="#wizard" hx-swap="innerHTML" style="margin-top:.25rem;">
|
||||
<input type="hidden" name="name" value="{{ name }}" />
|
||||
<button type="submit">Inspect</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if inspect and inspect.ok %}
|
||||
<div class="two-col two-col-left-rail">
|
||||
<aside class="card-preview card-sm" data-card-name="{{ selected }}">
|
||||
<a href="https://scryfall.com/search?q={{ selected|urlencode }}" target="_blank" rel="noopener">
|
||||
<img src="https://api.scryfall.com/cards/named?fuzzy={{ selected|urlencode }}&format=image&version=normal" alt="{{ selected }} card image" />
|
||||
</a>
|
||||
</aside>
|
||||
<div class="grow">
|
||||
<h4>Theme Tags</h4>
|
||||
{% if tags and tags|length > 0 %}
|
||||
<ul>
|
||||
{% for t in tags %}
|
||||
<li>{{ t }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="muted">No theme tags found for this commander.</p>
|
||||
{% endif %}
|
||||
<div style="margin-top:.75rem;">
|
||||
<form style="display:inline" hx-post="/build/step1/confirm" hx-target="#wizard" hx-swap="innerHTML">
|
||||
<input type="hidden" name="name" value="{{ selected }}" />
|
||||
<button>Use this commander</button>
|
||||
</form>
|
||||
<form style="display:inline" hx-post="/build/step1" hx-target="#wizard" hx-swap="innerHTML">
|
||||
<input type="hidden" name="query" value="" />
|
||||
<button>Back to search</button>
|
||||
</form>
|
||||
<form action="/build" method="get" style="display:inline; margin-left:.5rem;">
|
||||
<button type="submit">Start over</button>
|
||||
</form>
|
||||
</div>
|
||||
<div hx-get="/build/banner?step=Choose%20Commander&i=1&n=5" hx-trigger="load"></div>
|
||||
</div>
|
||||
</div>
|
||||
{% elif inspect and not inspect.ok %}
|
||||
<div style="color:#a00">{{ inspect.error }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if error %}
|
||||
<div style="color:#a00">{{ error }}</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
<script>
|
||||
(function(){
|
||||
var input = document.getElementById('cmdr-search');
|
||||
var form = document.getElementById('cmdr-search-form');
|
||||
var grid = document.getElementById('candidate-grid');
|
||||
if (!input || !form) return;
|
||||
// Debounce live search
|
||||
var t = null;
|
||||
function submit(){
|
||||
if (!form) return;
|
||||
// Trigger the HTMX post without clicking submit
|
||||
if (window.htmx) { window.htmx.trigger(form, 'submit'); }
|
||||
else { form.submit(); }
|
||||
}
|
||||
input.addEventListener('input', function(){
|
||||
if (t) clearTimeout(t);
|
||||
t = setTimeout(submit, 250);
|
||||
});
|
||||
// Keyboard navigation: up/down to move selection, Enter to choose/inspect
|
||||
document.addEventListener('keydown', function(e){
|
||||
if (!grid || !grid.children || grid.children.length === 0) return;
|
||||
var tiles = Array.prototype.slice.call(grid.querySelectorAll('.candidate-tile'));
|
||||
// Ensure something is selected by default
|
||||
var idx = tiles.findIndex(function(el){ return el.classList.contains('active'); });
|
||||
if (idx < 0 && tiles.length > 0) {
|
||||
tiles[0].classList.add('active');
|
||||
idx = 0;
|
||||
}
|
||||
|
||||
// Determine columns via first row's offsetTop count
|
||||
var cols = 1;
|
||||
if (tiles.length > 1) {
|
||||
var firstTop = tiles[0].offsetTop;
|
||||
cols = tiles.findIndex(function(el, i){ return i>0 && el.offsetTop !== firstTop; });
|
||||
if (cols === -1 || cols === 0) cols = tiles.length; // single row fallback
|
||||
}
|
||||
|
||||
function setActive(newIdx) {
|
||||
// Clamp to bounds; wrapping handled by callers
|
||||
newIdx = Math.max(0, Math.min(tiles.length - 1, newIdx));
|
||||
tiles.forEach(function(el){ el.classList.remove('active'); });
|
||||
tiles[newIdx].classList.add('active');
|
||||
tiles[newIdx].scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
||||
return newIdx;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
var total = tiles.length;
|
||||
var rows = Math.ceil(total / cols);
|
||||
var row = Math.floor(idx / cols);
|
||||
var col = idx % cols;
|
||||
var newRow = row + 1;
|
||||
if (newRow >= rows) newRow = 0; // wrap to first row
|
||||
var newIdx = newRow * cols + col;
|
||||
if (newIdx >= total) newIdx = total - 1; // clamp to last tile if last row shorter
|
||||
idx = setActive(newIdx);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
var totalU = tiles.length;
|
||||
var rowsU = Math.ceil(totalU / cols);
|
||||
var rowU = Math.floor(idx / cols);
|
||||
var colU = idx % cols;
|
||||
var newRowU = rowU - 1;
|
||||
if (newRowU < 0) newRowU = rowsU - 1; // wrap to last row
|
||||
var newIdxU = newRowU * cols + colU;
|
||||
if (newIdxU >= totalU) newIdxU = totalU - 1;
|
||||
idx = setActive(newIdxU);
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
var totalR = tiles.length;
|
||||
idx = setActive((idx + 1) % totalR);
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
var totalL = tiles.length;
|
||||
idx = setActive((idx - 1 + totalL) % totalL);
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
var active = tiles[idx];
|
||||
if (!active) return;
|
||||
var formSel = active.querySelector('form[hx-post="/build/step1/confirm"]');
|
||||
if (formSel) {
|
||||
if (window.htmx) { window.htmx.trigger(formSel, 'submit'); }
|
||||
else if (formSel.submit) { formSel.submit(); }
|
||||
else {
|
||||
var btn = active.querySelector('button');
|
||||
if (btn) btn.click();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
// Highlight matched text
|
||||
try {
|
||||
var q = (input.value || '').trim().toLowerCase();
|
||||
if (q && grid) {
|
||||
grid.querySelectorAll('.name-text').forEach(function(el){
|
||||
var txt = el.textContent || '';
|
||||
var low = txt.toLowerCase();
|
||||
var i = low.indexOf(q);
|
||||
if (i >= 0) {
|
||||
el.innerHTML = txt.substring(0, i) + '<mark>' + txt.substring(i, i+q.length) + '</mark>' + txt.substring(i+q.length);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch(_){}
|
||||
})();
|
||||
</script>
|
||||
<style>
|
||||
.candidate-grid { display:grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap:.75rem; }
|
||||
.candidate-tile { border:1px solid var(--border); border-radius:8px; background:#0f1115; padding:.5rem; }
|
||||
.candidate-tile.active { outline:2px solid #3b82f6; }
|
||||
.img-btn { display:block; width:100%; background:transparent; border:0; padding:0; cursor:pointer; }
|
||||
.img-btn img { width:100%; height:auto; border-radius:6px; }
|
||||
.chip { display:inline-block; padding:0 .35rem; border-radius:999px; font-size:.75rem; line-height:1.4; border:1px solid var(--border); background:#151821; margin-left:.15rem; }
|
||||
.chip-w { background:#fdf4d6; color:#6b4f00; border-color:#e9d8a6; }
|
||||
.chip-u { background:#dbeafe; color:#1e40af; border-color:#93c5fd; }
|
||||
.chip-b { background:#e5e7eb; color:#111827; border-color:#9ca3af; }
|
||||
.chip-g { background:#dcfce7; color:#065f46; border-color:#86efac; }
|
||||
.chip-r { background:#fee2e2; color:#991b1b; border-color:#fecaca; }
|
||||
.chip-c { background:#f3f4f6; color:#111827; border-color:#e5e7eb; }
|
||||
mark { background: rgba(251, 191, 36, .35); color: inherit; padding:0 .1rem; border-radius:2px; }
|
||||
.candidate-tile { cursor: pointer; }
|
||||
</style>
|
||||
77
code/web/templates/build/_step2.html
Normal file
77
code/web/templates/build/_step2.html
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
<section>
|
||||
<h3>Step 2: Tags & Bracket</h3>
|
||||
<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">
|
||||
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander.name|urlencode }}&format=image&version=normal" alt="{{ commander.name }} card image" />
|
||||
</a>
|
||||
</aside>
|
||||
<div class="grow">
|
||||
<div hx-get="/build/banner?step=Tags%20%26%20Bracket&i=2&n=5" hx-trigger="load"></div>
|
||||
|
||||
<form hx-post="/build/step2" hx-target="#wizard" hx-swap="innerHTML">
|
||||
<input type="hidden" name="commander" value="{{ commander.name }}" />
|
||||
{% if error %}
|
||||
<div style="color:#a00; margin:.5rem 0;">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<fieldset>
|
||||
<legend>Theme Tags</legend>
|
||||
{% if tags %}
|
||||
<label>Primary
|
||||
<select name="primary_tag">
|
||||
<option value="">-- none --</option>
|
||||
{% for t in tags %}
|
||||
<option value="{{ t }}" {% if t == primary_tag %}selected{% endif %}>{{ t }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>Secondary
|
||||
<select name="secondary_tag">
|
||||
<option value="">-- none --</option>
|
||||
{% for t in tags %}
|
||||
<option value="{{ t }}" {% if t == secondary_tag %}selected{% endif %}>{{ t }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>Tertiary
|
||||
<select name="tertiary_tag">
|
||||
<option value="">-- none --</option>
|
||||
{% for t in tags %}
|
||||
<option value="{{ t }}" {% if t == tertiary_tag %}selected{% endif %}>{{ t }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
{% else %}
|
||||
<p>No theme tags available for this commander.</p>
|
||||
{% endif %}
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Budget/Power Bracket</legend>
|
||||
<div style="display:grid; gap:.5rem;">
|
||||
{% for b in brackets %}
|
||||
<label style="display:flex; gap:.5rem; align-items:flex-start;">
|
||||
<input type="radio" name="bracket" value="{{ b.level }}" {% if (selected_bracket is defined and selected_bracket == b.level) or (selected_bracket is not defined and loop.first) %}checked{% endif %} />
|
||||
<span><strong>{{ b.name }}</strong> — <small>{{ b.desc }}</small></span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="muted" style="margin-top:.35rem; font-size:.9em;">
|
||||
Note: This guides deck creation and relaxes/raises constraints, but it is not a guarantee the final deck strictly fits that bracket.
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div style="margin-top:1rem;">
|
||||
<button type="submit">Continue to Ideals</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div style="margin-top:.5rem;">
|
||||
<form action="/build" method="get" style="display:inline; margin:0;">
|
||||
<button type="submit">Start over</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
44
code/web/templates/build/_step3.html
Normal file
44
code/web/templates/build/_step3.html
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
<section>
|
||||
<h3>Step 3: Ideal Counts</h3>
|
||||
<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">
|
||||
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=normal" alt="{{ commander }} card image" />
|
||||
</a>
|
||||
</aside>
|
||||
<div class="grow">
|
||||
<div hx-get="/build/banner?step=Ideal%20Counts&i=3&n=5" hx-trigger="load"></div>
|
||||
|
||||
|
||||
|
||||
{% if error %}
|
||||
<div style="color:#a00; margin:.5rem 0;">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<form hx-post="/build/step3" hx-target="#wizard" hx-swap="innerHTML">
|
||||
<fieldset>
|
||||
<legend>Card Type Targets</legend>
|
||||
<div style="display:grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap:.75rem;">
|
||||
{% for key, label in labels.items() %}
|
||||
<label>
|
||||
{{ label }}
|
||||
<input type="number" name="{{ key }}" min="0" value="{{ (values or defaults)[key] }}" />
|
||||
<small class="muted">Default: {{ defaults[key] }}</small>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div style="margin-top:1rem; display:flex; gap:.5rem;">
|
||||
<button type="submit">Continue to Review</button>
|
||||
<button type="button" hx-get="/build/step2" hx-target="#wizard" hx-swap="innerHTML">Back</button>
|
||||
</div>
|
||||
</form>
|
||||
<div style="margin-top:.5rem;">
|
||||
<form action="/build" method="get" style="display:inline; margin:0;">
|
||||
<button type="submit">Start over</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
28
code/web/templates/build/_step4.html
Normal file
28
code/web/templates/build/_step4.html
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<section>
|
||||
<h3>Step 4: Review</h3>
|
||||
<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">
|
||||
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=normal" alt="{{ commander }} card image" />
|
||||
</a>
|
||||
</aside>
|
||||
<div class="grow">
|
||||
<div hx-get="/build/banner?step=Review&i=4&n=5" hx-trigger="load"></div>
|
||||
<h4>Chosen Ideals</h4>
|
||||
<ul>
|
||||
{% for key, label in labels.items() %}
|
||||
<li>{{ label }}: <strong>{{ values[key] }}</strong></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<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>
|
||||
</form>
|
||||
<button type="button" hx-get="/build/step3" hx-target="#wizard" hx-swap="innerHTML">Back</button>
|
||||
<form action="/build" method="get" style="display:inline; margin:0;">
|
||||
<button type="submit">Start over</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
111
code/web/templates/build/_step5.html
Normal file
111
code/web/templates/build/_step5.html
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
<section>
|
||||
<h3>Step 5: Build</h3>
|
||||
<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" />
|
||||
</a>
|
||||
{% if status and status.startswith('Build complete') %}
|
||||
<div style="margin-top:.75rem; display:flex; gap:.35rem; flex-wrap:wrap;">
|
||||
{% if csv_path %}
|
||||
<form action="/files" method="get" target="_blank" style="display:inline; margin:0;">
|
||||
<input type="hidden" name="path" value="{{ csv_path }}" />
|
||||
<button type="submit">Download CSV</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if txt_path %}
|
||||
<form action="/files" method="get" target="_blank" style="display:inline; margin:0;">
|
||||
<input type="hidden" name="path" value="{{ txt_path }}" />
|
||||
<button type="submit">Download TXT</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</aside>
|
||||
<div class="grow">
|
||||
<div hx-get="/build/banner?step=Build&i=5&n=5" hx-trigger="load"></div>
|
||||
|
||||
<p>Commander: <strong>{{ commander }}</strong></p>
|
||||
<p>Tags: {{ tags|default([])|join(', ') }}</p>
|
||||
<p>Bracket: {{ bracket }}</p>
|
||||
|
||||
{% if i and n %}
|
||||
<div class="muted" style="margin:.25rem 0 .5rem 0;">Stage {{ i }}/{{ n }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if status %}
|
||||
<div style="margin-top:1rem;">
|
||||
<strong>Status:</strong> {{ status }}{% if stage_label %} — <em>{{ stage_label }}</em>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- 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;">
|
||||
<button type="submit">Start Build</button>
|
||||
</form>
|
||||
<form hx-post="/build/step5/continue" hx-target="#wizard" hx-swap="innerHTML" style="display:inline;">
|
||||
<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;">
|
||||
<button type="submit" {% if status and status.startswith('Build complete') %}disabled{% endif %}>Rerun Stage</button>
|
||||
</form>
|
||||
<button type="button" hx-get="/build/step4" hx-target="#wizard" hx-swap="innerHTML">Back</button>
|
||||
</div>
|
||||
|
||||
{% if added_cards %}
|
||||
<h4 style="margin-top:1rem;">Cards added this stage</h4>
|
||||
{% if stage_label and stage_label.startswith('Creatures') %}
|
||||
{% set groups = added_cards|groupby('sub_role') %}
|
||||
{% for g in groups %}
|
||||
{% set role = g.grouper %}
|
||||
{% if role %}
|
||||
{% set heading = 'Theme: ' + role.title() %}
|
||||
{% else %}
|
||||
{% set heading = 'Additional Picks' %}
|
||||
{% endif %}
|
||||
<h5 style="margin:.5rem 0 .25rem 0;">{{ heading }}</h5>
|
||||
<div class="card-grid">
|
||||
{% for c in g.list %}
|
||||
<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="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>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="card-grid">
|
||||
{% for c in added_cards %}
|
||||
<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="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>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if show_logs and log %}
|
||||
<details style="margin-top:1rem;">
|
||||
<summary>Show logs</summary>
|
||||
<pre style="margin-top:.5rem; white-space:pre-wrap; background:#0f1115; border:1px solid var(--border); padding:1rem; border-radius:8px; max-height:40vh; overflow:auto;">{{ log }}</pre>
|
||||
</details>
|
||||
{% endif %}
|
||||
|
||||
<!-- controls now above -->
|
||||
|
||||
{% if status and status.startswith('Build complete') %}
|
||||
{% if summary %}
|
||||
{% include "partials/deck_summary.html" %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
10
code/web/templates/build/index.html
Normal file
10
code/web/templates/build/index.html
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{% extends "base.html" %}
|
||||
{% block banner_subtitle %}Build a Deck{% endblock %}
|
||||
{% block content %}
|
||||
<h2>Build a Deck</h2>
|
||||
<div id="wizard">
|
||||
<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>
|
||||
<noscript><p>Enable JavaScript to use the wizard.</p></noscript>
|
||||
</div>
|
||||
{% endblock %}
|
||||
93
code/web/templates/configs/index.html
Normal file
93
code/web/templates/configs/index.html
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h2>Build from JSON</h2>
|
||||
<div style="display:grid; grid-template-columns: 1fr minmax(360px, 520px); gap:16px; align-items:start;">
|
||||
<p class="muted" style="max-width: 70ch; margin:0;">
|
||||
Run a non-interactive deck build using a saved JSON configuration. Upload a JSON file, view its details, or run it headlessly to generate deck exports and a build summary.
|
||||
</p>
|
||||
<div>
|
||||
<div style="display:flex; justify-content:space-between; align-items:center;">
|
||||
<strong style="font-size:14px;">Example: {{ example_name }}</strong>
|
||||
</div>
|
||||
<pre style="margin-top:.35rem; background:#0f1115; border:1px solid var(--border); padding:.75rem; border-radius:8px; max-height:300px; overflow:auto; white-space:pre;">{{ example_json or '{\n "commander": "Your Commander Name",\n "primary_tag": "Your Main Theme",\n "secondary_tag": null,\n "tertiary_tag": null,\n "bracket_level": 0,\n "ideal_counts": {\n "ramp": 10,\n "lands": 35,\n "basic_lands": 20,\n "fetch_lands": 3,\n "creatures": 28,\n "removal": 10,\n "wipes": 2,\n "card_advantage": 8,\n "protection": 4\n }\n}' }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
{% if error %}<div class="error">{{ error }}</div>{% endif %}
|
||||
{% if notice %}<div class="notice">{{ notice }}</div>{% endif %}
|
||||
<div class="config-actions" style="margin-bottom:1rem; display:flex; gap:12px; align-items:center;">
|
||||
<form hx-post="/configs/upload" hx-target="#config-list" hx-swap="outerHTML" enctype="multipart/form-data">
|
||||
<button type="button" class="btn" onclick="this.nextElementSibling.click();">Upload JSON</button>
|
||||
<input id="upload-json" type="file" name="file" accept="application/json" style="display:none" onchange="this.form.requestSubmit();">
|
||||
</form>
|
||||
<input id="config-filter" type="search" placeholder="Filter by commander or tag..." style="flex:1; max-width:360px; padding:.4rem .6rem; border-radius:8px; border:1px solid var(--border); background:#0f1115; color:#e5e7eb;" />
|
||||
</div>
|
||||
<script>
|
||||
(function(){
|
||||
// Drag and drop upload support
|
||||
var form = document.querySelector('.config-actions form[enctype="multipart/form-data"]');
|
||||
var fileInput = document.getElementById('upload-json');
|
||||
if (form && fileInput) {
|
||||
form.addEventListener('dragover', function(e){ e.preventDefault(); form.style.outline = '2px dashed #334155'; });
|
||||
form.addEventListener('dragleave', function(){ form.style.outline = ''; });
|
||||
form.addEventListener('drop', function(e){
|
||||
e.preventDefault(); form.style.outline = '';
|
||||
if (e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files.length) {
|
||||
fileInput.files = e.dataTransfer.files;
|
||||
form.requestSubmit();
|
||||
}
|
||||
});
|
||||
}
|
||||
// Client-side filter of config list
|
||||
var filter = document.getElementById('config-filter');
|
||||
var list = document.getElementById('config-list');
|
||||
function applyFilter() {
|
||||
if (!list) return;
|
||||
var q = (filter.value || '').toLowerCase().trim();
|
||||
var items = list.querySelectorAll('li');
|
||||
items.forEach(function(li){
|
||||
var txt = (li.textContent || '').toLowerCase();
|
||||
li.style.display = (!q || txt.indexOf(q) !== -1) ? '' : 'none';
|
||||
});
|
||||
}
|
||||
if (filter) {
|
||||
filter.addEventListener('input', applyFilter);
|
||||
}
|
||||
document.addEventListener('htmx:afterSwap', function(e){ if (e && e.target && e.target.id === 'config-list') applyFilter(); });
|
||||
})();
|
||||
</script>
|
||||
<div id="config-list">
|
||||
{% if not items %}
|
||||
<p>No configs found in /config. Export a run config from a build, or upload one here.</p>
|
||||
{% else %}
|
||||
<ul class="file-list" style="list-style: none; margin: 0; padding: 0;">
|
||||
{% for it in items %}
|
||||
<li>
|
||||
<div class="row" style="display:flex; justify-content:space-between; align-items:center; gap:12px;">
|
||||
<div style="display:flex; align-items:center; gap:10px;">
|
||||
<strong>
|
||||
{% if it.commander %}
|
||||
<span data-card-name="{{ it.commander }}" data-tags="{{ (it.tags|join(', ')) if it.tags else '' }}">{{ it.commander }}</span>
|
||||
{% else %}
|
||||
{{ it.name }}
|
||||
{% endif %}
|
||||
</strong>
|
||||
{% if it.tags %}<span style="color:#64748b;">[{{ ', '.join(it.tags) }}]</span>{% endif %}
|
||||
{% if it.bracket_level is not none %}<span class="badge">Bracket {{ it.bracket_level }}</span>{% endif %}
|
||||
</div>
|
||||
<div class="actions" style="display:flex; gap:8px;">
|
||||
<form method="get" action="/configs/view" style="display:inline;">
|
||||
<input type="hidden" name="name" value="{{ it.name }}" />
|
||||
<button type="submit" class="btn">View</button>
|
||||
</form>
|
||||
<form method="post" action="/configs/run" style="display:inline;">
|
||||
<input type="hidden" name="name" value="{{ it.name }}" />
|
||||
<button type="submit">Run</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
56
code/web/templates/configs/run_result.html
Normal file
56
code/web/templates/configs/run_result.html
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<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></div>
|
||||
{% endif %}
|
||||
|
||||
<div class="two-col two-col-left-rail">
|
||||
<aside class="card-preview">
|
||||
{% if 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" width="320" />
|
||||
</a>
|
||||
{% endif %}
|
||||
<div style="margin-top:.75rem; display:flex; gap:.35rem; flex-wrap:wrap;">
|
||||
{% if ok and csv_path %}
|
||||
<form action="/files" method="get" target="_blank" style="display:inline; margin:0;">
|
||||
<input type="hidden" name="path" value="{{ csv_path }}" />
|
||||
<button type="submit">Download CSV</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if ok and txt_path %}
|
||||
<form action="/files" method="get" target="_blank" style="display:inline; margin:0;">
|
||||
<input type="hidden" name="path" value="{{ txt_path }}" />
|
||||
<button type="submit">Download TXT</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<form method="get" action="/configs" style="display:inline; margin:0;">
|
||||
<button type="submit">Back to Build from JSON</button>
|
||||
</form>
|
||||
</div>
|
||||
</aside>
|
||||
<div class="grow">
|
||||
{% if not ok %}
|
||||
<div class="error">Build failed: {{ error }}</div>
|
||||
{% else %}
|
||||
<div class="notice">Build completed{% if commander %} — <strong>{{ commander }}</strong>{% endif %}</div>
|
||||
|
||||
|
||||
{% if summary %}
|
||||
{% include "partials/deck_summary.html" %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if show_logs %}
|
||||
<details style="margin-top:1rem;">
|
||||
<summary>Show logs</summary>
|
||||
<pre style="margin-top:.5rem; white-space:pre-wrap; background:#0f1115; border:1px solid var(--border); padding:1rem; border-radius:8px; max-height:40vh; overflow:auto;">{{ log }}</pre>
|
||||
</details>
|
||||
{% endif %}
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
24
code/web/templates/configs/run_summary.html
Normal file
24
code/web/templates/configs/run_summary.html
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h2>Run from Config: {{ cfg_name }}</h2>
|
||||
{% if not ok %}
|
||||
<div class="error">Build failed: {{ error }}</div>
|
||||
{% else %}
|
||||
<div class="notice">Build completed.</div>
|
||||
<div style="margin:.5rem 0;">
|
||||
{% if csv_path %}<a class="btn" href="/files?path={{ csv_path | urlencode }}">Download CSV</a>{% endif %}
|
||||
{% if txt_path %}<a class="btn" href="/files?path={{ txt_path | urlencode }}">Download TXT</a>{% endif %}
|
||||
</div>
|
||||
{% if summary %}
|
||||
<hr style="margin:1.25rem 0; border-color: var(--border);" />
|
||||
<h4>Deck Summary</h4>
|
||||
{# Reuse the same inner sections as the build summary page by including its markup #}
|
||||
{% set __summary = summary %}
|
||||
{% set summary = __summary %}
|
||||
{% include "build/_step5.html" ignore missing %}
|
||||
{% else %}
|
||||
<p class="muted">No summary data available.</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<p style="margin-top:1rem;"><a href="/configs">Back to Configs</a></p>
|
||||
{% endblock %}
|
||||
22
code/web/templates/configs/view.html
Normal file
22
code/web/templates/configs/view.html
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h2>Build from JSON: {{ name }}</h2>
|
||||
<p class="muted" style="max-width: 70ch;">Review the configuration details below, then run a non-interactive build to produce deck exports and a summary.</p>
|
||||
<details open>
|
||||
<summary>Overview</summary>
|
||||
<div class="grid" style="display:grid; grid-template-columns: 200px 1fr; gap:6px; max-width: 920px;">
|
||||
<div>Commander</div><div>{{ data.commander }}</div>
|
||||
<div>Tags</div><div>{{ data.primary_tag }}{% if data.secondary_tag %}, {{ data.secondary_tag }}{% endif %}{% if data.tertiary_tag %}, {{ data.tertiary_tag }}{% endif %}</div>
|
||||
<div>Bracket</div><div>{{ data.bracket_level }}</div>
|
||||
</div>
|
||||
</details>
|
||||
<details style="margin-top:1rem;" open>
|
||||
<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;">
|
||||
<input type="hidden" name="name" value="{{ name }}" />
|
||||
<button type="submit">Run Headless</button>
|
||||
<button type="submit" formaction="/configs" formmethod="get" class="btn" style="margin-left:.5rem;">Back</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
55
code/web/templates/decks/index.html
Normal file
55
code/web/templates/decks/index.html
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
{% extends "base.html" %}
|
||||
{% block banner_subtitle %}Finished Decks{% endblock %}
|
||||
{% block content %}
|
||||
<h2>Finished Decks</h2>
|
||||
<p class="muted">These are exported decklists from previous runs. Open a deck to view the final summary, download CSV/TXT, and inspect card types and curve.</p>
|
||||
|
||||
{% if error %}
|
||||
<div class="error">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<div style="margin:.75rem 0; display:flex; gap:.5rem; align-items:center;">
|
||||
<input type="text" id="deck-filter" placeholder="Filter decks…" style="max-width:280px;" />
|
||||
</div>
|
||||
|
||||
{% if items %}
|
||||
<div id="deck-list" style="list-style:none; padding:0; margin:0; display:block;">
|
||||
{% for it in items %}
|
||||
<div class="panel" data-name="{{ it.name }}" data-commander="{{ it.commander }}" data-tags="{{ (it.tags|join(' ')) if it.tags else '' }}" style="margin:0 0 .5rem 0;">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; gap:.5rem;">
|
||||
<div>
|
||||
<div>
|
||||
<strong data-card-name="{{ it.commander }}">{{ it.commander }}</strong>
|
||||
</div>
|
||||
{% if it.tags and it.tags|length %}
|
||||
<div class="muted" style="font-size:12px;">Themes: {{ it.tags|join(', ') }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div style="display:flex; gap:.35rem;">
|
||||
<form action="/decks/view" method="get" style="display:inline; margin:0;">
|
||||
<input type="hidden" name="name" value="{{ it.name }}" />
|
||||
<button type="submit">Open</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="muted">No exports yet. Run a build to create one.</div>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
var input = document.getElementById('deck-filter');
|
||||
if (!input) return;
|
||||
input.addEventListener('input', function(){
|
||||
var q = (input.value || '').toLowerCase();
|
||||
document.querySelectorAll('#deck-list .panel').forEach(function(row){
|
||||
var hay = (row.dataset.name + ' ' + row.dataset.commander + ' ' + (row.dataset.tags||'')).toLowerCase();
|
||||
row.style.display = hay.indexOf(q) >= 0 ? '' : 'none';
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
40
code/web/templates/decks/view.html
Normal file
40
code/web/templates/decks/view.html
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
{% extends "base.html" %}
|
||||
{% block banner_subtitle %}Finished Decks{% endblock %}
|
||||
{% block content %}
|
||||
<h2>Finished Deck</h2>
|
||||
<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>
|
||||
|
||||
<div style="display:grid; grid-template-columns: 360px 1fr; gap: 1rem; align-items:start; margin-top: .75rem;">
|
||||
<div>
|
||||
{% if commander %}
|
||||
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=normal" alt="{{ commander }}" style="width:320px; height:auto; border-radius:8px; border:1px solid var(--border); box-shadow: 0 6px 18px rgba(0,0,0,.55);" />
|
||||
<div class="muted" style="margin-top:.25rem;">Commander: <span data-card-name="{{ commander }}">{{ commander }}</span></div>
|
||||
{% endif %}
|
||||
<div style="margin-top:.75rem; display:flex; gap:.35rem; flex-wrap:wrap;">
|
||||
{% if csv_path %}
|
||||
<form action="/files" method="get" target="_blank" style="display:inline; margin:0;">
|
||||
<input type="hidden" name="path" value="{{ csv_path }}" />
|
||||
<button type="submit">Download CSV</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if txt_path %}
|
||||
<form action="/files" method="get" target="_blank" style="display:inline; margin:0;">
|
||||
<input type="hidden" name="path" value="{{ txt_path }}" />
|
||||
<button type="submit">Download TXT</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<form method="get" action="/decks" style="display:inline; margin:0;">
|
||||
<button type="submit">Back to Finished Decks</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{% if summary %}
|
||||
{% include "partials/deck_summary.html" %}
|
||||
{% else %}
|
||||
<div class="muted">No summary available.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
12
code/web/templates/home.html
Normal file
12
code/web/templates/home.html
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<section>
|
||||
<div class="actions-grid">
|
||||
<a class="action-button primary" href="/build">Build a Deck</a>
|
||||
<a class="action-button" href="/configs">Run a JSON Config</a>
|
||||
{% if show_setup %}<a class="action-button" href="/setup">Initial Setup</a>{% endif %}
|
||||
<a class="action-button" href="/decks">Finished Decks</a>
|
||||
{% if show_logs %}<a class="action-button" href="/logs">View Logs</a>{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
396
code/web/templates/partials/deck_summary.html
Normal file
396
code/web/templates/partials/deck_summary.html
Normal file
|
|
@ -0,0 +1,396 @@
|
|||
<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>
|
||||
|
||||
<!-- Card Type Breakdown with names-only list and hover preview -->
|
||||
<section style="margin-top:.5rem;">
|
||||
<h5>Card Types</h5>
|
||||
<div style="margin:.5rem 0 .25rem 0; display:flex; gap:.5rem; align-items:center;">
|
||||
<span class="muted">View:</span>
|
||||
<div class="seg" role="tablist" aria-label="Type view">
|
||||
<button type="button" class="seg-btn" data-view="list" aria-selected="true">List</button>
|
||||
<button type="button" class="seg-btn" data-view="thumbs">Thumbnails</button>
|
||||
</div>
|
||||
</div>
|
||||
{% set tb = summary.type_breakdown %}
|
||||
{% if tb and tb.counts %}
|
||||
<style>
|
||||
.seg { display:inline-flex; border:1px solid var(--border); border-radius:8px; overflow:hidden; }
|
||||
.seg-btn { background:#12161c; color:#e5e7eb; border:none; padding:.35rem .6rem; cursor:pointer; font-size:12px; }
|
||||
.seg-btn[aria-selected="true"] { background:#1f2937; }
|
||||
.typeview { margin-top:.25rem; }
|
||||
.typeview.hidden { display:none; }
|
||||
.stack-wrap { --card-w: 160px; --card-h: 224px; --cols: 9; --overlap: .5; overflow: visible; padding: 6px 0 calc(var(--card-h) * (1 - var(--overlap))) 0; }
|
||||
.stack-grid { display: grid; grid-template-columns: repeat(var(--cols), var(--card-w)); grid-auto-rows: calc(var(--card-h) * var(--overlap)); column-gap: 10px; }
|
||||
.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; }
|
||||
</style>
|
||||
<div id="typeview-list" class="typeview">
|
||||
{% for t in tb.order %}
|
||||
<div style="margin:.5rem 0 .25rem 0; font-weight:600;">
|
||||
{{ t }} — {{ tb.counts[t] }}{% if tb.total %} ({{ '%.1f' % (tb.counts[t] * 100.0 / tb.total) }}%){% endif %}
|
||||
</div>
|
||||
{% set clist = tb.cards.get(t, []) %}
|
||||
{% if clist %}
|
||||
<div style="display:grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap:.35rem .75rem; margin:.25rem 0 .75rem 0;">
|
||||
{% 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 %}
|
||||
<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>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="muted" style="margin-bottom:.75rem;">No cards in this type.</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div id="typeview-thumbs" class="typeview hidden">
|
||||
{% for t in tb.order %}
|
||||
<div style="margin:.5rem 0 .25rem 0; font-weight:600;">
|
||||
{{ t }} — {{ tb.counts[t] }}{% if tb.total %} ({{ '%.1f' % (tb.counts[t] * 100.0 / tb.total) }}%){% endif %}
|
||||
</div>
|
||||
{% set clist = tb.cards.get(t, []) %}
|
||||
{% if clist %}
|
||||
<div class="stack-wrap">
|
||||
<div class="stack-grid">
|
||||
{% for c in clist %}
|
||||
{% set cnt = c.count if c.count else 1 %}
|
||||
<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>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="muted" style="margin-bottom:.75rem;">No cards in this type.</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="muted">No type data available.</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
<script>
|
||||
(function(){
|
||||
var listBtn = document.querySelector('.seg-btn[data-view="list"]');
|
||||
var thumbsBtn = document.querySelector('.seg-btn[data-view="thumbs"]');
|
||||
var listView = document.getElementById('typeview-list');
|
||||
var thumbsView = document.getElementById('typeview-thumbs');
|
||||
|
||||
function recalcThumbCols() {
|
||||
if (thumbsView.classList.contains('hidden')) return;
|
||||
var wraps = thumbsView.querySelectorAll('.stack-wrap');
|
||||
wraps.forEach(function(sw){
|
||||
var grid = sw.querySelector('.stack-grid');
|
||||
if (!grid) return;
|
||||
var gridStyles = window.getComputedStyle(grid);
|
||||
var gap = parseFloat(gridStyles.columnGap) || 10;
|
||||
var swStyles = window.getComputedStyle(sw);
|
||||
var cardW = parseFloat(swStyles.getPropertyValue('--card-w')) || 160;
|
||||
var width = sw.clientWidth;
|
||||
if (!width || width < cardW) {
|
||||
sw.style.setProperty('--cols', '1');
|
||||
return;
|
||||
}
|
||||
var cols = Math.max(1, Math.floor((width + gap) / (cardW + gap)));
|
||||
sw.style.setProperty('--cols', String(cols));
|
||||
});
|
||||
}
|
||||
|
||||
function debounce(fn, ms){ var t; return function(){ clearTimeout(t); t = setTimeout(fn, ms); }; }
|
||||
var debouncedRecalc = debounce(recalcThumbCols, 100);
|
||||
window.addEventListener('resize', debouncedRecalc);
|
||||
document.addEventListener('htmx:afterSwap', debouncedRecalc);
|
||||
|
||||
function applyMode(mode){
|
||||
var isList = (mode !== 'thumbs');
|
||||
listView.classList.toggle('hidden', !isList);
|
||||
thumbsView.classList.toggle('hidden', isList);
|
||||
if (listBtn) listBtn.setAttribute('aria-selected', isList ? 'true' : 'false');
|
||||
if (thumbsBtn) thumbsBtn.setAttribute('aria-selected', isList ? 'false' : 'true');
|
||||
try { localStorage.setItem('summaryTypeView', mode); } catch(e) {}
|
||||
if (!isList) recalcThumbCols();
|
||||
}
|
||||
|
||||
if (listBtn && thumbsBtn) {
|
||||
listBtn.addEventListener('click', function(){ applyMode('list'); });
|
||||
thumbsBtn.addEventListener('click', function(){ applyMode('thumbs'); });
|
||||
}
|
||||
var initial = 'list';
|
||||
try { initial = localStorage.getItem('summaryTypeView') || 'list'; } catch(e) {}
|
||||
applyMode(initial);
|
||||
if (initial === 'thumbs') recalcThumbCols();
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- Mana Pip Distribution (vertical bars; only deck colors) -->
|
||||
<section style="margin-top:1rem;">
|
||||
<h5>Mana Pip Distribution (non-lands)</h5>
|
||||
{% set pd = summary.pip_distribution %}
|
||||
{% 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>
|
||||
{% endfor %}
|
||||
</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>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</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>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</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 %}
|
||||
</section>
|
||||
|
||||
<!-- Test Hand (7 random cards; duplicates allowed only for basic lands) -->
|
||||
<section style="margin-top:1rem;">
|
||||
<h5>Test Hand</h5>
|
||||
<div style="display:flex; gap:.5rem; align-items:center; margin-bottom:.5rem;">
|
||||
<button type="button" id="btn-new-hand">New Hand</button>
|
||||
<span class="muted" style="font-size:12px;">Draw 7 at random (no repeats except for basic lands).</span>
|
||||
</div>
|
||||
<div class="stack-wrap" id="test-hand" style="--card-w: 240px; --card-h: 336px; --overlap: .55; --cols: 7;">
|
||||
<div class="stack-grid" id="test-hand-grid"></div>
|
||||
</div>
|
||||
<script>
|
||||
(function(){
|
||||
var GC_SET = (function(){
|
||||
try {
|
||||
var els = document.querySelectorAll('#typeview-list .game-changer [data-card-name], #typeview-thumbs .game-changer [data-card-name]');
|
||||
var s = new Set();
|
||||
els.forEach(function(el){ var n = el.getAttribute('data-card-name'); if(n) s.add(n); });
|
||||
return s;
|
||||
} catch(e) { return new Set(); }
|
||||
})();
|
||||
var BASE_BASICS = ["Plains","Island","Swamp","Mountain","Forest","Wastes"];
|
||||
function isBasicLand(name){
|
||||
if (!name) return false;
|
||||
if (BASE_BASICS.indexOf(name) >= 0) return true;
|
||||
if (name.startsWith('Snow-Covered ')) {
|
||||
var base = name.substring('Snow-Covered '.length);
|
||||
return BASE_BASICS.indexOf(base) >= 0;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
function collectDeck(){
|
||||
var deck = [];
|
||||
document.querySelectorAll('#typeview-list span[data-card-name]').forEach(function(el){
|
||||
var name = el.getAttribute('data-card-name');
|
||||
var cnt = parseInt(el.getAttribute('data-count') || '1', 10);
|
||||
if (name) deck.push({ name: name, count: (isFinite(cnt) && cnt>0 ? cnt : 1) });
|
||||
});
|
||||
return deck;
|
||||
}
|
||||
function buildPool(deck){
|
||||
var pool = [];
|
||||
deck.forEach(function(it){
|
||||
var n = Math.max(1, parseInt(it.count || 1, 10));
|
||||
for (var i=0;i<n;i++){ pool.push(it.name); }
|
||||
});
|
||||
return pool;
|
||||
}
|
||||
function drawHand(deck){
|
||||
var pool = buildPool(deck);
|
||||
if (!pool.length) return [];
|
||||
var picked = {};
|
||||
var hand = [];
|
||||
var attempts = 0;
|
||||
while (hand.length < 7 && attempts < 500) {
|
||||
attempts++;
|
||||
var idx = Math.floor(Math.random() * pool.length);
|
||||
var name = pool[idx];
|
||||
if (!name) continue;
|
||||
var allowDup = isBasicLand(name);
|
||||
if (!allowDup && picked[name]) continue;
|
||||
hand.push(name);
|
||||
if (!allowDup) picked[name] = true;
|
||||
pool.splice(idx, 1);
|
||||
if (!pool.length) break;
|
||||
}
|
||||
return hand;
|
||||
}
|
||||
function compress(hand){
|
||||
var map = {};
|
||||
hand.forEach(function(n){ map[n] = (map[n]||0) + 1; });
|
||||
var out = [];
|
||||
Object.keys(map).forEach(function(n){ out.push({name:n, count: map[n]}); });
|
||||
out.sort(function(a,b){ return hand.indexOf(a.name) - hand.indexOf(b.name); });
|
||||
return out;
|
||||
}
|
||||
function render(hand){
|
||||
var grid = document.getElementById('test-hand-grid');
|
||||
if (!grid) return;
|
||||
grid.innerHTML = '';
|
||||
var unique = compress(hand);
|
||||
unique.forEach(function(it){
|
||||
var div = document.createElement('div');
|
||||
div.className = 'stack-card';
|
||||
if (GC_SET && GC_SET.has(it.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>'
|
||||
);
|
||||
grid.appendChild(div);
|
||||
});
|
||||
}
|
||||
function newHand(){ var deck = collectDeck(); render(drawHand(deck)); }
|
||||
var btn = document.getElementById('btn-new-hand');
|
||||
if (btn) btn.addEventListener('click', newHand);
|
||||
newHand();
|
||||
})();
|
||||
</script>
|
||||
</section>
|
||||
<style>
|
||||
.chart-tooltip { position: fixed; pointer-events: none; background: #0f1115; color: #e5e7eb; border: 1px solid var(--border); padding: .4rem .55rem; border-radius: 6px; font-size: 12px; line-height: 1.3; white-space: pre-line; z-index: 9999; display: none; box-shadow: 0 4px 16px rgba(0,0,0,.4); }
|
||||
</style>
|
||||
<script>
|
||||
(function() {
|
||||
function ensureTip() {
|
||||
var tip = document.getElementById('chart-tooltip');
|
||||
if (!tip) {
|
||||
tip = document.createElement('div');
|
||||
tip.id = 'chart-tooltip';
|
||||
tip.className = 'chart-tooltip';
|
||||
document.body.appendChild(tip);
|
||||
}
|
||||
return tip;
|
||||
}
|
||||
var tip = ensureTip();
|
||||
function position(e) {
|
||||
tip.style.display = 'block';
|
||||
var x = e.clientX + 12, y = e.clientY + 12;
|
||||
tip.style.left = x + 'px';
|
||||
tip.style.top = y + 'px';
|
||||
var rect = tip.getBoundingClientRect();
|
||||
var vw = window.innerWidth || document.documentElement.clientWidth;
|
||||
var vh = window.innerHeight || document.documentElement.clientHeight;
|
||||
if (x + rect.width + 8 > vw) tip.style.left = (e.clientX - rect.width - 12) + 'px';
|
||||
if (y + rect.height + 8 > vh) tip.style.top = (e.clientY - rect.height - 12) + 'px';
|
||||
}
|
||||
function compose(el) {
|
||||
var t = el.getAttribute('data-type');
|
||||
if (t === 'pips') {
|
||||
return el.dataset.color + ': ' + el.dataset.count + ' (' + el.dataset.pct + '%)';
|
||||
}
|
||||
if (t === 'sources') {
|
||||
return el.dataset.color + ': ' + el.dataset.val + ' (' + el.dataset.pct + '%)';
|
||||
}
|
||||
if (t === 'curve') {
|
||||
var cards = (el.dataset.cards || '').split(' • ').join('\n');
|
||||
return el.dataset.label + ': ' + el.dataset.val + ' (' + el.dataset.pct + '%)' + (cards ? '\n' + cards : '');
|
||||
}
|
||||
return el.getAttribute('aria-label') || '';
|
||||
}
|
||||
function attach() {
|
||||
document.querySelectorAll('[data-type]').forEach(function(el) {
|
||||
el.addEventListener('mouseenter', function(e) {
|
||||
tip.textContent = compose(el);
|
||||
position(e);
|
||||
});
|
||||
el.addEventListener('mousemove', position);
|
||||
el.addEventListener('mouseleave', function() { tip.style.display = 'none'; });
|
||||
});
|
||||
}
|
||||
attach();
|
||||
document.addEventListener('htmx:afterSwap', function() { attach(); });
|
||||
})();
|
||||
</script>
|
||||
162
code/web/templates/setup/index.html
Normal file
162
code/web/templates/setup/index.html
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<section>
|
||||
<h2>Setup / Tagging</h2>
|
||||
<p class="muted" style="max-width:70ch;">Prepare or refresh the card database and apply tags. You can run this anytime.</p>
|
||||
|
||||
<details open style="margin-top:.5rem;">
|
||||
<summary>Current Status</summary>
|
||||
<div id="setup-status" style="margin-top:.5rem; padding:1rem; border:1px solid var(--border); background:#0f1115; border-radius:8px;">
|
||||
<div class="muted">Status:</div>
|
||||
<div id="setup-status-line" style="margin-top:.25rem;">Checking…</div>
|
||||
<div id="setup-progress-line" class="muted" style="margin-top:.25rem; display:none;"></div>
|
||||
<div id="setup-progress-bar" style="margin-top:.25rem; width:100%; height:10px; background:#151821; border:1px solid var(--border); border-radius:6px; overflow:hidden; display:none;">
|
||||
<div id="setup-progress-bar-inner" style="height:100%; width:0%; background:#3b82f6;"></div>
|
||||
</div>
|
||||
<div id="setup-time-line" class="muted" style="margin-top:.25rem; display:none;"></div>
|
||||
<div id="setup-color-line" class="muted" style="margin-top:.25rem; display:none;"></div>
|
||||
<details id="setup-log-wrap" style="margin-top:.5rem; display:none;">
|
||||
<summary id="setup-log-summary" class="muted" style="cursor:pointer;">Show logs</summary>
|
||||
<pre id="setup-log-tail" style="margin-top:.5rem; max-height:240px; overflow:auto; background:#0b0d12; border:1px solid var(--border); padding:.5rem; border-radius:6px;"></pre>
|
||||
</details>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div style="margin-top:1rem; display:flex; gap:.5rem; flex-wrap:wrap;">
|
||||
<form id="frm-start-setup" action="/setup/start" method="post" onsubmit="event.preventDefault(); startSetup();">
|
||||
<button type="submit" id="btn-start-setup">Run Setup/Tagging</button>
|
||||
<label class="muted" style="margin-left:.75rem; font-size:.9rem;">
|
||||
<input type="checkbox" id="chk-force" checked /> Force run
|
||||
</label>
|
||||
</form>
|
||||
<form method="get" action="/setup/running?start=1&force=1">
|
||||
<button type="submit">Open Progress Page</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
<script>
|
||||
(function(){
|
||||
function update(data){
|
||||
var line = document.getElementById('setup-status-line');
|
||||
var colorEl = document.getElementById('setup-color-line');
|
||||
var logEl = document.getElementById('setup-log-tail');
|
||||
var progEl = document.getElementById('setup-progress-line');
|
||||
var timeEl = document.getElementById('setup-time-line');
|
||||
var bar = document.getElementById('setup-progress-bar');
|
||||
var barIn = document.getElementById('setup-progress-bar-inner');
|
||||
var logWrap = document.getElementById('setup-log-wrap');
|
||||
var logSummary = document.getElementById('setup-log-summary');
|
||||
if (!line) return;
|
||||
if (data && data.running) {
|
||||
line.textContent = (data.message || 'Working…');
|
||||
if (typeof data.percent === 'number') {
|
||||
progEl.style.display = '';
|
||||
var p = Math.max(0, Math.min(100, data.percent));
|
||||
progEl.textContent = 'Progress: ' + p + '%';
|
||||
if (bar && barIn) { bar.style.display = ''; barIn.style.width = p + '%'; }
|
||||
if (typeof data.color_idx === 'number' && typeof data.color_total === 'number') {
|
||||
progEl.textContent += ' • Colors: ' + data.color_idx + ' / ' + data.color_total;
|
||||
}
|
||||
if (typeof data.eta_seconds === 'number') {
|
||||
var mins = Math.floor(data.eta_seconds / 60); var secs = data.eta_seconds % 60;
|
||||
progEl.textContent += ' • ETA: ~' + mins + 'm ' + secs + 's';
|
||||
}
|
||||
} else {
|
||||
progEl.style.display = 'none';
|
||||
if (bar) bar.style.display = 'none';
|
||||
}
|
||||
if (data.started_at) {
|
||||
timeEl.style.display = '';
|
||||
timeEl.textContent = 'Started: ' + data.started_at;
|
||||
} else {
|
||||
timeEl.style.display = 'none';
|
||||
}
|
||||
if (data.color) {
|
||||
colorEl.style.display = '';
|
||||
colorEl.textContent = 'Current color: ' + data.color;
|
||||
} else {
|
||||
colorEl.style.display = 'none';
|
||||
}
|
||||
if (data.log_tail) {
|
||||
var lines = data.log_tail.split(/\r?\n/).filter(function(x){ return x.trim() !== ''; });
|
||||
if (logWrap) logWrap.style.display = '';
|
||||
if (logSummary) logSummary.textContent = 'Show logs (' + lines.length + ' lines)';
|
||||
logEl.textContent = data.log_tail;
|
||||
} else {
|
||||
if (logWrap) logWrap.style.display = 'none';
|
||||
}
|
||||
} else if (data && data.phase === 'done') {
|
||||
line.textContent = 'Setup complete.';
|
||||
if (typeof data.percent === 'number') {
|
||||
progEl.style.display = '';
|
||||
var p2 = Math.max(0, Math.min(100, data.percent));
|
||||
progEl.textContent = 'Progress: ' + p2 + '%';
|
||||
if (bar && barIn) { bar.style.display = ''; barIn.style.width = p2 + '%'; }
|
||||
} else {
|
||||
progEl.style.display = 'none';
|
||||
if (bar) bar.style.display = 'none';
|
||||
}
|
||||
if (data.started_at || data.finished_at) {
|
||||
timeEl.style.display = '';
|
||||
var t = [];
|
||||
if (data.started_at) t.push('Started: ' + data.started_at);
|
||||
if (data.finished_at) t.push('Finished: ' + data.finished_at);
|
||||
timeEl.textContent = t.join(' • ');
|
||||
} else {
|
||||
timeEl.style.display = 'none';
|
||||
}
|
||||
colorEl.style.display = 'none';
|
||||
if (logWrap) logWrap.style.display = 'none';
|
||||
} else if (data && data.phase === 'error') {
|
||||
line.textContent = (data.message || 'Setup error.');
|
||||
if (data.color) {
|
||||
colorEl.style.display = '';
|
||||
colorEl.textContent = 'While working on: ' + data.color;
|
||||
}
|
||||
} else {
|
||||
line.textContent = 'Idle';
|
||||
progEl.style.display = 'none';
|
||||
timeEl.style.display = 'none';
|
||||
colorEl.style.display = 'none';
|
||||
if (logWrap) logWrap.style.display = 'none';
|
||||
}
|
||||
}
|
||||
function poll(){
|
||||
fetch('/status/setup', { cache: 'no-store' })
|
||||
.then(function(r){ return r.json(); })
|
||||
.then(update)
|
||||
.catch(function(){});
|
||||
}
|
||||
function rapidPoll(times, delay){
|
||||
var i = 0;
|
||||
function tick(){
|
||||
poll();
|
||||
i++;
|
||||
if (i < times) setTimeout(tick, delay);
|
||||
}
|
||||
tick();
|
||||
}
|
||||
window.startSetup = function(){
|
||||
var btn = document.getElementById('btn-start-setup');
|
||||
var line = document.getElementById('setup-status-line');
|
||||
var force = document.getElementById('chk-force') && document.getElementById('chk-force').checked;
|
||||
if (btn) btn.disabled = true;
|
||||
if (line) line.textContent = 'Starting setup/tagging…';
|
||||
// First try POST with JSON body
|
||||
fetch('/setup/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ force: !!force }) })
|
||||
.then(function(r){ if (!r.ok) throw new Error('POST failed'); return r.json().catch(function(){ return {}; }); })
|
||||
.then(function(){ rapidPoll(5, 600); setTimeout(function(){ window.location.href = '/setup/running?start=1' + (force ? '&force=1' : ''); }, 500); })
|
||||
.catch(function(){
|
||||
// Fallback to GET if POST fails (proxy/middleware issues)
|
||||
var url = '/setup/start' + (force ? '?force=1' : '');
|
||||
fetch(url, { method: 'GET', cache: 'no-store' })
|
||||
.then(function(){ rapidPoll(5, 600); setTimeout(function(){ window.location.href = '/setup/running?start=1' + (force ? '&force=1' : ''); }, 500); })
|
||||
.catch(function(){});
|
||||
})
|
||||
.finally(function(){ if (btn) btn.disabled = false; });
|
||||
};
|
||||
setInterval(poll, 3000);
|
||||
poll();
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
134
code/web/templates/setup/running.html
Normal file
134
code/web/templates/setup/running.html
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<section>
|
||||
<h2>Preparing Card Database</h2>
|
||||
<p class="muted">Initial setup and tagging may take several minutes on first run.</p>
|
||||
|
||||
<div id="setup-status" style="margin-top:1rem; padding:1rem; border:1px solid var(--border); background:#0f1115; border-radius:8px;" data-next-url="{{ next_url or '' }}">
|
||||
<div class="muted">Status:</div>
|
||||
<div id="setup-status-line" style="margin-top:.25rem;">Starting…</div>
|
||||
<div id="setup-progress-line" class="muted" style="margin-top:.25rem; display:none;"></div>
|
||||
<div id="setup-progress-bar" style="margin-top:.25rem; width:100%; height:10px; background:#151821; border:1px solid var(--border); border-radius:6px; overflow:hidden; display:none;">
|
||||
<div id="setup-progress-bar-inner" style="height:100%; width:0%; background:#3b82f6;"></div>
|
||||
</div>
|
||||
<div id="setup-time-line" class="muted" style="margin-top:.25rem; display:none;"></div>
|
||||
<div id="setup-color-line" class="muted" style="margin-top:.25rem; display:none;"></div>
|
||||
<details id="setup-log-wrap" style="margin-top:.5rem; display:none;">
|
||||
<summary id="setup-log-summary" class="muted" style="cursor:pointer;">Show logs</summary>
|
||||
<pre id="setup-log-tail" style="margin-top:.5rem; max-height:240px; overflow:auto; background:#0b0d12; border:1px solid var(--border); padding:.5rem; border-radius:6px;"></pre>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:1rem; display:flex; gap:.5rem;">
|
||||
<form method="get" action="/setup">
|
||||
<button type="submit">Back to Setup</button>
|
||||
</form>
|
||||
{% if next_url %}
|
||||
<form method="get" action="{{ next_url }}">
|
||||
<button type="submit">Continue</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
<script>
|
||||
(function(){
|
||||
var container = document.getElementById('setup-status');
|
||||
var nextUrl = (container && container.dataset.nextUrl) ? container.dataset.nextUrl : null;
|
||||
if (nextUrl === '') nextUrl = null;
|
||||
function update(data){
|
||||
var line = document.getElementById('setup-status-line');
|
||||
var colorEl = document.getElementById('setup-color-line');
|
||||
var logEl = document.getElementById('setup-log-tail');
|
||||
var progEl = document.getElementById('setup-progress-line');
|
||||
var timeEl = document.getElementById('setup-time-line');
|
||||
var bar = document.getElementById('setup-progress-bar');
|
||||
var barIn = document.getElementById('setup-progress-bar-inner');
|
||||
var logWrap = document.getElementById('setup-log-wrap');
|
||||
var logSummary = document.getElementById('setup-log-summary');
|
||||
if (!line) return;
|
||||
if (data && data.running) {
|
||||
line.textContent = (data.message || 'Working…');
|
||||
if (typeof data.percent === 'number') {
|
||||
progEl.style.display = '';
|
||||
var p = Math.max(0, Math.min(100, data.percent));
|
||||
progEl.textContent = 'Progress: ' + p + '%';
|
||||
if (bar && barIn) { bar.style.display = ''; barIn.style.width = p + '%'; }
|
||||
if (typeof data.color_idx === 'number' && typeof data.color_total === 'number') {
|
||||
progEl.textContent += ' • Colors: ' + data.color_idx + ' / ' + data.color_total;
|
||||
}
|
||||
if (typeof data.eta_seconds === 'number') {
|
||||
var mins = Math.floor(data.eta_seconds / 60); var secs = data.eta_seconds % 60;
|
||||
progEl.textContent += ' • ETA: ~' + mins + 'm ' + secs + 's';
|
||||
}
|
||||
} else {
|
||||
progEl.style.display = 'none';
|
||||
if (bar) bar.style.display = 'none';
|
||||
}
|
||||
if (data.started_at) {
|
||||
timeEl.style.display = '';
|
||||
timeEl.textContent = 'Started: ' + data.started_at;
|
||||
} else {
|
||||
timeEl.style.display = 'none';
|
||||
}
|
||||
if (data.color) {
|
||||
colorEl.style.display = '';
|
||||
colorEl.textContent = 'Current color: ' + data.color;
|
||||
} else {
|
||||
colorEl.style.display = 'none';
|
||||
}
|
||||
if (data.log_tail) {
|
||||
var lines = data.log_tail.split(/\r?\n/).filter(function(x){ return x.trim() !== ''; });
|
||||
if (logWrap) logWrap.style.display = '';
|
||||
if (logSummary) logSummary.textContent = 'Show logs (' + lines.length + ' lines)';
|
||||
logEl.textContent = data.log_tail;
|
||||
} else {
|
||||
if (logWrap) logWrap.style.display = 'none';
|
||||
}
|
||||
} else if (data && data.phase === 'done') {
|
||||
line.textContent = 'Setup complete.';
|
||||
if (typeof data.percent === 'number') {
|
||||
progEl.style.display = '';
|
||||
var p2 = Math.max(0, Math.min(100, data.percent));
|
||||
progEl.textContent = 'Progress: ' + p2 + '%';
|
||||
if (bar && barIn) { bar.style.display = ''; barIn.style.width = p2 + '%'; }
|
||||
} else {
|
||||
progEl.style.display = 'none';
|
||||
if (bar) bar.style.display = 'none';
|
||||
}
|
||||
if (data.started_at || data.finished_at) {
|
||||
timeEl.style.display = '';
|
||||
var t = [];
|
||||
if (data.started_at) t.push('Started: ' + data.started_at);
|
||||
if (data.finished_at) t.push('Finished: ' + data.finished_at);
|
||||
timeEl.textContent = t.join(' • ');
|
||||
} else {
|
||||
timeEl.style.display = 'none';
|
||||
}
|
||||
colorEl.style.display = 'none';
|
||||
if (logWrap) logWrap.style.display = 'none';
|
||||
if (nextUrl) {
|
||||
setTimeout(function(){ window.location.href = nextUrl; }, 1200);
|
||||
}
|
||||
} else if (data && data.phase === 'error') {
|
||||
line.textContent = (data.message || 'Setup error.');
|
||||
if (data.color) {
|
||||
colorEl.style.display = '';
|
||||
colorEl.textContent = 'While working on: ' + data.color;
|
||||
}
|
||||
} else {
|
||||
line.textContent = 'Idle';
|
||||
colorEl.style.display = 'none';
|
||||
if (logWrap) logWrap.style.display = 'none';
|
||||
}
|
||||
}
|
||||
function poll(){
|
||||
fetch('/status/setup', { cache: 'no-store' })
|
||||
.then(function(r){ return r.json(); })
|
||||
.then(update)
|
||||
.catch(function(){});
|
||||
}
|
||||
setInterval(poll, 3000);
|
||||
poll();
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
Loading…
Add table
Add a link
Reference in a new issue