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:
mwisnowski 2025-08-26 09:48:25 -07:00
parent 8fa040a05a
commit 0f73a85a4e
43 changed files with 4515 additions and 105 deletions

View 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,'&lt;') + '</div>';
}
if (tags) {
html += '<div class="line"><span class="label">Themes</span>' + tags.replace(/</g,'&lt;') + '</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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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>

View 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 %}

View 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 %}