mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-20 17:40:13 +01:00
Web UI polish: thumbnail-hover preview, white thumbnail selection, Themes bullet list; global Scryfall image retry (thumbs+previews) with fallbacks and cache-bust; standardized data-card-name. Deck Summary alignment overhaul (count//name/owned grid, tabular numerals, inset highlight, tooltips, starts under header). Added diagnostics (health + logs pages, error pages, request-id propagation), global HTMX error toasts, and docs updates. Update DOCKER guide and add run-web scripts. Update CHANGELOG and release notes template.
This commit is contained in:
parent
8d1f6a8ac4
commit
f8c6b5c07e
30 changed files with 786 additions and 232 deletions
|
|
@ -15,7 +15,10 @@
|
|||
<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 style="display:flex; align-items:center; gap:.5rem">
|
||||
<span id="health-dot" class="health-dot" title="Health"></span>
|
||||
<div id="banner-status" class="banner-status">{% block banner_subtitle %}{% endblock %}</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="layout">
|
||||
|
|
@ -36,10 +39,11 @@
|
|||
{% if show_setup %}<a href="/setup">Setup/Tag</a>{% endif %}
|
||||
<a href="/owned">Owned Library</a>
|
||||
<a href="/decks">Finished Decks</a>
|
||||
{% if show_diagnostics %}<a href="/diagnostics">Diagnostics</a>{% endif %}
|
||||
{% if show_logs %}<a href="/logs">Logs</a>{% endif %}
|
||||
</nav>
|
||||
</aside>
|
||||
<main class="content">
|
||||
<main class="content" data-error-surface>
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</div>
|
||||
|
|
@ -52,8 +56,12 @@
|
|||
.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 { background: #0f1115; color: #e5e7eb; border: 1px solid var(--border); border-radius: 8px; padding: .5rem .6rem; max-width: 320px; font-size: 13px; line-height: 1.4; box-shadow: 0 6px 18px rgba(0,0,0,.35); }
|
||||
.card-meta ul { margin:.25rem 0; padding-left: 1.1rem; list-style: disc; }
|
||||
.card-meta li { margin:.1rem 0; }
|
||||
.card-meta .themes-list { font-size: 18px; line-height: 1.35; }
|
||||
.card-meta .label { color:#94a3b8; text-transform: uppercase; font-size: 10px; letter-spacing: .04em; display:block; margin-bottom:.15rem; }
|
||||
.card-meta .themes-label { color:#f3f4f6; font-size: 20px; letter-spacing: .05em; }
|
||||
.card-meta .line + .line { margin-top:.35rem; }
|
||||
.site-footer { margin: 12px 16px 0; padding: 8px 12px; border-top: 1px solid var(--border); color: #94a3b8; font-size: 12px; text-align: center; }
|
||||
.site-footer a { color: #cbd5e1; text-decoration: underline; }
|
||||
|
|
@ -94,7 +102,26 @@
|
|||
setInterval(pollStatus, 3000);
|
||||
pollStatus();
|
||||
|
||||
function ensureCard() {
|
||||
// Health indicator poller
|
||||
var healthDot = document.getElementById('health-dot');
|
||||
function renderHealth(data){
|
||||
if (!healthDot) return;
|
||||
var ok = data && data.status === 'ok';
|
||||
healthDot.setAttribute('data-state', ok ? 'ok' : 'bad');
|
||||
if (!ok) { healthDot.title = 'Degraded'; } else { healthDot.title = 'OK'; }
|
||||
}
|
||||
function pollHealth(){
|
||||
try {
|
||||
fetch('/healthz', { cache: 'no-store' })
|
||||
.then(function(r){ return r.json(); })
|
||||
.then(renderHealth)
|
||||
.catch(function(){ renderHealth({ status: 'bad' }); });
|
||||
} catch(e){ renderHealth({ status: 'bad' }); }
|
||||
}
|
||||
setInterval(pollHealth, 5000);
|
||||
pollHealth();
|
||||
|
||||
function ensureCard() {
|
||||
var pop = document.getElementById('card-hover');
|
||||
if (!pop) {
|
||||
pop = document.createElement('div');
|
||||
|
|
@ -113,7 +140,65 @@
|
|||
}
|
||||
return pop;
|
||||
}
|
||||
var cardPop = ensureCard();
|
||||
var cardPop = ensureCard();
|
||||
var PREVIEW_VERSIONS = ['normal','large'];
|
||||
function buildCardUrl(name, version, nocache){
|
||||
var q = encodeURIComponent(name||'');
|
||||
var url = 'https://api.scryfall.com/cards/named?fuzzy=' + q + '&format=image&version=' + (version||'normal');
|
||||
if (nocache) url += '&t=' + Date.now();
|
||||
return url;
|
||||
}
|
||||
// Generic Scryfall image URL builder
|
||||
function buildScryfallImageUrl(name, version, nocache){
|
||||
var q = encodeURIComponent(name||'');
|
||||
var url = 'https://api.scryfall.com/cards/named?fuzzy=' + q + '&format=image&version=' + (version||'normal');
|
||||
if (nocache) url += '&t=' + Date.now();
|
||||
return url;
|
||||
}
|
||||
|
||||
// Global image retry binding for any <img data-card-name>
|
||||
var IMG_FLAG = '__cardImgRetry';
|
||||
function bindCardImageRetry(img, versions){
|
||||
try {
|
||||
if (!img || img[IMG_FLAG]) return;
|
||||
var name = img.getAttribute('data-card-name') || '';
|
||||
if (!name) return;
|
||||
img[IMG_FLAG] = { vi: 0, nocache: 0, versions: versions && versions.length ? versions.slice() : ['normal','large'] };
|
||||
img.addEventListener('error', function(){
|
||||
var st = img[IMG_FLAG];
|
||||
if (!st) return;
|
||||
if (st.vi < st.versions.length - 1){
|
||||
st.vi += 1;
|
||||
img.src = buildScryfallImageUrl(name, st.versions[st.vi], false);
|
||||
} else if (!st.nocache){
|
||||
st.nocache = 1;
|
||||
img.src = buildScryfallImageUrl(name, st.versions[st.vi], true);
|
||||
}
|
||||
});
|
||||
// If the initial load already failed before binding, try the next immediately
|
||||
if (img.complete && img.naturalWidth === 0){
|
||||
// If src corresponds to the first version, move to next; else, just force a reload
|
||||
var st = img[IMG_FLAG];
|
||||
var current = img.src || '';
|
||||
var first = buildScryfallImageUrl(name, st.versions[0], false);
|
||||
if (current.indexOf(encodeURIComponent(name)) !== -1 && current.indexOf('version='+st.versions[0]) !== -1){
|
||||
st.vi = Math.min(1, st.versions.length - 1);
|
||||
img.src = buildScryfallImageUrl(name, st.versions[st.vi], false);
|
||||
} else {
|
||||
// Re-trigger current request (may succeed if transient)
|
||||
img.src = current;
|
||||
}
|
||||
}
|
||||
} catch(_){}
|
||||
}
|
||||
function bindAllCardImageRetries(){
|
||||
document.querySelectorAll('img[data-card-name]').forEach(function(img){
|
||||
// Use thumbnail fallbacks for card-thumb, otherwise preview fallbacks
|
||||
var versions = (img.classList && img.classList.contains('card-thumb')) ? ['small','normal','large'] : ['normal','large'];
|
||||
bindCardImageRetry(img, versions);
|
||||
});
|
||||
}
|
||||
|
||||
function positionCard(e) {
|
||||
var x = e.clientX + 16, y = e.clientY + 16;
|
||||
cardPop.style.display = 'block';
|
||||
|
|
@ -132,17 +217,32 @@
|
|||
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 name = el.getAttribute('data-card-name') || '';
|
||||
var vi = 0; // always start at 'normal' on hover
|
||||
img.src = buildCardUrl(name, PREVIEW_VERSIONS[vi], false);
|
||||
// Bind a one-off error handler per enter to try fallbacks
|
||||
var triedNoCache = false;
|
||||
function onErr(){
|
||||
if (vi < PREVIEW_VERSIONS.length - 1){ vi += 1; img.src = buildCardUrl(name, PREVIEW_VERSIONS[vi], false); }
|
||||
else if (!triedNoCache){ triedNoCache = true; img.src = buildCardUrl(name, PREVIEW_VERSIONS[vi], true); }
|
||||
else { img.removeEventListener('error', onErr); }
|
||||
}
|
||||
img.addEventListener('error', onErr, { once:false });
|
||||
img.addEventListener('load', function onOk(){ img.removeEventListener('load', onOk); img.removeEventListener('error', onErr); });
|
||||
var role = el.getAttribute('data-role') || '';
|
||||
var tags = el.getAttribute('data-tags') || '';
|
||||
if (role || tags) {
|
||||
var rawTags = el.getAttribute('data-tags') || '';
|
||||
// Clean and split tags into an array; remove brackets and quotes
|
||||
var tags = rawTags
|
||||
.replace(/[\[\]\u2018\u2019'\u201C\u201D"]/g,'')
|
||||
.split(/\s*,\s*/)
|
||||
.filter(function(t){ return t && t.trim(); });
|
||||
if (role || (tags && tags.length)) {
|
||||
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>';
|
||||
if (tags && tags.length) {
|
||||
html += '<div class="line"><span class="label themes-label">Themes</span><ul class="themes-list">' + tags.map(function(t){ return '<li>' + t.replace(/</g,'<') + '</li>'; }).join('') + '</ul></div>';
|
||||
}
|
||||
meta.innerHTML = html;
|
||||
meta.style.display = '';
|
||||
|
|
@ -157,7 +257,8 @@
|
|||
});
|
||||
}
|
||||
attachCardHover();
|
||||
document.addEventListener('htmx:afterSwap', function() { attachCardHover(); });
|
||||
bindAllCardImageRetries();
|
||||
document.addEventListener('htmx:afterSwap', function() { attachCardHover(); bindAllCardImageRetries(); });
|
||||
})();
|
||||
</script>
|
||||
<script src="/static/app.js?v=20250826-2"></script>
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@
|
|||
<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"
|
||||
<img src="https://api.scryfall.com/cards/named?fuzzy={{ name|urlencode }}&format=image&version=normal" data-card-name="{{ name }}"
|
||||
alt="{{ name }}" loading="lazy" decoding="async" />
|
||||
</button>
|
||||
</form>
|
||||
|
|
@ -75,7 +75,7 @@
|
|||
<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" />
|
||||
<img src="https://api.scryfall.com/cards/named?fuzzy={{ selected|urlencode }}&format=image&version=normal" alt="{{ selected }} card image" data-card-name="{{ selected }}" />
|
||||
</a>
|
||||
</aside>
|
||||
<div class="grow">
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
<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" />
|
||||
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander.name|urlencode }}&format=image&version=normal" alt="{{ commander.name }} card image" data-card-name="{{ commander.name }}" />
|
||||
</a>
|
||||
</aside>
|
||||
<div class="grow" data-skeleton>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
<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" />
|
||||
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=normal" alt="{{ commander }} card image" data-card-name="{{ commander }}" />
|
||||
</a>
|
||||
</aside>
|
||||
<div class="grow" data-skeleton>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
<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" />
|
||||
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=normal" alt="{{ commander }} card image" data-card-name="{{ commander }}" />
|
||||
</a>
|
||||
</aside>
|
||||
<div class="grow" data-skeleton>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
<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" />
|
||||
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=normal" alt="{{ commander }} card image" data-card-name="{{ commander }}" />
|
||||
</a>
|
||||
{% if status and status.startswith('Build complete') %}
|
||||
<div style="margin-top:.75rem; display:flex; gap:.35rem; flex-wrap:wrap;">
|
||||
|
|
@ -144,7 +144,7 @@
|
|||
{% set owned = (owned_set is defined and c.name and (c.name|lower in owned_set)) %}
|
||||
<div class="card-tile{% if game_changers and (c.name in game_changers) %} game-changer{% endif %}" data-card-name="{{ c.name }}" data-role="{{ c.role or c.sub_role or '' }}" data-tags="{{ (c.tags|join(', ')) if c.tags else '' }}" data-owned="{{ '1' if owned else '0' }}">
|
||||
<a href="https://scryfall.com/search?q={{ c.name|urlencode }}" target="_blank" rel="noopener">
|
||||
<img src="https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=normal" alt="{{ c.name }} image" width="160" />
|
||||
<img src="https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=normal" alt="{{ c.name }} image" width="160" data-card-name="{{ c.name }}" />
|
||||
</a>
|
||||
<div class="owned-badge" title="{{ 'Owned' if owned else 'Not owned' }}" aria-label="{{ 'Owned' if owned else 'Not owned' }}">{% if owned %}✔{% else %}✖{% endif %}</div>
|
||||
<div class="name">{{ c.name }}{% if c.count and c.count > 1 %} ×{{ c.count }}{% endif %}</div>
|
||||
|
|
@ -165,7 +165,7 @@
|
|||
{% set owned = (owned_set is defined and c.name and (c.name|lower in owned_set)) %}
|
||||
<div class="card-tile{% if game_changers and (c.name in game_changers) %} game-changer{% endif %}" data-card-name="{{ c.name }}" data-role="{{ c.role or c.sub_role or '' }}" data-tags="{{ (c.tags|join(', ')) if c.tags else '' }}" data-owned="{{ '1' if owned else '0' }}">
|
||||
<a href="https://scryfall.com/search?q={{ c.name|urlencode }}" target="_blank" rel="noopener">
|
||||
<img src="https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=normal" alt="{{ c.name }} image" width="160" />
|
||||
<img src="https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=normal" alt="{{ c.name }} image" width="160" data-card-name="{{ c.name }}" />
|
||||
</a>
|
||||
<div class="owned-badge" title="{{ 'Owned' if owned else 'Not owned' }}" aria-label="{{ 'Owned' if owned else 'Not owned' }}">{% if owned %}✔{% else %}✖{% endif %}</div>
|
||||
<div class="name">{{ c.name }}{% if c.count and c.count > 1 %} ×{{ c.count }}{% endif %}</div>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
<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" />
|
||||
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=normal" alt="{{ commander }} card image" width="320" data-card-name="{{ commander }}" />
|
||||
</a>
|
||||
{% endif %}
|
||||
<div style="margin-top:.75rem; display:flex; gap:.35rem; flex-wrap:wrap;">
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
<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);" />
|
||||
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=normal" alt="{{ commander }}" data-card-name="{{ 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;">
|
||||
|
|
|
|||
44
code/web/templates/diagnostics/index.html
Normal file
44
code/web/templates/diagnostics/index.html
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<section>
|
||||
<h2>Diagnostics</h2>
|
||||
<p class="muted">Use these tools to verify error handling surfaces.</p>
|
||||
<div class="card" style="background:#0f1115; border:1px solid var(--border); border-radius:10px; padding:.75rem; margin-bottom:.75rem">
|
||||
<h3 style="margin-top:0">System summary</h3>
|
||||
<div id="sysSummary" class="muted">Loading…</div>
|
||||
</div>
|
||||
<div class="card" style="background:#0f1115; border:1px solid var(--border); border-radius:10px; padding:.75rem;">
|
||||
<h3 style="margin-top:0">Error triggers</h3>
|
||||
<div class="row" style="display:flex; gap:.5rem; align-items:center">
|
||||
<button class="btn" hx-get="/diagnostics/trigger-error" hx-trigger="click" hx-target="this" hx-swap="none">Trigger HTTP error (418)</button>
|
||||
<button class="btn" hx-get="/diagnostics/trigger-error?kind=unhandled" hx-trigger="click" hx-target="this" hx-swap="none">Trigger unhandled error (500)</button>
|
||||
<small class="muted">You should see a toast and an inline banner with Request-ID.</small>
|
||||
</div>
|
||||
</div>
|
||||
{% if show_logs %}
|
||||
<p style="margin-top:.75rem"><a class="btn" href="/logs">Open Logs</a></p>
|
||||
{% endif %}
|
||||
</section>
|
||||
<script>
|
||||
(function(){
|
||||
var el = document.getElementById('sysSummary');
|
||||
function render(data){
|
||||
if (!el) return;
|
||||
try {
|
||||
var v = (data && data.version) || 'dev';
|
||||
var up = (data && data.uptime_seconds) || 0;
|
||||
var st = (data && data.server_time_utc) || '';
|
||||
var flags = (data && data.flags) || {};
|
||||
el.innerHTML = '<div><strong>Version:</strong> '+String(v)+'</div>'+
|
||||
(st ? '<div><strong>Server time (UTC):</strong> '+String(st)+'</div>' : '')+
|
||||
'<div><strong>Uptime:</strong> '+String(up)+'s</div>'+
|
||||
'<div><strong>Flags:</strong> SHOW_LOGS='+ (flags.SHOW_LOGS? '1':'0') +', SHOW_DIAGNOSTICS='+ (flags.SHOW_DIAGNOSTICS? '1':'0') +', SHOW_SETUP='+ (flags.SHOW_SETUP? '1':'0') +'</div>';
|
||||
} catch(_){ el.textContent = 'Unavailable'; }
|
||||
}
|
||||
function load(){
|
||||
try { fetch('/status/sys', { cache: 'no-store' }).then(function(r){ return r.json(); }).then(render).catch(function(){ el.textContent='Unavailable'; }); } catch(_){ el.textContent='Unavailable'; }
|
||||
}
|
||||
load();
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
85
code/web/templates/diagnostics/logs.html
Normal file
85
code/web/templates/diagnostics/logs.html
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<section>
|
||||
<h2>Logs</h2>
|
||||
<form method="get" action="/logs" class="form-row" style="gap:.5rem; align-items: center;">
|
||||
<label>Tail <input type="number" name="tail" value="{{ tail }}" min="1" max="500" style="width:80px"></label>
|
||||
<label>Filter <input type="text" name="q" value="{{ q }}" placeholder="keyword"></label>
|
||||
<label>Level
|
||||
<select name="level" id="levelSel">
|
||||
{% set _lvl = (level or 'all') %}
|
||||
<option value="all" {% if _lvl=='all' %}selected{% endif %}>All</option>
|
||||
<option value="error" {% if _lvl=='error' %}selected{% endif %}>Error</option>
|
||||
<option value="warning" {% if _lvl=='warning' %}selected{% endif %}>Warning</option>
|
||||
<option value="info" {% if _lvl=='info' %}selected{% endif %}>Info</option>
|
||||
<option value="debug" {% if _lvl=='debug' %}selected{% endif %}>Debug</option>
|
||||
</select>
|
||||
</label>
|
||||
<button class="btn" type="submit">Refresh</button>
|
||||
<button class="btn" type="button" id="copyLogsBtn" title="Copy visible logs">Copy</button>
|
||||
<label style="margin-left:1rem; display:inline-flex; align-items:center; gap:.35rem">
|
||||
<input type="checkbox" id="autoRefreshLogs" data-pref="logs:auto" /> Auto-refresh
|
||||
</label>
|
||||
<label style="display:inline-flex; align-items:center; gap:.35rem">
|
||||
every <input type="number" id="autoRefreshInterval" value="3" min="1" max="30" style="width:60px"> s
|
||||
</label>
|
||||
</form>
|
||||
<pre id="logTail" class="log-tail" data-tail="{{ tail }}" data-q="{{ q }}" data-level="{{ level or 'all' }}" style="white-space: pre-wrap; background:#0f1115; color:#e5e7eb; border:1px solid var(--border); border-radius:8px; padding:.75rem; margin-top:.75rem; max-height:60vh; overflow:auto">{{ lines | join('') }}</pre>
|
||||
<script>
|
||||
(function(){
|
||||
var pre = document.getElementById('logTail');
|
||||
var autoCb = document.getElementById('autoRefreshLogs');
|
||||
var intervalInput = document.getElementById('autoRefreshInterval');
|
||||
// hydrate from saved pref
|
||||
try { var saved = window.__mtgState && window.__mtgState.get('logs:auto', false); if (typeof saved === 'boolean') autoCb.checked = saved; } catch(_){ }
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
var tailAttr = (pre && pre.getAttribute('data-tail')) || '200';
|
||||
var qAttr = (pre && pre.getAttribute('data-q')) || '';
|
||||
var levelAttr = (pre && pre.getAttribute('data-level')) || 'all';
|
||||
var tail = parseInt(params.get('tail') || tailAttr, 10) || parseInt(tailAttr, 10) || 200;
|
||||
var q = params.get('q') || qAttr;
|
||||
var level = params.get('level') || levelAttr;
|
||||
var timer = null;
|
||||
function fetchLogs(){
|
||||
try {
|
||||
var url = '/status/logs?tail='+encodeURIComponent(String(tail));
|
||||
if (q) url += '&q='+encodeURIComponent(q);
|
||||
if (level && level !== 'all') url += '&level='+encodeURIComponent(level);
|
||||
fetch(url, { cache: 'no-store' })
|
||||
.then(function(r){ return r.json(); })
|
||||
.then(function(data){ if (pre && data && data.lines){ pre.textContent = (data.lines||[]).join(''); pre.scrollTop = pre.scrollHeight; } });
|
||||
} catch(e){}
|
||||
}
|
||||
function start(){
|
||||
stop();
|
||||
var sec = parseInt(intervalInput.value||'3', 10); if (isNaN(sec) || sec < 1) sec = 3; if (sec > 30) sec = 30;
|
||||
timer = setInterval(fetchLogs, sec * 1000);
|
||||
fetchLogs();
|
||||
}
|
||||
function stop(){ if (timer){ clearInterval(timer); timer = null; } }
|
||||
autoCb.addEventListener('change', function(){
|
||||
try { window.__mtgState && window.__mtgState.set('logs:auto', !!autoCb.checked); } catch(_){ }
|
||||
if (autoCb.checked) start(); else stop();
|
||||
});
|
||||
intervalInput.addEventListener('change', function(){ if (autoCb.checked) start(); });
|
||||
if (autoCb.checked) start();
|
||||
var levelSel = document.getElementById('levelSel');
|
||||
if (levelSel){ levelSel.addEventListener('change', function(){ if (autoCb.checked) fetchLogs(); }); }
|
||||
// Copy button
|
||||
var copyBtn = document.getElementById('copyLogsBtn');
|
||||
function copyText(text){
|
||||
try { navigator.clipboard.writeText(text); return true; } catch(_) {
|
||||
try {
|
||||
var ta = document.createElement('textarea'); ta.value = text; ta.style.position='fixed'; ta.style.opacity='0'; document.body.appendChild(ta); ta.select(); var ok = document.execCommand('copy'); ta.remove(); return ok; } catch(__){ return false; }
|
||||
}
|
||||
}
|
||||
if (copyBtn){
|
||||
copyBtn.addEventListener('click', function(){
|
||||
var ok = copyText(pre ? pre.textContent || '' : '');
|
||||
if (ok){ if (window.toast) window.toast('Copied logs'); copyBtn.textContent = 'Copied'; setTimeout(function(){ copyBtn.textContent='Copy'; }, 1200); }
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</section>
|
||||
{% endblock %}
|
||||
13
code/web/templates/errors/404.html
Normal file
13
code/web/templates/errors/404.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<section>
|
||||
<h2>Page not found</h2>
|
||||
<p>The page you requested could not be found.</p>
|
||||
<p class="muted">Request ID: <code>{{ request_id or request.state.request_id }}</code></p>
|
||||
<p><a class="btn" href="/">Go home</a></p>
|
||||
<details>
|
||||
<summary>Details</summary>
|
||||
<pre>Status: {{ status }}
|
||||
Path: {{ request.url.path }}</pre>
|
||||
</details>
|
||||
{% endblock %}
|
||||
13
code/web/templates/errors/4xx.html
Normal file
13
code/web/templates/errors/4xx.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<section>
|
||||
<h2>Error {{ status }}</h2>
|
||||
<p>{{ detail }}</p>
|
||||
<p class="muted">Request ID: <code>{{ request_id or request.state.request_id }}</code></p>
|
||||
<p><a class="btn" href="/">Go home</a></p>
|
||||
<details>
|
||||
<summary>Details</summary>
|
||||
<pre>Status: {{ status }}
|
||||
Path: {{ request.url.path }}</pre>
|
||||
</details>
|
||||
{% endblock %}
|
||||
8
code/web/templates/errors/500.html
Normal file
8
code/web/templates/errors/500.html
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<section>
|
||||
<h2>Internal Server Error</h2>
|
||||
<p>Something went wrong.</p>
|
||||
<p class="muted">Request ID: <code>{{ request_id or request.state.request_id }}</code></p>
|
||||
<p><a class="btn" href="/">Go home</a></p>
|
||||
{% endblock %}
|
||||
|
|
@ -5,6 +5,7 @@
|
|||
<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="/owned">Owned Library</a>
|
||||
<a class="action-button" href="/decks">Finished Decks</a>
|
||||
{% if show_logs %}<a class="action-button" href="/logs">View Logs</a>{% endif %}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -78,27 +78,22 @@
|
|||
{% set cols = (colors_by_name.get(n, []) if colors_by_name else []) %}
|
||||
{% set added_ts = (added_at_map.get(n) if added_at_map else None) %}
|
||||
<li style="break-inside: avoid; overflow-wrap:anywhere;" data-type="{{ tline }}" data-tags="{{ (tags or [])|join('|') }}" data-colors="{{ (cols or [])|join('') }}" data-added="{{ added_ts if added_ts else '' }}">
|
||||
<label style="display:flex; align-items:center; gap:.4rem;">
|
||||
<input type="checkbox" class="sel" />
|
||||
<span data-card-name="{{ n }}" {% if tags %}data-tags="{{ (tags or [])|join(', ') }}"{% endif %}>{{ n }}</span>
|
||||
<label class="owned-row" style="cursor:pointer;" tabindex="0">
|
||||
<input type="checkbox" class="sel sr-only" aria-label="Select {{ n }}" />
|
||||
<div class="owned-vstack">
|
||||
<img class="card-thumb" loading="lazy" alt="{{ n }} image" src="https://api.scryfall.com/cards/named?fuzzy={{ n|urlencode }}&format=image&version=small" data-card-name="{{ n }}" {% if tags %}data-tags="{{ (tags or [])|join(', ') }}"{% endif %} />
|
||||
<span class="card-name"{% if tags %} data-tags="{{ (tags or [])|join(', ') }}"{% endif %}>{{ n }}</span>
|
||||
{% if cols and cols|length %}
|
||||
<div class="mana-group" aria-hidden="true">
|
||||
{% for c in cols %}
|
||||
<span class="mana mana-{{ c }}" title="{{ c }}"></span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<span class="sr-only"> Colors: {{ cols|join(', ') }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</label>
|
||||
{# Inline user tag badges #}
|
||||
{% set utags = (user_tags_map.get(n, []) if user_tags_map else []) %}
|
||||
{% if utags and utags|length %}
|
||||
<div class="user-tags" style="display:flex; flex-wrap:wrap; gap:6px; margin:.25rem 0 .15rem 1.65rem;">
|
||||
{% for t in utags %}
|
||||
<span class="chip" data-name="{{ n }}" data-user-tag="{{ t }}" title="Click to remove tag" style="display:inline-flex; align-items:center; gap:6px; background:#0f1115; color:#e5e7eb; border:1px solid var(--border); border-radius:999px; padding:2px 8px; font-size:12px; cursor:pointer;">{{ t }} <span aria-hidden="true" style="opacity:.8;">×</span></span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if cols and cols|length %}
|
||||
<span class="mana-group" aria-hidden="true" style="margin-left:.35rem; display:inline-flex; gap:4px; vertical-align:middle;">
|
||||
{% for c in cols %}
|
||||
<span class="mana mana-{{ c }}" title="{{ c }}"></span>
|
||||
{% endfor %}
|
||||
</span>
|
||||
<span class="sr-only"> Colors: {{ cols|join(', ') }}</span>
|
||||
{% endif %}
|
||||
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
|
@ -127,7 +122,7 @@
|
|||
var btnClear = document.getElementById('clear-filters');
|
||||
var shownCount = document.getElementById('shown-count');
|
||||
var chips = document.getElementById('active-chips');
|
||||
var tagInput;
|
||||
|
||||
|
||||
// State helpers for URL hash and localStorage
|
||||
var state = {
|
||||
|
|
@ -137,6 +132,14 @@
|
|||
readHash: function(){ try{ return new URLSearchParams((location.hash||'').replace(/^#/,'')); }catch(e){ return new URLSearchParams(); } }
|
||||
};
|
||||
|
||||
// Helper: build Scryfall image URL with optional cache-busting
|
||||
function buildImageUrl(name, version, nocache){
|
||||
var q = encodeURIComponent(name||'');
|
||||
var url = 'https://api.scryfall.com/cards/named?fuzzy=' + q + '&format=image&version=' + (version||'small');
|
||||
if (nocache) url += '&t=' + Date.now();
|
||||
return url;
|
||||
}
|
||||
|
||||
// Resize the container to fill the viewport height
|
||||
function sizeBox(){
|
||||
if (!box) return;
|
||||
|
|
@ -236,25 +239,7 @@
|
|||
});
|
||||
}
|
||||
|
||||
// Bulk tagging controls
|
||||
(function(){
|
||||
var bar = document.getElementById('bulk-bar');
|
||||
if (!bar) return;
|
||||
var wrap = document.createElement('div');
|
||||
wrap.style.display='flex'; wrap.style.alignItems='center'; wrap.style.gap='.5rem'; wrap.style.flexWrap='wrap';
|
||||
var inp = document.createElement('input'); inp.type='text'; inp.placeholder='Tag…'; inp.id='bulk-tag-input';
|
||||
inp.style.background='#0f1115'; inp.style.color='#e5e7eb'; inp.style.border='1px solid var(--border)'; inp.style.borderRadius='6px'; inp.style.padding='.3rem .5rem';
|
||||
var addBtn = document.createElement('button'); addBtn.textContent='Add tag to selected'; addBtn.id='btn-tag-add'; addBtn.disabled=true;
|
||||
var remBtn = document.createElement('button'); remBtn.textContent='Remove tag from selected'; remBtn.id='btn-tag-remove'; remBtn.disabled=true;
|
||||
wrap.appendChild(inp); wrap.appendChild(addBtn); wrap.appendChild(remBtn);
|
||||
bar.appendChild(wrap);
|
||||
tagInput = inp;
|
||||
function refreshTagBtns(){ var hasSel = getSelectedNames().length>0; var hasTag = !!(tagInput && tagInput.value && tagInput.value.trim()); addBtn.disabled = !(hasSel && hasTag); remBtn.disabled = !(hasSel && hasTag); }
|
||||
if (tagInput) tagInput.addEventListener('input', refreshTagBtns);
|
||||
document.addEventListener('change', function(e){ if (e.target && e.target.classList && e.target.classList.contains('sel')) refreshTagBtns(); });
|
||||
addBtn.addEventListener('click', function(){ var names=getSelectedNames(); var t=(tagInput&&tagInput.value||'').trim(); if(!names.length||!t) return; fetch('/owned/tag/add',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({names:names, tag:t})}).then(function(r){return r.text();}).then(function(html){ document.documentElement.innerHTML = html; }).catch(function(){ alert('Tagging failed'); }); });
|
||||
remBtn.addEventListener('click', function(){ var names=getSelectedNames(); var t=(tagInput&&tagInput.value||'').trim(); if(!names.length||!t) return; fetch('/owned/tag/remove',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({names:names, tag:t})}).then(function(r){return r.text();}).then(function(html){ document.documentElement.innerHTML = html; }).catch(function(){ alert('Untag failed'); }); });
|
||||
})();
|
||||
// Bulk user-tag add/remove controls removed by request; inline chip removal remains supported.
|
||||
|
||||
function resort(){
|
||||
if (!fSort) return;
|
||||
|
|
@ -308,6 +293,11 @@
|
|||
selAll.checked = (vis.length>0 && selected.length === vis.length);
|
||||
selAll.indeterminate = (selected.length>0 && selected.length < vis.length);
|
||||
}
|
||||
// Toggle selected class for visual feedback
|
||||
Array.prototype.forEach.call(grid.children, function(li){
|
||||
var cb = li.querySelector('input.sel');
|
||||
li.classList.toggle('is-selected', !!(cb && cb.checked));
|
||||
});
|
||||
}
|
||||
|
||||
if (selAll){
|
||||
|
|
@ -318,6 +308,15 @@
|
|||
});
|
||||
}
|
||||
grid.addEventListener('change', function(e){ if (e.target && e.target.classList && e.target.classList.contains('sel')) updateSelectedState(); });
|
||||
// Keyboard: allow Enter/Space on the row to toggle selection
|
||||
grid.addEventListener('keydown', function(e){
|
||||
if (!(e.key === 'Enter' || e.key === ' ')) return;
|
||||
var row = e.target && e.target.closest && e.target.closest('label.owned-row');
|
||||
if (!row) return;
|
||||
e.preventDefault();
|
||||
var cb = row.querySelector('input.sel');
|
||||
if (cb){ cb.checked = !cb.checked; cb.dispatchEvent(new Event('change', { bubbles:true })); }
|
||||
});
|
||||
|
||||
function postJSON(url, body){ return fetch(url, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body||{}) }).then(function(r){ if (r.ok) return r.text(); throw new Error('Request failed'); }); }
|
||||
function formPost(url, names){
|
||||
|
|
@ -362,19 +361,9 @@
|
|||
// Initial state
|
||||
apply();
|
||||
|
||||
// Delegated click: quick remove a user tag chip
|
||||
grid.addEventListener('click', function(e){
|
||||
var chip = e.target.closest && e.target.closest('.user-tags .chip');
|
||||
if (!chip) return;
|
||||
var name = chip.getAttribute('data-name');
|
||||
var tag = chip.getAttribute('data-user-tag');
|
||||
if (!name || !tag) return;
|
||||
if (!window.confirm('Remove tag \''+tag+'\' from "'+name+'"?')) return;
|
||||
fetch('/owned/tag/remove', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ names:[name], tag: tag }) })
|
||||
.then(function(r){ return r.text(); })
|
||||
.then(function(html){ sessionStorage.setItem('mtg:toastAfterReload', JSON.stringify({msg:'Removed tag \''+tag+'\' from '+name+'.', type:'success'})); document.documentElement.innerHTML = html; })
|
||||
.catch(function(){ alert('Untag failed'); });
|
||||
});
|
||||
// Thumbnail retry now handled by global binder in base.html
|
||||
|
||||
// User tag chip UI removed by request.
|
||||
})();
|
||||
</script>
|
||||
<style>
|
||||
|
|
@ -393,5 +382,14 @@
|
|||
#owned-box::-webkit-scrollbar-track{ background: transparent; }
|
||||
#owned-box::-webkit-scrollbar-thumb{ background-color: rgba(148,163,184,.35); border-radius:8px; }
|
||||
#owned-box:hover::-webkit-scrollbar-thumb{ background-color: rgba(148,163,184,.6); }
|
||||
/* Owned item layout */
|
||||
#owned-grid{ justify-items:center; }
|
||||
.owned-row{ display:flex; align-items:center; justify-content:center; gap:.5rem; border:1px solid transparent; border-radius:8px; padding:.5rem; width:100%; max-width:200px; margin:0 auto; }
|
||||
.owned-vstack{ display:flex; flex-direction:column; gap:.25rem; align-items:center; text-align:center; }
|
||||
.card-thumb{ display:block; width:100px; height:auto; border-radius:6px; border:1px solid var(--border); background:#0b0d12; object-fit:cover; }
|
||||
/* Highlight only the thumbnail when selected */
|
||||
li.is-selected .card-thumb{ border-color:#ffffff; box-shadow:0 0 0 3px rgba(255,255,255,.35); }
|
||||
.mana-group{ display:flex; gap:4px; justify-content:center; }
|
||||
.card-name{ display:block; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -40,14 +40,25 @@
|
|||
</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;">
|
||||
<style>
|
||||
.list-grid { display:grid; grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); gap:.35rem .75rem; margin:.25rem 0 .75rem 0; }
|
||||
@media (max-width: 1199px) {
|
||||
.list-grid { grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); }
|
||||
}
|
||||
.list-row { display:grid; grid-template-columns: 4ch 1.25ch minmax(0,1fr) 1.6em; align-items:center; column-gap:.45rem; width:100%; }
|
||||
.list-row .count { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-variant-numeric: tabular-nums; font-feature-settings: 'tnum'; text-align:right; color:#94a3b8; }
|
||||
.list-row .times { color:#94a3b8; text-align:center; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
|
||||
.list-row .name { display:inline-block; padding: 2px 4px; border-radius: 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.list-row .owned-flag { width: 1.6em; min-width: 1.6em; text-align:center; display:inline-block; }
|
||||
</style>
|
||||
<div class="list-grid">
|
||||
{% 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 %}">
|
||||
<div class="list-row {% if (game_changers and (c.name in game_changers)) or ('game_changer' in (c.role or '') or 'Game Changer' in (c.role or '')) %}game-changer{% endif %}">
|
||||
{% set cnt = c.count if c.count else 1 %}
|
||||
{% set owned = (owned_set is defined and c.name and (c.name|lower in owned_set)) %}
|
||||
<span data-card-name="{{ c.name }}" data-count="{{ cnt }}" data-role="{{ c.role }}" data-tags="{{ (c.tags|join(', ')) if c.tags else '' }}">
|
||||
{{ cnt }}x {{ c.name }}
|
||||
</span>
|
||||
<span class="count">{{ cnt }}</span>
|
||||
<span class="times">x</span>
|
||||
<span class="name" title="{{ c.name }}" data-card-name="{{ c.name }}" data-count="{{ cnt }}" data-role="{{ c.role }}" data-tags="{{ (c.tags|join(', ')) if c.tags else '' }}">{{ c.name }}</span>
|
||||
<span class="owned-flag" title="{{ 'Owned' if owned else 'Not owned' }}" aria-label="{{ 'Owned' if owned else 'Not owned' }}">{% if owned %}✔{% else %}✖{% endif %}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
|
@ -375,9 +386,9 @@
|
|||
<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); }
|
||||
/* Cross-highlight from charts to cards */
|
||||
.chart-highlight { outline: 2px solid #f59e0b; outline-offset: 2px; border-radius: 6px; background: rgba(245,158,11,.08); }
|
||||
/* For list view, wrap highlight visually */
|
||||
#typeview-list [data-card-name].chart-highlight { display:inline-block; padding: 2px 4px; border-radius: 6px; }
|
||||
.chart-highlight { border-radius: 6px; background: rgba(245,158,11,.08); box-shadow: 0 0 0 2px #f59e0b inset; }
|
||||
/* For list view, ensure baseline padding so no layout shift on highlight */
|
||||
#typeview-list .list-row .name { display:inline-block; padding: 2px 4px; border-radius: 6px; }
|
||||
/* Ensure stack-card gets visible highlight */
|
||||
.stack-card.chart-highlight { box-shadow: 0 0 0 2px #f59e0b, 0 6px 18px rgba(0,0,0,.55); }
|
||||
</style>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue