mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-17 08:00:13 +01:00
feat(random): multi-theme groundwork, locked reroll export parity, duplicate export fix, expanded diagnostics and test coverage
This commit is contained in:
parent
a029d430c5
commit
73685f22c8
39 changed files with 2671 additions and 271 deletions
|
|
@ -83,6 +83,7 @@
|
|||
<a href="/owned">Owned Library</a>
|
||||
<a href="/decks">Finished Decks</a>
|
||||
<a href="/themes/">Themes</a>
|
||||
{% if random_ui %}<a href="/random">Random</a>{% endif %}
|
||||
{% if show_diagnostics %}<a href="/diagnostics">Diagnostics</a>{% endif %}
|
||||
{% if show_logs %}<a href="/logs">Logs</a>{% endif %}
|
||||
</nav>
|
||||
|
|
@ -514,9 +515,12 @@
|
|||
el.addEventListener('mouseleave', function(){ cardPop.style.display='none'; });
|
||||
});
|
||||
}
|
||||
attachCardHover();
|
||||
bindAllCardImageRetries();
|
||||
document.addEventListener('htmx:afterSwap', function() { attachCardHover(); bindAllCardImageRetries(); });
|
||||
// Expose re-init functions globally for dynamic content
|
||||
window.attachCardHover = attachCardHover;
|
||||
window.bindAllCardImageRetries = bindAllCardImageRetries;
|
||||
attachCardHover();
|
||||
bindAllCardImageRetries();
|
||||
document.addEventListener('htmx:afterSwap', function() { attachCardHover(); bindAllCardImageRetries(); });
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
|
|
@ -959,10 +963,20 @@
|
|||
if(!el) return null;
|
||||
// If inside flip button
|
||||
var btn = el.closest && el.closest('.dfc-toggle');
|
||||
if(btn) return btn.closest('.card-sample, .commander-cell, .card-tile, .candidate-tile, .card-preview');
|
||||
if(el.matches && el.matches('img.card-thumb')) return el.closest('.card-sample, .commander-cell, .card-tile, .candidate-tile, .card-preview');
|
||||
if(btn) return btn.closest('.card-sample, .commander-cell, .card-tile, .candidate-tile, .card-preview, .stack-card');
|
||||
// Recognized container classes (add .stack-card for finished/random deck thumbnails)
|
||||
var container = el.closest && el.closest('.card-sample, .commander-cell, .card-tile, .candidate-tile, .card-preview, .stack-card');
|
||||
if(container) return container;
|
||||
// Image-based detection (any card image carrying data-card-name)
|
||||
if(el.matches && (el.matches('img.card-thumb') || el.matches('img[data-card-name]') || el.classList.contains('commander-img'))){
|
||||
var up = el.closest && el.closest('.stack-card');
|
||||
return up || el; // fall back to the image itself
|
||||
}
|
||||
// List view spans (deck summary list mode, finished deck list, etc.)
|
||||
if(el.hasAttribute && el.hasAttribute('data-card-name')) return el;
|
||||
return null;
|
||||
}
|
||||
document.addEventListener('pointermove', function(e){ window.__lastPointerEvent = e; });
|
||||
document.addEventListener('pointerover', function(e){
|
||||
var card = getCardFromEl(e.target);
|
||||
if(!card) return;
|
||||
|
|
@ -987,6 +1001,12 @@
|
|||
var ev = window.__lastPointerEvent || { clientX: (card.getBoundingClientRect().left+12), clientY: (card.getBoundingClientRect().top+12) };
|
||||
show(card, ev);
|
||||
};
|
||||
window.hoverShowByName = function(name){
|
||||
try {
|
||||
var el = document.querySelector('[data-card-name="'+CSS.escape(name)+'"]');
|
||||
if(el){ window.__hoverShowCard(el.closest('.card-sample, .commander-cell, .card-tile, .candidate-tile, .card-preview, .stack-card') || el); }
|
||||
} catch(_) {}
|
||||
};
|
||||
// Keyboard accessibility & focus traversal (P2 UI Hover keyboard accessibility)
|
||||
document.addEventListener('focusin', function(e){ var card=e.target.closest && e.target.closest('.card-sample, .commander-cell'); if(card){ show(card, {clientX:card.getBoundingClientRect().left+10, clientY:card.getBoundingClientRect().top+10}); }});
|
||||
document.addEventListener('focusout', function(e){ var next=e.relatedTarget && e.relatedTarget.closest && e.relatedTarget.closest('.card-sample, .commander-cell'); if(!next) hide(); });
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
<a class="action-button" href="/owned">Owned Library</a>
|
||||
<a class="action-button" href="/decks">Finished Decks</a>
|
||||
<a class="action-button" href="/themes/">Browse Themes</a>
|
||||
{% if random_ui %}<a class="action-button" href="/random">Random Build</a>{% endif %}
|
||||
{% if show_logs %}<a class="action-button" href="/logs">View Logs</a>{% endif %}
|
||||
</div>
|
||||
<div id="themes-quick" style="margin-top:1rem; font-size:.85rem; color:var(--text-muted);">
|
||||
|
|
|
|||
|
|
@ -1,70 +1,15 @@
|
|||
<hr style="margin:1.25rem 0; border-color: var(--border);" />
|
||||
<h4>Deck Summary</h4>
|
||||
{% if versions and (versions.combos or versions.synergies) %}
|
||||
<div class="muted" style="font-size:12px; margin:.1rem 0 .4rem 0;">Combos/Synergies lists: v{{ versions.combos or '?' }} / v{{ versions.synergies or '?' }}</div>
|
||||
{% endif %}
|
||||
<div class="muted" style="font-size:12px; margin:.15rem 0 .4rem 0; display:flex; gap:.75rem; align-items:center; flex-wrap:wrap;">
|
||||
<span>Legend:</span>
|
||||
<span><span class="game-changer" style="font-weight:600;">Game Changer</span> <span class="muted" style="opacity:.8;">(green highlight)</span></span>
|
||||
<span><span class="owned-flag" style="margin:0 .25rem 0 .1rem;">✔</span>Owned • <span class="owned-flag" style="margin:0 .25rem 0 .1rem;">✖</span>Not owned</span>
|
||||
</div>
|
||||
|
||||
<!-- Detected Combos & Synergies (top) -->
|
||||
{% if combos or synergies %}
|
||||
<section style="margin-top:.25rem;">
|
||||
<h5>Combos & Synergies</h5>
|
||||
{% if combos %}
|
||||
<div style="margin:.25rem 0 .5rem 0;">
|
||||
<div class="muted" style="font-weight:600; margin-bottom:.25rem;">Detected Combos ({{ combos|length }})</div>
|
||||
<ul style="list-style:none; padding:0; margin:0; display:grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap:.25rem .75rem;">
|
||||
{% for c in combos %}
|
||||
<li style="border:1px solid var(--border); border-radius:8px; padding:.35rem .5rem; background:#0f1115;" data-combo-names="{{ c.a }}||{{ c.b }}">
|
||||
<span data-card-name="{{ c.a }}">{{ c.a }}</span>
|
||||
<span class="muted"> + </span>
|
||||
<span data-card-name="{{ c.b }}">{{ c.b }}</span>
|
||||
{% if c.cheap_early or c.setup_dependent %}
|
||||
<span class="muted" style="margin-left:.4rem; font-size:12px;">
|
||||
{% if c.cheap_early %}<span title="Cheap/Early" style="border:1px solid var(--border); padding:.05rem .35rem; border-radius:999px;">cheap/early</span>{% endif %}
|
||||
{% if c.setup_dependent %}<span title="Setup Dependent" style="border:1px solid var(--border); padding:.05rem .35rem; border-radius:999px; margin-left:.25rem;">setup</span>{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if synergies %}
|
||||
<div style="margin:.25rem 0 .5rem 0;">
|
||||
<div class="muted" style="font-weight:600; margin-bottom:.25rem;">Detected Synergies ({{ synergies|length }})</div>
|
||||
<ul style="list-style:none; padding:0; margin:0; display:grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap:.25rem .75rem;">
|
||||
{% for s in synergies %}
|
||||
<li style="border:1px solid var(--border); border-radius:8px; padding:.35rem .5rem; background:#0f1115;" data-combo-names="{{ s.a }}||{{ s.b }}">
|
||||
<span data-card-name="{{ s.a }}">{{ s.a }}</span>
|
||||
<span class="muted"> + </span>
|
||||
<span data-card-name="{{ s.b }}">{{ s.b }}</span>
|
||||
{% if s.tags %}
|
||||
<span class="muted" style="margin-left:.4rem; font-size:12px;">
|
||||
{% for t in s.tags %}<span style="border:1px solid var(--border); padding:.05rem .35rem; border-radius:999px; margin-right:.25rem;">{{ t }}</span>{% endfor %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<!-- 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>
|
||||
<button type="button" class="seg-btn" data-view="list" aria-selected="true" onclick="(function(btn){var list=document.getElementById('typeview-list');var thumbs=document.getElementById('typeview-thumbs');if(!list||!thumbs)return;list.classList.remove('hidden');thumbs.classList.add('hidden');btn.setAttribute('aria-selected','true');var other=btn.parentElement.querySelector('.seg-btn[data-view=thumbs]');if(other)other.setAttribute('aria-selected','false');try{localStorage.setItem('summaryTypeView','list');}catch(e){}})(this)">List</button>
|
||||
<button type="button" class="seg-btn" data-view="thumbs" onclick="(function(btn){var list=document.getElementById('typeview-list');var thumbs=document.getElementById('typeview-thumbs');if(!list||!thumbs)return;list.classList.add('hidden');thumbs.classList.remove('hidden');btn.setAttribute('aria-selected','true');var other=btn.parentElement.querySelector('.seg-btn[data-view=list]');if(other)other.setAttribute('aria-selected','false');try{localStorage.setItem('summaryTypeView','thumbs');}catch(e){}; (function(){var tv=document.getElementById('typeview-thumbs'); if(!tv) return; tv.querySelectorAll('.stack-wrap').forEach(function(sw){var grid=sw.querySelector('.stack-grid'); if(!grid) return; var cs=getComputedStyle(sw); var cardW=parseFloat(cs.getPropertyValue('--card-w'))||160; var gap=10; 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));}); })();})(this)">Thumbnails</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:none" hx-on:load="(function(){try{var mode=localStorage.getItem('summaryTypeView')||'list';if(mode==='thumbs'){var list=document.getElementById('typeview-list');var thumbs=document.getElementById('typeview-thumbs');if(list&&thumbs){list.classList.add('hidden');thumbs.classList.remove('hidden');var lb=document.querySelector('.seg-btn[data-view=list]');var tb=document.querySelector('.seg-btn[data-view=thumbs]');if(lb&&tb){lb.setAttribute('aria-selected','false');tb.setAttribute('aria-selected','true');}thumbs.querySelectorAll('.stack-wrap').forEach(function(sw){var grid=sw.querySelector('.stack-grid');if(!grid)return;var cs=getComputedStyle(sw);var cardW=parseFloat(cs.getPropertyValue('--card-w'))||160;var gap=10;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));});}}catch(e){}})()"></div>
|
||||
{% set tb = summary.type_breakdown %}
|
||||
{% if tb and tb.counts %}
|
||||
<style>
|
||||
|
|
@ -149,58 +94,7 @@
|
|||
{% 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>
|
||||
<!-- Deck Summary initializer script moved below markup for proper element availability -->
|
||||
|
||||
<!-- Mana Overview Row: Pips • Sources • Curve -->
|
||||
<section style="margin-top:1rem;">
|
||||
|
|
|
|||
|
|
@ -1,12 +1,70 @@
|
|||
<div class="random-result" hx-swap-oob="true" id="random-result">
|
||||
<div class="random-meta">
|
||||
<span class="seed">Seed: {{ seed }}</span>
|
||||
{% if theme %}<span class="theme">Theme: {{ theme }}</span>{% endif %}
|
||||
<div class="random-result" id="random-result">
|
||||
<style>
|
||||
.diag-badges{display:inline-flex; gap:4px; margin-left:8px; flex-wrap:wrap;}
|
||||
.diag-badge{background:var(--panel-alt,#334155); color:#fff; padding:2px 6px; border-radius:12px; font-size:10px; letter-spacing:.5px; line-height:1.2;}
|
||||
.diag-badge.warn{background:#8a6d3b;}
|
||||
.diag-badge.err{background:#7f1d1d;}
|
||||
.diag-badge.fallback{background:#4f46e5;}
|
||||
.btn-compact{font-size:11px; padding:2px 6px; line-height:1.2;}
|
||||
</style>
|
||||
<div class="random-meta" style="display:flex; gap:12px; align-items:center; flex-wrap:wrap;">
|
||||
<span class="seed">Seed: <strong>{{ seed }}</strong></span>
|
||||
{% if theme %}<span class="theme">Theme: <strong>{{ theme }}</strong></span>{% endif %}
|
||||
{% if permalink %}
|
||||
<button class="btn btn-compact" type="button" aria-label="Copy permalink for this exact build" onclick="(async()=>{try{await navigator.clipboard.writeText(location.origin + '{{ permalink }}');(window.toast&&toast('Permalink copied'))||console.log('Permalink copied');}catch(e){alert('Copy failed');}})()">Copy Permalink</button>
|
||||
{% endif %}
|
||||
{% if show_diagnostics and diagnostics %}
|
||||
<span class="diag-badges" aria-label="Diagnostics" role="group">
|
||||
<span class="diag-badge" title="Attempts tried before acceptance">Att {{ diagnostics.attempts }}</span>
|
||||
<span class="diag-badge" title="Elapsed build time in milliseconds">{{ diagnostics.elapsed_ms }}ms</span>
|
||||
{% if diagnostics.timeout_hit %}<span class="diag-badge warn" title="Generation loop exceeded timeout limit before success">Timeout</span>{% endif %}
|
||||
{% if diagnostics.retries_exhausted %}<span class="diag-badge warn" title="All allotted attempts were used without an early acceptable candidate">Retries</span>{% endif %}
|
||||
{% if fallback or diagnostics.fallback %}<span class="diag-badge fallback" title="Original theme produced no candidates; Surprise mode fallback engaged">Fallback</span>{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<h3 class="commander">{{ commander }}</h3>
|
||||
<ul class="decklist">
|
||||
{% for card in decklist %}
|
||||
<li>{{ card }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<!-- Hidden current seed so HTMX reroll button can include it via hx-include -->
|
||||
<input type="hidden" id="current-seed" name="seed" value="{{ seed }}" />
|
||||
<input type="hidden" id="current-commander" name="commander" value="{{ commander }}" />
|
||||
<div class="commander-block" style="display:flex; gap:14px; align-items:flex-start; margin-top:.75rem;">
|
||||
<div class="commander-thumb" style="flex:0 0 auto;">
|
||||
<img
|
||||
src="https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=small"
|
||||
srcset="https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=small 160w, https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=normal 488w"
|
||||
sizes="(max-width: 600px) 120px, 160px"
|
||||
alt="{{ commander }} image"
|
||||
width="160" height="220"
|
||||
style="width:160px; height:auto; border-radius:8px; box-shadow:0 6px 18px rgba(0,0,0,.55); border:1px solid var(--border); background:#0f1115;"
|
||||
class="commander-img"
|
||||
loading="lazy" decoding="async"
|
||||
data-card-name="{{ commander }}" />
|
||||
</div>
|
||||
<div style="flex:1 1 auto;">
|
||||
<div class="muted" style="font-size:12px; font-weight:600; letter-spacing:.5px; text-transform:uppercase;">Commander</div>
|
||||
<h3 class="commander" style="margin:.15rem 0 0 0;" data-card-name="{{ commander }}">{{ commander }}</h3>
|
||||
</div>
|
||||
</div>
|
||||
{% if summary %}
|
||||
{# Reuse the comprehensive deck summary partial #}
|
||||
{% include "partials/deck_summary.html" %}
|
||||
{% else %}
|
||||
<ul class="decklist">
|
||||
{% for card in decklist %}
|
||||
{% if card.name %}
|
||||
<li>{{ card.name }}{% if card.count %} ×{{ card.count }}{% endif %}</li>
|
||||
{% else %}
|
||||
<li>{{ card }}</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
<script>
|
||||
// Re-run bindings after OOB swap so hover & view toggle work consistently
|
||||
(function(){
|
||||
try { if (window.bindAllCardImageRetries) window.bindAllCardImageRetries(); } catch(_) {}
|
||||
try { if (window.attachCardHover) window.attachCardHover(); } catch(_) {}
|
||||
// Deck summary initializer (idempotent) – will assign aria-selected
|
||||
try { if (window.initDeckSummaryTypeView) window.initDeckSummaryTypeView(document.getElementById('random-result')); } catch(_) {}
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
|
|
|
|||
274
code/web/templates/random/index.html
Normal file
274
code/web/templates/random/index.html
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
{% set enable_ui = random_ui %}
|
||||
<section id="random-modes" aria-labelledby="random-heading">
|
||||
<h2 id="random-heading">Random Modes</h2>
|
||||
{% if not enable_ui %}
|
||||
<div class="notice" role="status">Random UI is disabled. Set <code>RANDOM_UI=1</code> to enable.</div>
|
||||
{% else %}
|
||||
<div class="controls" role="group" aria-label="Random controls" style="display:flex; gap:8px; align-items:center; flex-wrap: wrap;">
|
||||
<label for="random-theme" class="field-label" style="margin-right:6px;">Theme</label>
|
||||
<div style="position:relative;">
|
||||
<input id="random-theme" name="theme" type="text" placeholder="optional (e.g., Tokens)" aria-label="Theme (optional)" autocomplete="off" role="combobox" aria-autocomplete="list" aria-expanded="false" aria-owns="theme-suggest-box" aria-haspopup="listbox" />
|
||||
<div id="theme-suggest-box" role="listbox" style="display:none; position:absolute; top:100%; left:0; right:0; background:var(--panel,#1e293b); border:1px solid var(--border,#334155); z-index:20; max-height:220px; overflow-y:auto; box-shadow:0 4px 10px rgba(0,0,0,.4); font-size:13px;">
|
||||
<!-- suggestions injected here -->
|
||||
</div>
|
||||
</div>
|
||||
{% if show_diagnostics %}
|
||||
<label for="rand-attempts" style="font-size:11px;">Attempts</label>
|
||||
<input id="rand-attempts" name="attempts" type="number" min="1" max="25" value="{{ random_max_attempts }}" style="width:60px; font-size:11px;" title="Override max attempts" />
|
||||
<label for="rand-timeout" style="font-size:11px;">Timeout(ms)</label>
|
||||
<input id="rand-timeout" name="timeout_ms" type="number" min="100" max="15000" step="100" value="{{ random_timeout_ms }}" style="width:80px; font-size:11px;" title="Override generation timeout in milliseconds" />
|
||||
{% endif %}
|
||||
<!-- Added hx-trigger with delay to provide debounce without custom JS recursion -->
|
||||
<button id="btn-surprise" class="btn" hx-post="/hx/random_reroll" hx-vals='{"mode":"surprise"}' hx-include="#random-theme{% if show_diagnostics %},#rand-attempts,#rand-timeout{% endif %}" hx-target="#random-result" hx-swap="outerHTML" hx-trigger="click delay:150ms" hx-disabled-elt="#btn-surprise,#btn-reroll" aria-label="Surprise me">Surprise me</button>
|
||||
<button id="btn-reroll" class="btn" hx-post="/hx/random_reroll" hx-vals='{"mode":"reroll_same_commander"}' hx-include="#current-seed,#current-commander,#random-theme{% if show_diagnostics %},#rand-attempts,#rand-timeout{% endif %}" hx-target="#random-result" hx-swap="outerHTML" hx-trigger="click delay:150ms" hx-disabled-elt="#btn-surprise,#btn-reroll" aria-label="Reroll" disabled>Reroll</button>
|
||||
<button id="btn-share" class="btn" type="button" aria-label="Copy permalink" onclick="(async ()=>{try{const r=await fetch('/build/permalink'); const j=await r.json(); const url=(j.permalink? location.origin + j.permalink : location.href); await navigator.clipboard.writeText(url); (window.toast && toast('Permalink copied')) || alert('Permalink copied');}catch(e){console.error(e); alert('Failed to copy permalink');}})()">Share</button>
|
||||
<span id="spinner" role="status" aria-live="polite" style="display:none; margin-left:8px;">Loading…</span>
|
||||
</div>
|
||||
<div id="rate-limit-banner" role="status" aria-live="polite" style="display:none; margin-top:8px; padding:6px 8px; border:1px solid #cc9900; background:#fff8e1; color:#5f4200; border-radius:4px;">
|
||||
Too many requests. Please wait…
|
||||
</div>
|
||||
<div id="random-area" style="margin-top:12px;">
|
||||
<div id="random-result" class="random-result empty" aria-live="polite">Click “Surprise me” to build a deck.</div>
|
||||
<div id="recent-seeds" style="margin-top:10px; font-size:12px; color:var(--text-muted);">
|
||||
<button id="btn-load-seeds" class="btn" type="button" style="font-size:11px; padding:2px 6px;">Show Recent Seeds</button>
|
||||
<button id="btn-metrics" class="btn" type="button" style="font-size:11px; padding:2px 6px;" title="Download NDJSON metrics" {% if not random_modes %}disabled{% endif %}>Metrics</button>
|
||||
<span id="seed-list" style="margin-left:6px;"></span>
|
||||
<div id="favorite-seeds" style="margin-top:6px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function(){
|
||||
// Typeahead: simple debounce + /themes/suggest
|
||||
var input = document.getElementById('random-theme');
|
||||
var listBox = document.getElementById('theme-suggest-box');
|
||||
var to = null;
|
||||
var cache = new Map(); // simple in-memory cache of q -> [names]
|
||||
var activeIndex = -1; // keyboard highlight
|
||||
function hideList(){ if(listBox){ listBox.style.display='none'; input.setAttribute('aria-expanded','false'); activeIndex=-1; } }
|
||||
function highlight(text, q){
|
||||
try{ if(!q) return text; var i=text.toLowerCase().indexOf(q.toLowerCase()); if(i===-1) return text; return text.substring(0,i)+'<mark style="background:#4f46e5; color:#fff; padding:0 2px; border-radius:2px;">'+text.substring(i,i+q.length)+'</mark>'+text.substring(i+q.length);}catch(e){return text;}}
|
||||
function renderList(items, q){
|
||||
if(!listBox) return; listBox.innerHTML=''; activeIndex=-1;
|
||||
if(!items || !items.length){ hideList(); return; }
|
||||
items.slice(0,50).forEach(function(it, idx){
|
||||
var div=document.createElement('div');
|
||||
div.setAttribute('role','option');
|
||||
div.setAttribute('data-value', it);
|
||||
div.innerHTML=highlight(it, q);
|
||||
div.style.cssText='padding:4px 8px; cursor:pointer;';
|
||||
div.addEventListener('mouseenter', function(){ setActive(idx); });
|
||||
div.addEventListener('mousedown', function(ev){ ev.preventDefault(); pick(it); });
|
||||
listBox.appendChild(div);
|
||||
});
|
||||
listBox.style.display='block';
|
||||
input.setAttribute('aria-expanded','true');
|
||||
}
|
||||
function setActive(idx){
|
||||
if(!listBox) return; var children=[...listBox.children];
|
||||
children.forEach(function(c,i){ c.style.background = (i===idx) ? 'rgba(99,102,241,0.35)' : 'transparent'; });
|
||||
activeIndex = idx;
|
||||
}
|
||||
function move(delta){
|
||||
if(!listBox || listBox.style.display==='none'){ return; }
|
||||
var children=[...listBox.children]; if(!children.length) return;
|
||||
var next = activeIndex + delta; if(next < 0) next = children.length -1; if(next >= children.length) next = 0;
|
||||
setActive(next);
|
||||
var el = children[next]; if(el && el.scrollIntoView){ el.scrollIntoView({block:'nearest'}); }
|
||||
}
|
||||
function pick(value){ input.value = value; hideList(); input.dispatchEvent(new Event('change')); }
|
||||
function updateList(items, q){ renderList(items, q); }
|
||||
function showRateLimitBanner(seconds){
|
||||
var b = document.getElementById('rate-limit-banner');
|
||||
var btn1 = document.getElementById('btn-surprise');
|
||||
var btn2 = document.getElementById('btn-reroll');
|
||||
if(!b){ return; }
|
||||
var secs = (typeof seconds === 'number' && !isNaN(seconds) && seconds > 0) ? Math.floor(seconds) : null;
|
||||
var base = 'Too many requests';
|
||||
var update = function(){
|
||||
if(secs !== null){ b.textContent = base + ' — try again in ' + secs + 's'; }
|
||||
else { b.textContent = base + ' — please try again shortly'; }
|
||||
};
|
||||
update();
|
||||
b.style.display = 'block';
|
||||
if(btn1) btn1.disabled = true; if(btn2) btn2.disabled = true;
|
||||
if(secs !== null){
|
||||
var t = setInterval(function(){
|
||||
secs -= 1; update();
|
||||
if(secs <= 0){ clearInterval(t); b.style.display = 'none'; if(btn1) btn1.disabled = false; if(btn2) btn2.disabled = false; }
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
function highlightMatch(item, q){
|
||||
try{
|
||||
var idx = item.toLowerCase().indexOf(q.toLowerCase());
|
||||
if(idx === -1) return item;
|
||||
return item.substring(0,idx) + '[[' + item.substring(idx, idx+q.length) + ']]' + item.substring(idx+q.length);
|
||||
}catch(e){ return item; }
|
||||
}
|
||||
async function fetchSuggest(q){
|
||||
try{
|
||||
var u = '/themes/api/suggest' + (q? ('?q=' + encodeURIComponent(q)) : '');
|
||||
if(cache.has(q)) { updateList(cache.get(q)); return; }
|
||||
var r = await fetch(u);
|
||||
if(r.status === 429){
|
||||
var ra = r.headers.get('Retry-After');
|
||||
var secs = ra ? parseInt(ra, 10) : null;
|
||||
var msg = 'You are being rate limited';
|
||||
if(secs && !isNaN(secs)) msg += ' — retry in ' + secs + 's';
|
||||
if(window.toast) { toast(msg); } else { console.warn(msg); }
|
||||
showRateLimitBanner(secs);
|
||||
return updateList([]);
|
||||
}
|
||||
if(!r.ok) return updateList([]);
|
||||
var j = await r.json();
|
||||
var items = (j && j.themes) || [];
|
||||
cache.set(q, items);
|
||||
// cap cache size to 50
|
||||
if(cache.size > 50){
|
||||
var firstKey = cache.keys().next().value; cache.delete(firstKey);
|
||||
}
|
||||
updateList(items, q);
|
||||
}catch(e){ /* no-op */ }
|
||||
}
|
||||
if(input){
|
||||
input.addEventListener('input', function(){
|
||||
var q = input.value || '';
|
||||
if(to) clearTimeout(to);
|
||||
if(!q || q.length < 2){ hideList(); return; }
|
||||
to = setTimeout(function(){ fetchSuggest(q); }, 150);
|
||||
});
|
||||
input.addEventListener('keydown', function(ev){
|
||||
if(ev.key === 'ArrowDown'){ ev.preventDefault(); move(1); }
|
||||
else if(ev.key === 'ArrowUp'){ ev.preventDefault(); move(-1); }
|
||||
else if(ev.key === 'Enter'){ if(activeIndex >=0 && listBox && listBox.children[activeIndex]){ ev.preventDefault(); pick(listBox.children[activeIndex].getAttribute('data-value')); } }
|
||||
else if(ev.key === 'Escape'){ hideList(); }
|
||||
});
|
||||
document.addEventListener('click', function(ev){ if(!listBox) return; if(ev.target === input || listBox.contains(ev.target)){ return; } hideList(); });
|
||||
}
|
||||
// Relying on hx-trigger delay (150ms) for soft debounce. Added hx-disabled-elt to avoid rapid spamming.
|
||||
document.addEventListener('htmx:afterRequest', function(){
|
||||
// Safety: ensure buttons are always re-enabled after request completes
|
||||
var b1=document.getElementById('btn-surprise'); var b2=document.getElementById('btn-reroll');
|
||||
if(b1) b1.disabled=false; if(b2 && document.getElementById('current-seed')) b2.disabled=false;
|
||||
});
|
||||
// (No configRequest hook needed; using hx-vals + hx-include for simple form-style submission.)
|
||||
// Enable reroll once a result exists
|
||||
document.addEventListener('htmx:afterSwap', function(ev){
|
||||
if (ev && ev.detail && ev.detail.target && ev.detail.target.id === 'random-result'){
|
||||
var rr = document.getElementById('btn-reroll'); if (rr) rr.disabled = false;
|
||||
// Refresh recent seeds asynchronously
|
||||
fetch('/api/random/seeds').then(r=>r.json()).then(function(j){
|
||||
try{
|
||||
if(!j || !j.seeds) return; var span=document.getElementById('seed-list'); if(!span) return;
|
||||
span.textContent = j.seeds.join(', ');
|
||||
}catch(e){}
|
||||
}).catch(function(){});
|
||||
}
|
||||
});
|
||||
// Simple spinner hooks
|
||||
document.addEventListener('htmx:beforeRequest', function(){ var s=document.getElementById('spinner'); if(s) s.style.display='inline-block'; });
|
||||
document.addEventListener('htmx:afterRequest', function(){ var s=document.getElementById('spinner'); if(s) s.style.display='none'; });
|
||||
// HTMX-friendly rate limit message on 429 + countdown banner
|
||||
document.addEventListener('htmx:afterOnLoad', function(ev){
|
||||
try{
|
||||
var xhr = ev && ev.detail && ev.detail.xhr; if(!xhr) return;
|
||||
if(xhr.status === 429){
|
||||
var ra = xhr.getResponseHeader('Retry-After');
|
||||
var secs = ra ? parseInt(ra, 10) : null;
|
||||
var msg = 'Too many requests';
|
||||
if(secs && !isNaN(secs)) msg += ' — try again in ' + secs + 's';
|
||||
if(window.toast) { toast(msg); } else { alert(msg); }
|
||||
showRateLimitBanner(secs);
|
||||
}
|
||||
}catch(e){/* no-op */}
|
||||
});
|
||||
|
||||
function favoriteButton(seed, favorites){
|
||||
var isFav = favorites.includes(seed);
|
||||
var b=document.createElement('button');
|
||||
b.type='button';
|
||||
b.textContent = isFav ? '★' : '☆';
|
||||
b.title = isFav ? 'Remove from favorites' : 'Add to favorites';
|
||||
b.style.cssText='font-size:12px; margin-left:2px; padding:0 4px; line-height:1;';
|
||||
b.addEventListener('click', function(ev){
|
||||
ev.stopPropagation();
|
||||
fetch('/api/random/seed_favorite', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({seed: seed})})
|
||||
.then(r=>r.json()).then(function(){
|
||||
// refresh seeds display
|
||||
loadSeeds(true);
|
||||
}).catch(()=>{});
|
||||
});
|
||||
return b;
|
||||
}
|
||||
function renderFavorites(favorites){
|
||||
var container=document.getElementById('favorite-seeds'); if(!container) return;
|
||||
if(!favorites || !favorites.length){ container.textContent=''; return; }
|
||||
container.innerHTML='<span style="margin-right:4px;">Favorites:</span>';
|
||||
favorites.forEach(function(s){
|
||||
var btn=document.createElement('button'); btn.type='button'; btn.className='btn'; btn.textContent=s; btn.style.cssText='font-size:10px; margin-right:4px; padding:2px 5px;';
|
||||
btn.addEventListener('click', function(){
|
||||
fetch('/hx/random_reroll', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ seed: s-1, theme: document.getElementById('random-theme').value || null }) })
|
||||
.then(r=>r.text()).then(html=>{ var target=document.getElementById('random-result'); if(target){ target.outerHTML=html; } });
|
||||
});
|
||||
container.appendChild(btn);
|
||||
});
|
||||
}
|
||||
function renderSeedList(seeds, favorites){
|
||||
var span=document.getElementById('seed-list'); if(!span) return;
|
||||
if(!seeds || !seeds.length){ span.textContent='(none yet)'; return; }
|
||||
span.innerHTML='';
|
||||
seeds.slice().forEach(function(s){
|
||||
var b=document.createElement('button');
|
||||
b.type='button';
|
||||
b.textContent=s;
|
||||
b.className='btn seed-btn';
|
||||
b.style.cssText='font-size:10px; margin-right:4px; padding:2px 5px;';
|
||||
b.setAttribute('aria-label','Rebuild using seed '+s);
|
||||
b.addEventListener('click', function(){
|
||||
// Post to reroll endpoint but treat as explicit seed build
|
||||
fetch('/hx/random_reroll', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ seed: s-1, theme: document.getElementById('random-theme').value || null }) })
|
||||
.then(r=> r.text())
|
||||
.then(html=>{ var target=document.getElementById('random-result'); if(target){ target.outerHTML=html; } })
|
||||
.catch(()=>{});
|
||||
});
|
||||
span.appendChild(b);
|
||||
span.appendChild(favoriteButton(s, favorites || []));
|
||||
});
|
||||
}
|
||||
function loadSeeds(refreshFavs){
|
||||
fetch('/api/random/seeds').then(r=>r.json()).then(function(j){
|
||||
if(!j){ renderSeedList([]); return; }
|
||||
renderSeedList(j.seeds || [], j.favorites || []);
|
||||
if(refreshFavs) renderFavorites(j.favorites || []);
|
||||
}).catch(function(){ var span=document.getElementById('seed-list'); if(span) span.textContent='(error)'; });
|
||||
}
|
||||
|
||||
// Manual load seeds button
|
||||
var btnSeeds = document.getElementById('btn-load-seeds');
|
||||
if(btnSeeds){ btnSeeds.addEventListener('click', function(){ loadSeeds(true); }); }
|
||||
var btnMetrics = document.getElementById('btn-metrics');
|
||||
if(btnMetrics){
|
||||
btnMetrics.addEventListener('click', function(){
|
||||
fetch('/status/random_metrics_ndjson').then(r=>r.text()).then(function(t){
|
||||
try{ var blob=new Blob([t], {type:'application/x-ndjson'}); var url=URL.createObjectURL(blob); var a=document.createElement('a'); a.href=url; a.download='random_metrics.ndjson'; document.body.appendChild(a); a.click(); setTimeout(function(){ URL.revokeObjectURL(url); a.remove(); }, 1000);}catch(e){ console.error(e); }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Persist last used theme in localStorage
|
||||
try {
|
||||
var THEME_KEY='random_last_theme';
|
||||
if(input){
|
||||
var prev = localStorage.getItem(THEME_KEY);
|
||||
if(prev && !input.value){ input.value = prev; }
|
||||
input.addEventListener('change', function(){ localStorage.setItem(THEME_KEY, input.value || ''); });
|
||||
}
|
||||
} catch(e) { /* ignore */ }
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
Loading…
Add table
Add a link
Reference in a new issue