mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-18 00:20:13 +01:00
feat(web): launch commander browser with deck builder CTA
This commit is contained in:
parent
6e9ba244c9
commit
8e57588f40
27 changed files with 1960 additions and 45 deletions
|
|
@ -3,6 +3,7 @@
|
|||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta http-equiv="Content-Security-Policy" content="upgrade-insecure-requests" />
|
||||
<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>
|
||||
<script>
|
||||
|
|
@ -81,6 +82,7 @@
|
|||
<a href="/configs">Build from JSON</a>
|
||||
{% if show_setup %}<a href="/setup">Setup/Tag</a>{% endif %}
|
||||
<a href="/owned">Owned Library</a>
|
||||
{% if show_commanders %}<a href="/commanders">Commanders</a>{% endif %}
|
||||
<a href="/decks">Finished Decks</a>
|
||||
<a href="/themes/">Themes</a>
|
||||
{% if random_ui %}<a href="/random">Random</a>{% endif %}
|
||||
|
|
@ -172,6 +174,10 @@
|
|||
#hover-card-panel .hcp-body { display:grid; grid-template-columns: 320px 1fr; gap:18px; align-items:start; }
|
||||
#hover-card-panel .hcp-img-wrap { grid-column:1 / 2; }
|
||||
#hover-card-panel.compact-img .hcp-body { grid-template-columns: 120px 1fr; }
|
||||
#hover-card-panel.hcp-simple { width:auto !important; max-width:min(360px, 90vw) !important; padding:12px !important; height:auto !important; max-height:none !important; overflow:hidden !important; }
|
||||
#hover-card-panel.hcp-simple .hcp-body { display:flex; flex-direction:column; gap:12px; align-items:center; }
|
||||
#hover-card-panel.hcp-simple .hcp-right { display:none !important; }
|
||||
#hover-card-panel.hcp-simple .hcp-img { max-width:100%; }
|
||||
/* Tag list as multi-column list instead of pill chips for readability */
|
||||
#hover-card-panel .hcp-taglist { columns:2; column-gap:18px; font-size:13px; line-height:1.3; margin:6px 0 6px; padding:0; list-style:none; max-height:180px; overflow:auto; }
|
||||
#hover-card-panel .hcp-taglist li { break-inside:avoid; padding:2px 0 2px 0; position:relative; }
|
||||
|
|
@ -195,9 +201,9 @@
|
|||
.list-row .dfc-toggle .icon { font-size:12px; }
|
||||
.list-row .dfc-toggle[data-face='back'] { background:rgba(76,29,149,.3); }
|
||||
.list-row .dfc-toggle[data-face='front'] { background:rgba(56,189,248,.2); }
|
||||
#hover-card-panel.mobile { left:50% !important; top:auto !important; bottom:max(16px, 5vh); transform:translateX(-50%); width:min(92vw, 420px) !important; max-height:80vh; overflow-y:auto; padding:16px 18px; pointer-events:auto !important; }
|
||||
#hover-card-panel.mobile .hcp-body { display:flex; flex-direction:column; gap:18px; }
|
||||
#hover-card-panel.mobile .hcp-img { max-width:100%; margin:0 auto; }
|
||||
#hover-card-panel.mobile { left:50% !important; top:50% !important; bottom:auto !important; transform:translate(-50%, -50%); width:min(94vw, 460px) !important; max-height:88vh; overflow-y:auto; padding:20px 22px; pointer-events:auto !important; }
|
||||
#hover-card-panel.mobile .hcp-body { display:flex; flex-direction:column; gap:20px; }
|
||||
#hover-card-panel.mobile .hcp-img { width:100%; max-width:min(90vw, 420px) !important; margin:0 auto; }
|
||||
#hover-card-panel.mobile .hcp-right { width:100%; display:flex; flex-direction:column; gap:10px; align-items:flex-start; }
|
||||
#hover-card-panel.mobile .hcp-header { flex-wrap:wrap; gap:8px; align-items:flex-start; }
|
||||
#hover-card-panel.mobile .hcp-role { font-size:12px; letter-spacing:.55px; }
|
||||
|
|
@ -923,12 +929,14 @@
|
|||
var panel = ensurePanel();
|
||||
if(!panel || panel.__hoverInit) return;
|
||||
panel.__hoverInit = true;
|
||||
var imgEl = panel.querySelector('.hcp-img');
|
||||
var nameEl = panel.querySelector('.hcp-name');
|
||||
var rarityEl = panel.querySelector('.hcp-rarity');
|
||||
var metaEl = panel.querySelector('.hcp-meta');
|
||||
var reasonsList = panel.querySelector('.hcp-reasons');
|
||||
var tagsEl = panel.querySelector('.hcp-tags');
|
||||
var imgEl = panel.querySelector('.hcp-img');
|
||||
var nameEl = panel.querySelector('.hcp-name');
|
||||
var rarityEl = panel.querySelector('.hcp-rarity');
|
||||
var metaEl = panel.querySelector('.hcp-meta');
|
||||
var reasonsList = panel.querySelector('.hcp-reasons');
|
||||
var tagsEl = panel.querySelector('.hcp-tags');
|
||||
var bodyEl = panel.querySelector('.hcp-body');
|
||||
var rightCol = panel.querySelector('.hcp-right');
|
||||
var coarseQuery = window.matchMedia('(pointer: coarse)');
|
||||
function isMobileMode(){ return (coarseQuery && coarseQuery.matches) || window.innerWidth <= 768; }
|
||||
function refreshPosition(){ if(panel.style.display==='block'){ move(window.__lastPointerEvent); } }
|
||||
|
|
@ -946,12 +954,11 @@
|
|||
function positionPanel(evt){
|
||||
if(isMobileMode()){
|
||||
panel.classList.add('mobile');
|
||||
var bottomOffset = Math.max(16, Math.round(window.innerHeight * 0.05));
|
||||
panel.style.bottom = bottomOffset + 'px';
|
||||
panel.style.bottom = 'auto';
|
||||
panel.style.left = '50%';
|
||||
panel.style.top = 'auto';
|
||||
panel.style.top = '50%';
|
||||
panel.style.right = 'auto';
|
||||
panel.style.transform = 'translateX(-50%)';
|
||||
panel.style.transform = 'translate(-50%, -50%)';
|
||||
panel.style.pointerEvents = 'auto';
|
||||
} else {
|
||||
panel.classList.remove('mobile');
|
||||
|
|
@ -990,6 +997,11 @@
|
|||
if(!card) return;
|
||||
// Prefer attributes on container, fallback to child (image) if missing
|
||||
function attr(name){ return card.getAttribute(name) || (card.querySelector('[data-'+name.slice(5)+']') && card.querySelector('[data-'+name.slice(5)+']').getAttribute(name)) || ''; }
|
||||
var simpleSource = null;
|
||||
if(card.closest){
|
||||
simpleSource = card.closest('[data-hover-simple]');
|
||||
}
|
||||
var forceSimple = (card.hasAttribute && card.hasAttribute('data-hover-simple')) || !!simpleSource;
|
||||
var nm = attr('data-card-name') || attr('data-original-name') || 'Card';
|
||||
var rarity = (attr('data-rarity')||'').trim();
|
||||
var mana = (attr('data-mana')||'').trim();
|
||||
|
|
@ -1110,6 +1122,26 @@
|
|||
}
|
||||
panel.classList.toggle('is-payoff', role === 'payoff');
|
||||
panel.classList.toggle('is-commander', isCommanderRole);
|
||||
var hasDetails = !forceSimple && (
|
||||
!!roleLabel || !!mana || !!rarity || (reasonsRaw && reasonsRaw.trim()) || (overlapArr && overlapArr.length) || (allTags && allTags.length)
|
||||
);
|
||||
panel.classList.toggle('hcp-simple', !hasDetails);
|
||||
if(rightCol){
|
||||
rightCol.style.display = hasDetails ? 'flex' : 'none';
|
||||
}
|
||||
if(bodyEl){
|
||||
if(!hasDetails){
|
||||
bodyEl.style.display = 'flex';
|
||||
bodyEl.style.flexDirection = 'column';
|
||||
bodyEl.style.alignItems = 'center';
|
||||
bodyEl.style.gap = '12px';
|
||||
} else {
|
||||
bodyEl.style.display = '';
|
||||
bodyEl.style.flexDirection = '';
|
||||
bodyEl.style.alignItems = '';
|
||||
bodyEl.style.gap = '';
|
||||
}
|
||||
}
|
||||
var fuzzy = encodeURIComponent(nm);
|
||||
var rawName = nm || '';
|
||||
var hasBack = rawName.indexOf('//')>-1 || (attr('data-original-name')||'').indexOf('//')>-1;
|
||||
|
|
|
|||
|
|
@ -2,12 +2,80 @@
|
|||
{% block banner_subtitle %}Build a Deck{% endblock %}
|
||||
{% block content %}
|
||||
<h2>Build a Deck</h2>
|
||||
<div style="margin:.25rem 0 1rem 0;">
|
||||
<div style="margin:.25rem 0 1rem 0; display:flex; align-items:center; gap:.75rem; flex-wrap:wrap;">
|
||||
<button type="button" class="btn" hx-get="/build/new" hx-target="body" hx-swap="beforeend">Build a New Deck…</button>
|
||||
<span class="muted" style="margin-left:.5rem;">Quick-start wizard (name, commander, themes, ideals)</span>
|
||||
<span class="muted" style="margin-left:.25rem;">Quick-start wizard (name, commander, themes, ideals)</span>
|
||||
{% if return_url %}
|
||||
<a href="{{ return_url }}" class="btn" style="margin-left:auto;">← Back to Commanders</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div id="wizard">
|
||||
<!-- Wizard content will load here after the modal submit starts the build. -->
|
||||
<noscript><p>Enable JavaScript to build a deck.</p></noscript>
|
||||
</div>
|
||||
{% if commander %}
|
||||
<span id="builder-init" data-commander="{{ commander|e }}" hidden></span>
|
||||
<script>
|
||||
(function(){
|
||||
var opened = false;
|
||||
function openWizard(){
|
||||
if(opened) return; opened = true;
|
||||
try{
|
||||
var btn = document.querySelector('button.btn[hx-get="/build/new"]');
|
||||
if(btn){ btn.click(); }
|
||||
}catch(_){ }
|
||||
}
|
||||
// Pre-fill and auto-inspect when the modal content is injected
|
||||
function onModalLoaded(e){
|
||||
try{
|
||||
var target = (e && e.detail && e.detail.target) ? e.detail.target : null; if(!target) return;
|
||||
if(!(target.tagName && target.tagName.toLowerCase() === 'body')) return;
|
||||
var init = document.getElementById('builder-init');
|
||||
var preset = init && init.dataset ? (init.dataset.commander || '') : '';
|
||||
if(!preset) return;
|
||||
var input = document.querySelector('input[name="commander"]');
|
||||
if(input){
|
||||
if(!input.value){ input.value = preset; }
|
||||
try { input.dispatchEvent(new Event('input', {bubbles:true})); } catch(_){ }
|
||||
try { input.focus(); } catch(_){ }
|
||||
}
|
||||
// If htmx is available, auto-load the inspect view for an exact preset name.
|
||||
try {
|
||||
if (window.htmx && preset && typeof window.htmx.ajax === 'function'){
|
||||
window.htmx.ajax('GET', '/build/new/inspect?name=' + encodeURIComponent(preset), { target: '#newdeck-tags-slot', swap: 'innerHTML' });
|
||||
// Also try to load multi-copy suggestions based on current radio defaults
|
||||
setTimeout(function(){
|
||||
try{
|
||||
var mode = document.querySelector('input[name="tag_mode"]') || document.getElementById('modal_tag_mode');
|
||||
var primary = document.getElementById('modal_primary_tag');
|
||||
var secondary = document.getElementById('modal_secondary_tag');
|
||||
var tertiary = document.getElementById('modal_tertiary_tag');
|
||||
var params = new URLSearchParams();
|
||||
params.set('commander', preset);
|
||||
if (primary && primary.value) params.set('primary_tag', primary.value);
|
||||
if (secondary && secondary.value) params.set('secondary_tag', secondary.value);
|
||||
if (tertiary && tertiary.value) params.set('tertiary_tag', tertiary.value);
|
||||
if (mode && mode.value) params.set('tag_mode', mode.value);
|
||||
window.htmx.ajax('GET', '/build/new/multicopy?' + params.toString(), { target: '#newdeck-multicopy-slot', swap: 'innerHTML' });
|
||||
}catch(_){ }
|
||||
}, 250);
|
||||
}
|
||||
} catch(_){ }
|
||||
}catch(_){ }
|
||||
}
|
||||
document.addEventListener('htmx:afterSwap', onModalLoaded);
|
||||
// Open after DOM is ready; try a few hooks to ensure htmx is initialized
|
||||
if (document.readyState === 'complete' || document.readyState === 'interactive') {
|
||||
setTimeout(openWizard, 0);
|
||||
} else {
|
||||
document.addEventListener('DOMContentLoaded', function(){ setTimeout(openWizard, 0); });
|
||||
}
|
||||
if (window.htmx && typeof window.htmx.onLoad === 'function'){
|
||||
window.htmx.onLoad(function(){ setTimeout(openWizard, 0); });
|
||||
}
|
||||
// Last resort: delayed attempt in case previous hooks raced htmx init
|
||||
setTimeout(openWizard, 200);
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
|
|
|||
201
code/web/templates/commanders/index.html
Normal file
201
code/web/templates/commanders/index.html
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<section class="commander-page">
|
||||
<header class="commander-hero">
|
||||
<h2>Commanders</h2>
|
||||
<p class="muted">Browse the catalog and jump straight into a build with your chosen leader.</p>
|
||||
</header>
|
||||
|
||||
<form
|
||||
id="commander-filter-form"
|
||||
class="commander-filters"
|
||||
action="/commanders"
|
||||
method="get"
|
||||
hx-get="/commanders"
|
||||
hx-target="#commander-results"
|
||||
hx-trigger="submit, change from:#commander-color, keyup changed delay:300ms from:#commander-search"
|
||||
hx-include="#commander-filter-form"
|
||||
hx-push-url="true"
|
||||
hx-indicator="#commander-loading"
|
||||
novalidate
|
||||
>
|
||||
<label>
|
||||
<span class="filter-label">Search</span>
|
||||
<input type="search" id="commander-search" name="q" value="{{ query }}" placeholder="Search commanders, themes, or text..." autocomplete="off" />
|
||||
</label>
|
||||
<label>
|
||||
<span class="filter-label">Color identity</span>
|
||||
<select id="commander-color" name="color">
|
||||
<option value="">All colors</option>
|
||||
{% for code, label in color_options %}
|
||||
<option value="{{ code }}" {% if color == code %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<input type="hidden" name="page" value="{{ page }}" />
|
||||
<button type="submit" class="btn filter-submit">Apply</button>
|
||||
</form>
|
||||
|
||||
<div id="commander-loading" class="commander-loading" role="status" aria-live="polite">
|
||||
<span class="sr-only">Loading commanders…</span>
|
||||
<div class="commander-skeleton-list" aria-hidden="true">
|
||||
{% for i in range(3) %}
|
||||
<article class="commander-skeleton">
|
||||
<div class="skeleton-thumb shimmer"></div>
|
||||
<div class="skeleton-main">
|
||||
<div class="skeleton-line skeleton-title shimmer"></div>
|
||||
<div class="skeleton-line skeleton-meta shimmer"></div>
|
||||
<div class="skeleton-chip-row">
|
||||
<span class="skeleton-chip shimmer"></span>
|
||||
<span class="skeleton-chip shimmer"></span>
|
||||
<span class="skeleton-chip shimmer"></span>
|
||||
</div>
|
||||
<div class="skeleton-line skeleton-text shimmer"></div>
|
||||
</div>
|
||||
<div class="skeleton-cta shimmer"></div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="commander-results">
|
||||
{% include "commanders/list_fragment.html" %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.commander-page { display:flex; flex-direction:column; gap:1.25rem; }
|
||||
.commander-hero h2 { margin:0; font-size:1.75rem; }
|
||||
.commander-hero p { margin:0; max-width:60ch; }
|
||||
.commander-filters { display:flex; flex-wrap:wrap; gap:.75rem 1rem; align-items:flex-end; }
|
||||
.commander-filters label { display:flex; flex-direction:column; gap:.35rem; min-width:220px; }
|
||||
.filter-label { font-size:.85rem; color:var(--muted); letter-spacing:.03em; text-transform:uppercase; }
|
||||
.commander-filters input,
|
||||
.commander-filters select { background:var(--panel); color:var(--text); border:1px solid var(--border); border-radius:8px; padding:.45rem .6rem; min-height:2.4rem; }
|
||||
.commander-filters input:focus,
|
||||
.commander-filters select:focus { outline:2px solid var(--ring); outline-offset:2px; }
|
||||
.filter-submit { height:2.4rem; align-self:flex-end; }
|
||||
|
||||
.commander-summary { font-size:.9rem; }
|
||||
.commander-error { padding:.75rem .9rem; border:1px solid #f87171; background:rgba(248,113,113,.12); border-radius:10px; color:#fca5a5; }
|
||||
.commander-empty { margin:1rem 0 0; }
|
||||
.commander-list { display:flex; flex-direction:column; gap:1rem; margin-top:.5rem; }
|
||||
|
||||
.commander-row { display:flex; gap:1rem; padding:1rem; border:1px solid var(--border); border-radius:14px; background:var(--panel); align-items:stretch; }
|
||||
.commander-thumb { width:160px; flex:0 0 auto; }
|
||||
.commander-thumb img { width:160px; height:auto; border-radius:10px; border:1px solid var(--border); background:#0b0d12; display:block; }
|
||||
.commander-main { flex:1 1 auto; display:flex; flex-direction:column; gap:.6rem; min-width:0; }
|
||||
.commander-header { display:flex; flex-wrap:wrap; align-items:center; gap:.5rem .75rem; }
|
||||
.commander-name { margin:0; font-size:1.25rem; }
|
||||
.color-identity { display:flex; align-items:center; gap:.35rem; }
|
||||
.commander-context { margin:0; font-size:.95rem; }
|
||||
.commander-themes { display:flex; flex-wrap:wrap; gap:.4rem; }
|
||||
.commander-themes-empty { font-size:.85rem; }
|
||||
.commander-theme-chip { display:inline-flex; align-items:center; gap:.25rem; padding:4px 10px; border-radius:9999px; border:1px solid var(--border); background:rgba(148,163,184,.15); font-size:.75rem; letter-spacing:.03em; color:inherit; cursor:pointer; transition:background .15s ease, border-color .15s ease, transform .15s ease; appearance:none; font:inherit; }
|
||||
.commander-theme-chip:focus-visible { outline:2px solid var(--ring); outline-offset:2px; }
|
||||
.commander-theme-chip:hover { background:rgba(148,163,184,.25); border-color:rgba(148,163,184,.45); transform:translateY(-1px); }
|
||||
.commander-partners { display:flex; flex-wrap:wrap; gap:.4rem; font-size:.85rem; }
|
||||
.commander-partner-sep { opacity:.6; }
|
||||
.commander-cta { margin-left:auto; display:flex; align-items:center; }
|
||||
.commander-cta .btn { white-space:nowrap; }
|
||||
.commander-pagination { display:flex; align-items:center; justify-content:space-between; gap:.75rem; margin-top:1rem; flex-wrap:wrap; }
|
||||
.commander-summary + .commander-pagination { margin-top:.75rem; }
|
||||
.commander-pagination .pagination-group { display:flex; align-items:center; gap:.5rem; flex-wrap:wrap; }
|
||||
.commander-pagination .commander-page-btn { display:inline-flex; align-items:center; justify-content:center; min-width:96px; }
|
||||
.commander-pagination .commander-page-btn[disabled],
|
||||
.commander-pagination .commander-page-btn.disabled { opacity:.55; cursor:default; pointer-events:none; }
|
||||
.commander-pagination-status { font-size:.85rem; color:var(--muted); }
|
||||
|
||||
.commander-loading { display:none; margin-top:1rem; }
|
||||
.commander-loading.htmx-request { display:block; }
|
||||
.commander-skeleton-list { display:flex; flex-direction:column; gap:1rem; }
|
||||
.commander-skeleton { display:flex; gap:1rem; padding:1rem; border:1px solid var(--border); border-radius:14px; background:var(--panel); align-items:stretch; }
|
||||
.skeleton-thumb { width:160px; height:220px; border-radius:10px; }
|
||||
.skeleton-main { flex:1 1 auto; display:flex; flex-direction:column; gap:.6rem; }
|
||||
.skeleton-line { height:16px; border-radius:9999px; }
|
||||
.skeleton-title { width:45%; height:22px; }
|
||||
.skeleton-meta { width:30%; }
|
||||
.skeleton-text { width:65%; }
|
||||
.skeleton-chip-row { display:flex; gap:.5rem; flex-wrap:wrap; }
|
||||
.skeleton-chip { width:90px; height:22px; border-radius:9999px; display:inline-block; }
|
||||
.skeleton-cta { width:120px; height:42px; border-radius:9999px; }
|
||||
.shimmer { background:linear-gradient(90deg, rgba(148,163,184,0.25) 25%, rgba(148,163,184,0.15) 37%, rgba(148,163,184,0.25) 63%); background-size:400% 100%; animation:commander-shimmer 1.4s ease-in-out infinite; }
|
||||
@keyframes commander-shimmer {
|
||||
0% { background-position:100% 0; }
|
||||
100% { background-position:-100% 0; }
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.commander-row { flex-direction:column; }
|
||||
.commander-thumb img { width:100%; max-width:280px; }
|
||||
.commander-cta { margin-left:0; }
|
||||
.commander-cta .btn { width:100%; justify-content:center; text-align:center; }
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.commander-filters { align-items:stretch; }
|
||||
.filter-submit { width:100%; }
|
||||
.commander-filters label { flex:1 1 100%; min-width:0; }
|
||||
.commander-thumb { width:min(70vw, 220px); align-self:center; }
|
||||
.commander-thumb img { width:100%; }
|
||||
.skeleton-thumb { width:min(70vw, 220px); height:calc(min(70vw, 220px) * 1.4); }
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
(function(){
|
||||
const form = document.getElementById('commander-filter-form');
|
||||
if (!form) return;
|
||||
const pageInput = form.querySelector('input[name="page"]');
|
||||
if (!pageInput) return;
|
||||
|
||||
const resetPage = () => { pageInput.value = '1'; };
|
||||
const searchField = document.getElementById('commander-search');
|
||||
const colorField = document.getElementById('commander-color');
|
||||
if (searchField) searchField.addEventListener('input', resetPage);
|
||||
if (colorField) colorField.addEventListener('change', resetPage);
|
||||
|
||||
const updatePageFromResults = (container) => {
|
||||
if (!container) return;
|
||||
const marker = container.querySelector('[data-current-page]');
|
||||
if (marker) {
|
||||
const current = marker.getAttribute('data-current-page');
|
||||
if (current) pageInput.value = current;
|
||||
}
|
||||
};
|
||||
|
||||
document.body.addEventListener('htmx:afterSwap', (event) => {
|
||||
const target = event.detail && event.detail.target;
|
||||
if (!target || target.id !== 'commander-results') return;
|
||||
updatePageFromResults(target);
|
||||
// Intelligent scroll-to-top: only when triggered from bottom controls or when the summary/top controls are off-screen
|
||||
const container = document.getElementById('commander-results');
|
||||
const searchEl = document.getElementById('commander-search');
|
||||
if (!container) return;
|
||||
const invoker = event.detail && event.detail.elt ? event.detail.elt : null;
|
||||
const fromBottom = invoker && invoker.closest && invoker.closest('[data-bottom-controls]');
|
||||
// If not from bottom, check whether the top of the results is already within view; if so, skip scroll
|
||||
const rect = container.getBoundingClientRect();
|
||||
const topInView = rect.top >= 0 && rect.top <= (window.innerHeight * 0.25);
|
||||
// If we're below the top controls (content's top is above viewport) or the click came from the bottom controls,
|
||||
// jump directly to the search input (no smooth animation) for fastest navigation.
|
||||
if (fromBottom || rect.top < 0) {
|
||||
requestAnimationFrame(() => {
|
||||
if (searchEl) {
|
||||
searchEl.scrollIntoView({ behavior: 'auto', block: 'start' });
|
||||
try { searchEl.focus({ preventScroll: true }); } catch(_) { /* no-op */ }
|
||||
} else {
|
||||
window.scrollTo({ top: 0, behavior: 'auto' });
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!topInView) {
|
||||
requestAnimationFrame(() => {
|
||||
container.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
updatePageFromResults(document.getElementById('commander-results'));
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
38
code/web/templates/commanders/list_fragment.html
Normal file
38
code/web/templates/commanders/list_fragment.html
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<div class="commander-results-inner" data-current-page="{{ page }}">
|
||||
{% if error %}
|
||||
<div class="commander-error" role="alert">{{ error }}</div>
|
||||
{% else %}
|
||||
<div class="commander-summary muted">
|
||||
{% if total_count %}
|
||||
{% if commanders %}
|
||||
Showing {{ page_start }} – {{ page_end }} of {{ result_total }} commander{% if result_total != 1 %}s{% endif %}{% if is_filtered %} (filtered){% endif %}.
|
||||
{% else %}
|
||||
No commanders matched your filters.
|
||||
{% endif %}
|
||||
{% else %}
|
||||
No commander data available.
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if commanders %}
|
||||
{% set pagination_position = 'top' %}
|
||||
{% include "commanders/pagination_controls.html" %}
|
||||
<div class="commander-list" role="list">
|
||||
{% for entry in commanders %}
|
||||
{% include "commanders/row_wireframe.html" %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if page_count > 1 %}
|
||||
{% set pagination_position = 'bottom' %}
|
||||
{% include "commanders/pagination_controls.html" %}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p class="muted commander-empty" role="status">
|
||||
{% if total_count %}
|
||||
No commanders matched your filters.
|
||||
{% else %}
|
||||
Commander catalog is empty.
|
||||
{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
37
code/web/templates/commanders/pagination_controls.html
Normal file
37
code/web/templates/commanders/pagination_controls.html
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<nav class="commander-pagination" role="navigation" aria-label="Commander pagination" {% if pagination_position == 'bottom' %}data-bottom-controls="1"{% endif %}>
|
||||
<div class="pagination-group">
|
||||
<a
|
||||
class="btn ghost commander-page-btn {% if not has_prev %}disabled{% endif %}"
|
||||
{% if has_prev %}
|
||||
href="{{ prev_url }}"
|
||||
hx-get="{{ prev_url }}"
|
||||
hx-target="#commander-results"
|
||||
hx-push-url="true"
|
||||
data-scroll-top-on-swap="1"
|
||||
{% else %}
|
||||
aria-disabled="true"
|
||||
tabindex="-1"
|
||||
{% endif %}
|
||||
>
|
||||
← Previous
|
||||
</a>
|
||||
<span class="commander-pagination-status" aria-live="polite">
|
||||
Page {{ page }} of {{ page_count }}
|
||||
</span>
|
||||
<a
|
||||
class="btn ghost commander-page-btn {% if not has_next %}disabled{% endif %}"
|
||||
{% if has_next %}
|
||||
href="{{ next_url }}"
|
||||
hx-get="{{ next_url }}"
|
||||
hx-target="#commander-results"
|
||||
hx-push-url="true"
|
||||
data-scroll-top-on-swap="1"
|
||||
{% else %}
|
||||
aria-disabled="true"
|
||||
tabindex="-1"
|
||||
{% endif %}
|
||||
>
|
||||
Next →
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
56
code/web/templates/commanders/row_wireframe.html
Normal file
56
code/web/templates/commanders/row_wireframe.html
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
{# Commander row partial fed by CommanderView entries #}
|
||||
{% from "partials/_macros.html" import color_identity %}
|
||||
{% set record = entry.record %}
|
||||
<article class="commander-row" data-commander-slug="{{ record.slug }}" data-hover-simple="true">
|
||||
<div class="commander-thumb">
|
||||
{% set small = record.image_small_url or record.image_normal_url %}
|
||||
<img
|
||||
src="{{ small }}"
|
||||
srcset="{{ small }} 160w, {{ record.image_normal_url or small }} 488w"
|
||||
sizes="160px"
|
||||
alt="{{ record.display_name }} card art"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
data-card-name="{{ record.display_name }}"
|
||||
data-hover-simple="true"
|
||||
/>
|
||||
</div>
|
||||
<div class="commander-main">
|
||||
<div class="commander-header">
|
||||
<h3 class="commander-name">{{ record.display_name }}</h3>
|
||||
{{ color_identity(record.color_identity, record.is_colorless, entry.color_aria_label, entry.color_label) }}
|
||||
</div>
|
||||
<p class="commander-context muted">{{ record.type_line or 'Legendary Creature' }}</p>
|
||||
{% if entry.themes %}
|
||||
<div class="commander-themes" role="list">
|
||||
{% for theme in entry.themes %}
|
||||
{% set summary = theme.summary or 'Summary unavailable' %}
|
||||
<button type="button"
|
||||
class="commander-theme-chip"
|
||||
role="listitem"
|
||||
data-theme-name="{{ theme.name }}"
|
||||
data-theme-slug="{{ theme.slug }}"
|
||||
data-theme-summary="{{ summary }}"
|
||||
title="{{ summary }}"
|
||||
aria-label="{{ theme.name }} theme: {{ summary }}">
|
||||
{{ theme.name }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="commander-themes commander-themes-empty">
|
||||
<span class="muted">No themes linked yet.</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if entry.partner_summary %}
|
||||
<div class="commander-partners muted">
|
||||
{% for note in entry.partner_summary %}
|
||||
<span>{{ note }}</span>{% if not loop.last %}<span aria-hidden="true" class="commander-partner-sep">•</span>{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="commander-cta">
|
||||
<a class="btn" href="/build?commander={{ record.display_name|urlencode }}&return={{ return_url|urlencode }}" data-commander="{{ record.slug }}">Build</a>
|
||||
</div>
|
||||
</article>
|
||||
|
|
@ -66,6 +66,7 @@
|
|||
+ 'SHOW_LOGS='+ (flags.SHOW_LOGS? '1':'0')
|
||||
+ ', SHOW_DIAGNOSTICS='+ (flags.SHOW_DIAGNOSTICS? '1':'0')
|
||||
+ ', SHOW_SETUP='+ (flags.SHOW_SETUP? '1':'0')
|
||||
+ ', SHOW_COMMANDERS='+ (flags.SHOW_COMMANDERS? '1':'0')
|
||||
+ ', RANDOM_MODES='+ (flags.RANDOM_MODES? '1':'0')
|
||||
+ ', RANDOM_UI='+ (flags.RANDOM_UI? '1':'0')
|
||||
+ ', RANDOM_MAX_ATTEMPTS='+ String(flags.RANDOM_MAX_ATTEMPTS ?? '')
|
||||
|
|
|
|||
|
|
@ -4,12 +4,14 @@
|
|||
<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 %}
|
||||
{% if show_setup %}<a class="action-button" href="/setup">Initial Setup</a>{% endif %}
|
||||
<a class="action-button" href="/owned">Owned Library</a>
|
||||
{% if show_commanders %}<a class="action-button" href="/commanders">Browse Commanders</a>{% endif %}
|
||||
<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 %}
|
||||
<a class="action-button" href="/themes/">Browse Themes</a>
|
||||
{% if random_ui %}<a class="action-button" href="/random">Random Build</a>{% endif %}
|
||||
{% if show_diagnostics %}<a class="action-button" href="/diagnostics">Diagnostics</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);">
|
||||
<span id="themes-quick-status">Themes: …</span>
|
||||
|
|
|
|||
|
|
@ -11,3 +11,19 @@
|
|||
{{ '🔒 Unlock' if locked else '🔓 Lock' }}
|
||||
</button>
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro color_identity(colors, is_colorless=False, aria_label='', title_text='') -%}
|
||||
<div class="color-identity" role="img"
|
||||
aria-label="{{ aria_label }}"
|
||||
data-colorless="{{ '1' if is_colorless or not (colors and colors|length) else '0' }}"
|
||||
{% if title_text %}title="{{ title_text }}"{% endif %}>
|
||||
{% if colors and colors|length %}
|
||||
{% for color in colors %}
|
||||
<span class="mana mana-{{ color }}" aria-hidden="true"></span>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<span class="mana mana-C" aria-hidden="true"></span>
|
||||
{% endif %}
|
||||
<span class="sr-only">{{ aria_label }}</span>
|
||||
</div>
|
||||
{%- endmacro %}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue