mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-16 15:40:12 +01:00
539 lines
33 KiB
HTML
539 lines
33 KiB
HTML
{% from 'partials/_macros.html' import color_identity %}
|
||
{% set combined = combined_commander if combined_commander else {} %}
|
||
{% set display_commander_name = commander_display_name or commander %}
|
||
{% if not display_commander_name %}
|
||
{% set display_commander_name = commander %}
|
||
{% endif %}
|
||
{% set color_identity_list = commander_color_identity if commander_color_identity else [] %}
|
||
{% if not color_identity_list and summary and summary.colors %}
|
||
{% set color_identity_list = summary.colors %}
|
||
{% endif %}
|
||
{% set color_label = commander_color_label %}
|
||
{% if not color_label and color_identity_list %}
|
||
{% set color_label = color_identity_list|join(' / ') %}
|
||
{% endif %}
|
||
{% if not color_label and (color_identity_list|length == 0) and combined %}
|
||
{% set color_label = 'Colorless (C)' %}
|
||
{% endif %}
|
||
{% set display_tags_source = deck_theme_tags if deck_theme_tags else (tags if tags else commander_combined_tags) %}
|
||
{% set hover_tags_source = deck_theme_tags if deck_theme_tags else commander_combined_tags %}
|
||
{% set hover_tags_joined = hover_tags_source|join(', ') %}
|
||
{% set display_tags = display_tags_source if display_tags_source else [] %}
|
||
{% set show_color_identity = color_label or (color_identity_list|length > 0) %}
|
||
<section>
|
||
{# Step phases removed #}
|
||
<div class="two-col two-col-left-rail">
|
||
<aside class="card-preview">
|
||
{# Strip synergy annotation for Scryfall search #}
|
||
{% if commander %}
|
||
{% set commander_base = (commander.split(' - Synergy (')[0] if ' - Synergy (' in commander else commander) %}
|
||
<div class="commander-card" tabindex="0"
|
||
data-card-name="{{ commander_base }}"
|
||
data-original-name="{{ commander }}"
|
||
data-role="{{ commander_role_label or 'Commander' }}"
|
||
{% if hover_tags_joined %}data-tags="{{ hover_tags_joined }}"{% endif %}
|
||
{% if commander_tag_slugs %}data-tags-slug="{{ commander_tag_slugs|join(' ') }}"{% endif %}
|
||
{% if commander_overlap_tags %}data-overlaps="{{ commander_overlap_tags|join(', ') }}"{% endif %}
|
||
{% if commander_reason_text %}data-reasons="{{ commander_reason_text|e }}"{% endif %}>
|
||
<img src="{{ commander_base|card_image('normal') }}" alt="{{ commander }} card image"
|
||
width="320"
|
||
data-card-name="{{ commander_base }}"
|
||
data-original-name="{{ commander }}"
|
||
data-role="{{ commander_role_label or 'Commander' }}"
|
||
{% if hover_tags_joined %}data-tags="{{ hover_tags_joined }}"{% endif %}
|
||
{% if commander_tag_slugs %}data-tags-slug="{{ commander_tag_slugs|join(' ') }}"{% endif %}
|
||
{% if commander_overlap_tags %}data-overlaps="{{ commander_overlap_tags|join(', ') }}"{% endif %}
|
||
{% if commander_reason_text %}data-reasons="{{ commander_reason_text|e }}"{% endif %}
|
||
loading="lazy" decoding="async" data-lqip="1"
|
||
srcset="{{ commander_base|card_image('small') }} 160w, {{ commander_base|card_image('normal') }} 488w"
|
||
sizes="(max-width: 900px) 100vw, 320px" />
|
||
</div>
|
||
<div class="muted mt-1">
|
||
Commander: <span data-card-name="{{ commander }}"
|
||
data-original-name="{{ commander }}"
|
||
data-role="{{ commander_role_label or 'Commander' }}"
|
||
{% if hover_tags_joined %}data-tags="{{ hover_tags_joined }}"{% endif %}
|
||
{% if commander_tag_slugs %}data-tags-slug="{{ commander_tag_slugs|join(' ') }}"{% endif %}
|
||
{% if commander_overlap_tags %}data-overlaps="{{ commander_overlap_tags|join(', ') }}"{% endif %}
|
||
{% if commander_reason_text %}data-reasons="{{ commander_reason_text|e }}"{% endif %}>{{ display_commander_name or commander }}</span>
|
||
</div>
|
||
{% endif %}
|
||
{% if combined and combined.secondary_name %}
|
||
{% set partner_secondary_name = combined.secondary_name %}
|
||
{% set partner_role_label = combined.secondary_role_label or ('Background' if (combined.partner_mode == 'background') else 'Partner commander') %}
|
||
{% set partner_theme_tags = combined.theme_tags if combined.theme_tags else [] %}
|
||
{% set partner_tags_joined = partner_theme_tags|join(', ') %}
|
||
{% if partner_secondary_name %}
|
||
{% set partner_name_base = (partner_secondary_name.split(' - Synergy (')[0] if ' - Synergy (' in partner_secondary_name else partner_secondary_name) %}
|
||
{% else %}
|
||
{% set partner_name_base = partner_secondary_name %}
|
||
{% endif %}
|
||
{% set partner_href = combined.secondary_scryfall_url or combined.scryfall_url %}
|
||
{% if not partner_href and partner_name_base %}
|
||
{% set partner_href = 'https://scryfall.com/search?q=' ~ partner_name_base|urlencode %}
|
||
{% endif %}
|
||
<div class="commander-card partner-card" tabindex="0"
|
||
data-card-name="{{ partner_name_base }}"
|
||
data-original-name="{{ partner_secondary_name }}"
|
||
data-role="{{ partner_role_label }}"
|
||
{% if partner_tags_joined %}data-tags="{{ partner_tags_joined }}" data-overlaps="{{ partner_tags_joined }}"{% endif %}>
|
||
{% if partner_href %}<a href="{{ partner_href }}" target="_blank" rel="noopener">{% endif %}
|
||
{% if partner_name_base %}
|
||
<img src="{{ partner_name_base|card_image('normal') }}" alt="{{ (partner_secondary_name or 'Selected card') ~ ' card image' }}"
|
||
width="320"
|
||
data-card-name="{{ partner_name_base }}"
|
||
data-original-name="{{ partner_secondary_name }}"
|
||
data-role="{{ partner_role_label }}"
|
||
{% if partner_tags_joined %}data-tags="{{ partner_tags_joined }}" data-overlaps="{{ partner_tags_joined }}"{% endif %}
|
||
loading="lazy" decoding="async" data-lqip="1"
|
||
srcset="{{ partner_name_base|card_image('small') }} 160w, {{ partner_name_base|card_image('normal') }} 488w"
|
||
sizes="(max-width: 900px) 100vw, 320px" />
|
||
{% else %}
|
||
<img src="{{ combined.secondary_image_url or combined.image_url }}" alt="{{ (partner_secondary_name or 'Selected card') ~ ' card image' }}" loading="lazy" decoding="async" width="320" />
|
||
{% endif %}
|
||
{% if partner_href %}</a>{% endif %}
|
||
</div>
|
||
<div class="muted partner-label mt-1.5">
|
||
{{ partner_role_label }}:
|
||
<span data-card-name="{{ partner_secondary_name }}"
|
||
data-original-name="{{ partner_secondary_name }}"
|
||
data-role="{{ partner_role_label }}"
|
||
{% if partner_tags_joined %}data-tags="{{ partner_tags_joined }}" data-overlaps="{{ partner_tags_joined }}"{% endif %}>{{ partner_secondary_name }}</span>
|
||
</div>
|
||
<div class="muted partner-meta text-xs mt-1">
|
||
Pairing: {{ combined.primary_name or display_commander_name or commander }}{% if partner_secondary_name %} + {{ partner_secondary_name }}{% endif %}
|
||
</div>
|
||
{% if combined.color_label %}
|
||
<div class="muted partner-meta text-xs mt-1">
|
||
Colors: {{ combined.color_label }}
|
||
</div>
|
||
{% endif %}
|
||
{% if partner_theme_tags %}
|
||
<div class="muted partner-meta text-xs mt-1">
|
||
Theme emphasis: {{ partner_theme_tags|join(', ') }}
|
||
</div>
|
||
{% endif %}
|
||
{% endif %}
|
||
{% if status and status.startswith('Build complete') %}
|
||
<div class="mt-3 flex gap-1.5 flex-wrap">
|
||
{% if csv_path %}
|
||
<form action="/files" method="get" target="_blank" class="inline m-0">
|
||
<input type="hidden" name="path" value="{{ csv_path }}" />
|
||
<button type="submit">Download CSV</button>
|
||
</form>
|
||
{% endif %}
|
||
{% if txt_path %}
|
||
<form action="/files" method="get" target="_blank" class="inline m-0">
|
||
<input type="hidden" name="path" value="{{ txt_path }}" />
|
||
<button type="submit">Download TXT</button>
|
||
</form>
|
||
{% endif %}
|
||
</div>
|
||
{% endif %}
|
||
</aside>
|
||
<div class="grow" data-skeleton>
|
||
<div hx-get="/build/banner" hx-trigger="load"></div>
|
||
|
||
|
||
<p>Commander:
|
||
{% if commander %}
|
||
<strong class="commander-hover"
|
||
data-card-name="{{ commander }}"
|
||
data-original-name="{{ commander }}"
|
||
data-role="{{ commander_role_label or 'Commander' }}"
|
||
{% if hover_tags_joined %}data-tags="{{ hover_tags_joined }}"{% endif %}
|
||
{% if commander_tag_slugs %}data-tags-slug="{{ commander_tag_slugs|join(' ') }}"{% endif %}
|
||
{% if commander_overlap_tags %}data-overlaps="{{ commander_overlap_tags|join(', ') }}"{% endif %}
|
||
{% if commander_reason_text %}data-reasons="{{ commander_reason_text|e }}"{% endif %}>{{ display_commander_name or commander }}</strong>
|
||
{% else %}
|
||
<strong>None selected</strong>
|
||
{% endif %}
|
||
</p>
|
||
{% if show_color_identity %}
|
||
<div class="muted flex items-center gap-1.5 -my-1.5 mb-2">
|
||
{{ color_identity(color_identity_list, is_colorless=(color_identity_list|length == 0), aria_label=color_label or '', title_text=color_label or '') }}
|
||
<span>{{ color_label }}</span>
|
||
</div>
|
||
{% endif %}
|
||
<p>Tags: {% if display_tags %}{{ display_tags|join(', ') }}{% else %}—{% endif %}</p>
|
||
<div class="my-1.5 text-muted flex gap-2 items-center flex-wrap">
|
||
<span>Owned-only: <strong>{{ 'On' if owned_only else 'Off' }}</strong></span>
|
||
<div class="flex items-center gap-4 flex-wrap">
|
||
<button type="button" hx-get="/build/step4" hx-target="#wizard" hx-swap="innerHTML" class="bg-gray-700 text-gray-200 border-0 rounded-md px-2 py-1 cursor-pointer text-xs" title="Change owned settings in Review">Edit in Review</button>
|
||
<div>Prefer-owned: <strong>{{ 'On' if prefer_owned else 'Off' }}</strong></div>
|
||
<div>MDFC swap: <strong>{{ 'On' if swap_mdfc_basics else 'Off' }}</strong></div>
|
||
</div>
|
||
<span class="ml-auto"><a href="/owned" target="_blank" rel="noopener" class="btn">Manage Owned Library</a></span>
|
||
</div>
|
||
<p>Bracket: {{ bracket }}</p>
|
||
|
||
<div class="flex items-center gap-2 flex-wrap my-1 mb-2">
|
||
{% if i and n %}
|
||
<span class="chip"><span class="dot"></span> Stage {{ i }}/{{ n }}</span>
|
||
{% endif %}
|
||
{% set deck_count = (total_cards if total_cards is not none else 0) %}
|
||
<span class="chip"><span class="dot dot-green"></span> Deck {{ deck_count }}/100</span>
|
||
{% if added_total is not none %}
|
||
<span class="chip"><span class="dot dot-blue"></span> Added {{ added_total }}</span>
|
||
{% endif %}
|
||
{% if prefer_combos %}
|
||
<span class="chip" title="Combos plan"><span class="dot dot-orange"></span> Combos: {{ combo_target_count }} ({{ combo_balance }})</span>
|
||
{% endif %}
|
||
{% if clamped_overflow is defined and clamped_overflow and (clamped_overflow > 0) %}
|
||
<span class="chip" title="Trimmed overflow from this stage"><span class="dot dot-red"></span> Clamped {{ clamped_overflow }}</span>
|
||
{% endif %}
|
||
{% if stage_label and stage_label == 'Multi-Copy Package' and mc_summary is defined and mc_summary %}
|
||
<span class="chip" title="Multi-Copy package summary"><span class="dot dot-purple"></span> {{ mc_summary }}</span>
|
||
{% endif %}
|
||
<span id="locks-chip">{% if locks and locks|length > 0 %}<span class="chip" title="Locked cards">🔒 {{ locks|length }} locked</span>{% endif %}</span>
|
||
<button type="button" class="btn ml-auto" title="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+'#'+btoa(JSON.stringify(j.state||{}))); await navigator.clipboard.writeText(url); toast && toast('Permalink copied');}catch(e){alert('Copied state to console'); console.log(e);}})()">Copy Permalink</button>
|
||
<button type="button" class="btn" title="Open a saved permalink" onclick="(function(){try{var token = prompt('Paste a /build/from?state=... URL or token:'); if(!token) return; var m = token.match(/state=([^&]+)/); var t = m? m[1] : token.trim(); if(!t) return; window.location.href = '/build/from?state=' + encodeURIComponent(t); }catch(_){}})()">Open Permalink…</button>
|
||
</div>
|
||
{% set pct = ((deck_count / 100.0) * 100.0) if deck_count else 0 %}
|
||
{% set pct_clamped = (pct if pct <= 100 else 100) %}
|
||
{% set pct_int = pct_clamped|int %}
|
||
<div class="progress{% if added_cards is defined and added_cards is not none and (added_cards|length == 0) and (status and not status.startswith('Build complete')) %} flash{% endif %} my-1 mb-4" aria-label="Deck progress" title="{{ deck_count }} of 100 cards" data-pct="{{ pct_int }}">
|
||
<div class="bar"></div>
|
||
</div>
|
||
|
||
{% if mc_adjustments is defined and mc_adjustments and stage_label and stage_label == 'Multi-Copy Package' %}
|
||
<div class="muted my-1">Adjusted targets: {{ mc_adjustments|join(', ') }}</div>
|
||
{% endif %}
|
||
|
||
{% if status %}
|
||
<div class="mt-4">
|
||
<strong>Status:</strong> {{ status }}{% if stage_label %} — <em>{{ stage_label }}</em>{% endif %}
|
||
</div>
|
||
{% endif %}
|
||
{% if gated and (not status or not status.startswith('Build complete')) %}
|
||
<div class="alert-error">
|
||
Compliance gating active — resolve violations above (replace or remove cards) to continue.
|
||
</div>
|
||
{% endif %}
|
||
|
||
{# Load compliance panel as soon as the page renders, regardless of final status #}
|
||
<div hx-get="/build/compliance" hx-trigger="load" hx-swap="afterend"></div>
|
||
{% if status and status.startswith('Build complete') %}
|
||
<div hx-get="/build/combos" hx-trigger="load" hx-swap="afterend"></div>
|
||
{% endif %}
|
||
|
||
{% if locked_cards is defined and locked_cards %}
|
||
{% from 'partials/_macros.html' import lock_button %}
|
||
<details id="locked-section" class="mt-2">
|
||
<summary>Locked cards (always kept)</summary>
|
||
<ul id="locked-list" class="locked-list">
|
||
{% for lk in locked_cards %}
|
||
<li class="locked-item">
|
||
<span class="chip"><span class="dot"></span> {{ lk.name }}</span>
|
||
<span class="muted">{% if lk.owned %}✔ Owned{% else %}✖ Not owned{% endif %}</span>
|
||
{% if lk.in_deck %}<span class="muted">• In deck</span>{% else %}<span class="muted">• Will be included on rerun</span>{% endif %}
|
||
<div class="lock-box-inline">
|
||
{{ lock_button(lk.name, True, from_list=True, target_selector='closest li') }}
|
||
</div>
|
||
</li>
|
||
{% endfor %}
|
||
</ul>
|
||
</details>
|
||
{% endif %}
|
||
|
||
<!-- Last action chip (oob-updated) -->
|
||
<div id="last-action" aria-live="polite" class="my-1 last-action"></div>
|
||
|
||
<!-- Filters toolbar -->
|
||
<div class="cards-toolbar">
|
||
<input type="text" name="filter_query" placeholder="Filter by name, role, or tag" data-pref="cards:filter_q" />
|
||
<select name="filter_owned" data-pref="cards:owned">
|
||
<option value="all">All</option>
|
||
<option value="owned">Owned</option>
|
||
<option value="not">Not owned</option>
|
||
</select>
|
||
<label class="form-label-icon">
|
||
<input type="checkbox" name="show_reasons" data-pref="cards:show_reasons" checked /> Show reasons
|
||
</label>
|
||
<label class="form-label-icon">
|
||
<input type="checkbox" name="collapse_groups" data-pref="cards:collapse" /> Collapse groups
|
||
</label>
|
||
<select name="filter_sort" data-pref="cards:sort" aria-label="Sort">
|
||
<option value="az">A–Z</option>
|
||
<option value="owned">Owned first</option>
|
||
<option value="gc">Game-changers first</option>
|
||
</select>
|
||
<span class="sep"></span>
|
||
<span class="hint">Visible: <strong data-results>0</strong></span>
|
||
<span class="sep"></span>
|
||
<div class="chips-inline">
|
||
<span class="chip" data-chip-owned="all">All</span>
|
||
<span class="chip" data-chip-owned="owned">Owned</span>
|
||
<span class="chip" data-chip-owned="not">Not owned</span>
|
||
<span class="chip" data-chip-clear>Clear</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Sticky build controls on mobile -->
|
||
<div class="build-controls">
|
||
<form hx-post="/build/step5/start" hx-target="#wizard" hx-swap="innerHTML" class="inline-form mr-2" onsubmit="try{ toast('Restarting build…'); }catch(_){}">
|
||
<input type="hidden" name="show_skipped" value="{{ '1' if show_skipped else '0' }}" />
|
||
<button type="submit" class="btn-continue" data-action="continue">Restart Build</button>
|
||
</form>
|
||
<form hx-post="/build/step5/continue" hx-target="#wizard" hx-swap="innerHTML" class="inline-form" onsubmit="try{ toast('Continuing…'); }catch(_){}">
|
||
<input type="hidden" name="show_skipped" value="{{ '1' if show_skipped else '0' }}" />
|
||
<button type="submit" class="btn-continue" data-action="continue" {% if (status and status.startswith('Build complete')) or gated %}disabled{% endif %}>Continue</button>
|
||
</form>
|
||
<form hx-post="/build/step5/rerun" hx-target="#wizard" hx-swap="innerHTML" class="inline-form" onsubmit="try{ toast('Rerunning stage…'); }catch(_){}">
|
||
<input type="hidden" name="show_skipped" value="{{ '1' if show_skipped else '0' }}" />
|
||
<button type="submit" class="btn-rerun" data-action="rerun" {% if (status and status.startswith('Build complete')) or gated %}disabled{% endif %}>Rerun Stage</button>
|
||
</form>
|
||
<span class="sep"></span>
|
||
<div class="replace-toggle" role="group" aria-label="Replace toggle">
|
||
<form hx-post="/build/step5/toggle-replace" hx-target="closest .replace-toggle" hx-swap="outerHTML" onsubmit="return false;" class="inline-form">
|
||
<input type="hidden" name="replace" value="{{ '1' if replace_mode else '0' }}" />
|
||
<label class="muted form-label-icon" title="When enabled, reruns of this stage will replace its picks with alternatives instead of keeping them.">
|
||
<input type="checkbox" name="replace_chk" value="1" {% if replace_mode %}checked{% endif %}
|
||
onchange="try{ const f=this.form; const h=f.querySelector('input[name=replace]'); if(h){ h.value=this.checked?'1':'0'; } f.requestSubmit(); }catch(_){ }" />
|
||
Replace stage picks
|
||
</label>
|
||
</form>
|
||
</div>
|
||
<form hx-post="/build/step5/reset-stage" hx-target="#wizard" hx-swap="innerHTML" class="inline-form">
|
||
<button type="submit" class="btn" title="Reset this stage to pre-stage picks">Reset stage</button>
|
||
</form>
|
||
<form hx-post="/build/reset-all" hx-target="#wizard" hx-swap="innerHTML" class="inline-form">
|
||
<button type="submit" class="btn" title="Start a brand new build (clears selections)">New build</button>
|
||
</form>
|
||
<label class="muted form-label-icon ml-2">
|
||
<input type="checkbox" name="__toggle_show_skipped" data-pref="build:show_skipped" {% if show_skipped %}checked{% endif %}
|
||
onchange="const val=this.checked?'1':'0'; for(const f of this.closest('section').querySelectorAll('form')){ const h=f.querySelector('input[name=show_skipped]'); if(h) h.value=val; }" />
|
||
Show skipped stages
|
||
</label>
|
||
<button type="button" class="btn-back" data-action="back" hx-get="/build/step4" hx-target="#wizard" hx-swap="innerHTML">Back</button>
|
||
</div>
|
||
|
||
{% if added_cards is not none %}
|
||
{% if history is defined and history %}
|
||
<details class="mt-2">
|
||
<summary>Stage timeline</summary>
|
||
<div class="muted text-xs my-1">Jump back to a previous stage, then you can continue forward again.</div>
|
||
<ul class="timeline-list">
|
||
{% for h in history %}
|
||
<li class="timeline-item">
|
||
<span class="chip"><span class="dot"></span> {{ h.label }}</span>
|
||
<form hx-post="/build/step5/rewind" hx-target="#wizard" hx-swap="innerHTML" class="inline-form m-0">
|
||
<input type="hidden" name="to" value="{{ h.i }}" />
|
||
<button type="submit" class="btn">Go</button>
|
||
</form>
|
||
</li>
|
||
{% endfor %}
|
||
</ul>
|
||
</details>
|
||
{% endif %}
|
||
<h4 class="mt-4">Cards added this stage</h4>
|
||
{% if skipped and (not added_cards or added_cards|length == 0) %}
|
||
<div class="muted my-2">No cards added in this stage.</div>
|
||
{% endif %}
|
||
<div class="muted text-xs my-1 flex gap-3 items-center flex-wrap">
|
||
<span><span class="ownership-badge">✔</span> Owned</span>
|
||
<span><span class="ownership-badge">✖</span> Not owned</span>
|
||
</div>
|
||
|
||
{% if stage_label and stage_label.startswith('Creatures') %}
|
||
{% set groups = added_cards|groupby('sub_role') %}
|
||
{% for g in groups %}
|
||
{% set group_idx = loop.index0 %}
|
||
{% set role = g.grouper %}
|
||
{% if role %}
|
||
{% set heading = 'Theme: ' + role.title() %}
|
||
{% else %}
|
||
{% set heading = 'Additional Picks' %}
|
||
{% endif %}
|
||
<div class="group" data-group-key="{{ (role or 'other')|lower|replace(' ', '-') }}">
|
||
<div class="group-header">
|
||
<h5 class="my-2">{{ heading }}</h5>
|
||
<span class="count">(<span data-count>{{ g.list|length }}</span>)</span>
|
||
<button type="button" class="toggle" title="Collapse/Expand">Toggle</button>
|
||
</div>
|
||
<div class="card-grid group-grid" data-skeleton {% if virtualize %}data-virtualize="1"{% endif %}>
|
||
{% for c in g.list %}
|
||
{% set owned = (owned_set is defined and c.name and (c.name|lower in owned_set)) %}
|
||
{% set is_locked = (locks is defined and (c.name|lower in locks)) %}
|
||
<div class="card-tile{% if game_changers and (c.name in game_changers) %} game-changer{% endif %}{% if is_locked %} locked{% endif %}{% if c.must_include %} must-include{% endif %}{% if c.must_exclude %} must-exclude{% 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-tags-slug="{{ (c.tags_slug|join(', ')) if c.tags_slug else '' }}" data-owned="{{ '1' if owned else '0' }}"{% if c.reason %} data-reasons="{{ c.reason|e }}"{% endif %}
|
||
data-must-include="{{ '1' if c.must_include else '0' }}" data-must-exclude="{{ '1' if c.must_exclude else '0' }}">
|
||
<div class="img-btn" role="button" tabindex="0" title="Tap or click to view {{ c.name }}" aria-label="View {{ c.name }} details">
|
||
<img class="card-thumb" src="{{ c.name|card_image('normal') }}" alt="{{ c.name }} image" width="160" data-card-name="{{ c.name }}" loading="lazy" decoding="async" data-lqip="1"
|
||
srcset="{{ c.name|card_image('small') }} 160w, {{ c.name|card_image('normal') }} 488w"
|
||
sizes="160px" />
|
||
</div>
|
||
<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|safe }}{% if c.count and c.count > 1 %} ×{{ c.count }}{% endif %}</div>
|
||
<div class="lock-box" id="lock-{{ group_idx }}-{{ loop.index0 }}" class="flex justify-center gap-1 mt-1">
|
||
{% from 'partials/_macros.html' import lock_button %}
|
||
{{ lock_button(c.name, is_locked) }}
|
||
</div>
|
||
{% if allow_must_haves and show_must_have_buttons %}
|
||
<div class="must-have-controls" role="group" aria-label="Must have controls">
|
||
<button type="button" class="btn-chip must-have-btn include" data-toggle="include" data-card-name="{{ c.name }}" data-card-label="{{ c.must_include_label or c.name }}" data-active="{{ '1' if c.must_include else '0' }}" title="Must include forces future reruns to add this card whenever it's legal. Unlike Lock, it re-adds the card if it falls out.">Must include</button>
|
||
<button type="button" class="btn-chip must-have-btn exclude" data-toggle="exclude" data-card-name="{{ c.name }}" data-card-label="{{ c.must_exclude_label or c.name }}" data-active="{{ '1' if c.must_exclude else '0' }}" title="Must exclude keeps this card out of future reruns before selection. Use it when you never want the builder to pick it again.">Must exclude</button>
|
||
</div>
|
||
{% endif %}
|
||
{% if c.reason %}
|
||
<div class="card-actions-center">
|
||
<button type="button" class="btn-why" aria-expanded="false">Why?</button>
|
||
<button type="button" class="btn" hx-get="/build/alternatives" hx-vals='{"name": "{{ c.name }}"}' hx-target="#alts-{{ group_idx }}-{{ loop.index0 }}" hx-swap="innerHTML" title="Suggest alternatives"
|
||
data-hx-cache="1" data-hx-cache-key="alts:{{ c.name|lower }}" data-hx-cache-ttl="20000"
|
||
data-hx-prefetch="/build/alternatives?name={{ c.name|urlencode }}">Alternatives</button>
|
||
</div>
|
||
<div class="reason" role="region" aria-label="Reason">{{ c.reason }}</div>
|
||
{% else %}
|
||
<div class="flex justify-center mt-1">
|
||
<button type="button" class="btn" hx-get="/build/alternatives" hx-vals='{"name": "{{ c.name }}"}' hx-target="#alts-{{ group_idx }}-{{ loop.index0 }}" hx-swap="innerHTML" title="Suggest alternatives"
|
||
data-hx-cache="1" data-hx-cache-key="alts:{{ c.name|lower }}" data-hx-cache-ttl="20000"
|
||
data-hx-prefetch="/build/alternatives?name={{ c.name|urlencode }}">Alternatives</button>
|
||
</div>
|
||
{% endif %}
|
||
<div id="alts-{{ group_idx }}-{{ loop.index0 }}" class="alts mt-1"></div>
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
</div>
|
||
{% endfor %}
|
||
{% else %}
|
||
<div class="card-grid" data-skeleton {% if virtualize %}data-virtualize="1"{% endif %}>
|
||
{% for c in added_cards %}
|
||
{% set owned = (owned_set is defined and c.name and (c.name|lower in owned_set)) %}
|
||
{% set is_locked = (locks is defined and (c.name|lower in locks)) %}
|
||
<div class="card-tile{% if game_changers and (c.name in game_changers) %} game-changer{% endif %}{% if is_locked %} locked{% endif %}{% if c.must_include %} must-include{% endif %}{% if c.must_exclude %} must-exclude{% 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-tags-slug="{{ (c.tags_slug|join(', ')) if c.tags_slug else '' }}" data-owned="{{ '1' if owned else '0' }}"{% if c.reason %} data-reasons="{{ c.reason|e }}"{% endif %}
|
||
data-must-include="{{ '1' if c.must_include else '0' }}" data-must-exclude="{{ '1' if c.must_exclude else '0' }}">
|
||
<div class="img-btn" role="button" tabindex="0" title="Tap or click to view {{ c.name }}" aria-label="View {{ c.name }} details">
|
||
<img class="card-thumb" src="{{ c.name|card_image('normal') }}" alt="{{ c.name }} image" width="160" data-card-name="{{ c.name }}" loading="lazy" decoding="async" data-lqip="1"
|
||
srcset="{{ c.name|card_image('small') }} 160w, {{ c.name|card_image('normal') }} 488w"
|
||
sizes="160px" />
|
||
</div>
|
||
<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|safe }}{% if c.count and c.count > 1 %} ×{{ c.count }}{% endif %}</div>
|
||
<div class="lock-box" id="lock-{{ loop.index0 }}" class="flex justify-center gap-1 mt-1">
|
||
{% from 'partials/_macros.html' import lock_button %}
|
||
{{ lock_button(c.name, is_locked) }}
|
||
</div>
|
||
{% if allow_must_haves and show_must_have_buttons %}
|
||
<div class="must-have-controls" role="group" aria-label="Must have controls">
|
||
<button type="button" class="btn-chip must-have-btn include" data-toggle="include" data-card-name="{{ c.name }}" data-card-label="{{ c.must_include_label or c.name }}" data-active="{{ '1' if c.must_include else '0' }}" title="Must include forces future reruns to add this card whenever it's legal. Unlike Lock, it re-adds the card if it falls out.">Must include</button>
|
||
<button type="button" class="btn-chip must-have-btn exclude" data-toggle="exclude" data-card-name="{{ c.name }}" data-card-label="{{ c.must_exclude_label or c.name }}" data-active="{{ '1' if c.must_exclude else '0' }}" title="Must exclude keeps this card out of future reruns before selection. Use it when you never want the builder to pick it again.">Must exclude</button>
|
||
</div>
|
||
{% endif %}
|
||
{% if c.reason %}
|
||
<div class="card-actions-center">
|
||
<button type="button" class="btn-why" aria-expanded="false">Why?</button>
|
||
<button type="button" class="btn" hx-get="/build/alternatives" hx-vals='{"name": "{{ c.name }}"}' hx-target="#alts-{{ loop.index0 }}" hx-swap="innerHTML" title="Suggest alternatives"
|
||
data-hx-cache="1" data-hx-cache-key="alts:{{ c.name|lower }}" data-hx-cache-ttl="20000"
|
||
data-hx-prefetch="/build/alternatives?name={{ c.name|urlencode }}">Alternatives</button>
|
||
</div>
|
||
<div class="reason" role="region" aria-label="Reason">{{ c.reason }}</div>
|
||
{% else %}
|
||
<div class="flex justify-center mt-1">
|
||
<button type="button" class="btn" hx-get="/build/alternatives" hx-vals='{"name": "{{ c.name }}"}' hx-target="#alts-{{ loop.index0 }}" hx-swap="innerHTML" title="Suggest alternatives"
|
||
data-hx-cache="1" data-hx-cache-key="alts:{{ c.name|lower }}" data-hx-cache-ttl="20000"
|
||
data-hx-prefetch="/build/alternatives?name={{ c.name|urlencode }}">Alternatives</button>
|
||
</div>
|
||
{% endif %}
|
||
<div id="alts-{{ loop.index0 }}" class="alts mt-1"></div>
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
{% endif %}
|
||
{% if allow_must_haves and show_must_have_buttons %}
|
||
<div class="muted text-xs my-1">Tip: Use the 🔒 Lock button to preserve the current copy in the deck. "Must include" will try to pull the card back in on future reruns, while "Must exclude" blocks the engine from selecting it again. Tap or click the card art to view details without changing the lock state.</div>
|
||
{% else %}
|
||
<div class="muted text-xs my-1">Tip: Use the 🔒 Lock button under each card to keep it across reruns. Tap or click the card art to view details without changing the lock state.</div>
|
||
{% endif %}
|
||
<div data-empty hidden role="status" aria-live="polite" class="muted mt-2">
|
||
No cards match your filters.
|
||
</div>
|
||
{% endif %}
|
||
|
||
{% if show_logs and log %}
|
||
<details class="mt-4">
|
||
<summary>Show logs</summary>
|
||
<pre class="build-log">{{ log }}</pre>
|
||
</details>
|
||
{% endif %}
|
||
|
||
<!-- controls now above -->
|
||
|
||
{% if allow_must_haves %}
|
||
{% set oob = False %}
|
||
{% include "partials/include_exclude_summary.html" %}
|
||
{% endif %}
|
||
<div id="deck-summary" data-summary
|
||
hx-get="/build/step5/summary?token={{ summary_token }}"
|
||
hx-trigger="load once, step5:refresh from:body"
|
||
hx-swap="outerHTML">
|
||
<div class="muted mt-4">
|
||
{% if summary_ready %}Loading deck summary…{% else %}Deck summary will appear after the build completes.{% endif %}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
<script>
|
||
// Sync tile class after lock button swaps
|
||
document.addEventListener('htmx:afterSwap', function(ev){
|
||
try{
|
||
const tgt = ev.target;
|
||
if(!tgt) return;
|
||
if(!tgt.classList || !tgt.classList.contains('lock-box')) return;
|
||
const tile = tgt.closest('.card-tile');
|
||
if(!tile) return;
|
||
const lockBtn = tgt.querySelector('.btn-lock');
|
||
if(lockBtn){
|
||
const isLocked = (lockBtn.getAttribute('data-locked') === '1');
|
||
tile.classList.toggle('locked', isLocked);
|
||
}
|
||
}catch(_){/* noop */}
|
||
});
|
||
// Keyboard activation for preview tile when focused
|
||
document.addEventListener('keydown', function(ev){
|
||
try{
|
||
if(ev.key !== 'Enter' && ev.key !== ' ') return;
|
||
const target = ev.target;
|
||
if(!target || !target.classList || !target.classList.contains('img-btn')) return;
|
||
ev.preventDefault();
|
||
ev.stopPropagation();
|
||
const tile = target.closest('.card-tile');
|
||
if(tile && window.__hoverShowCard){ window.__hoverShowCard(tile); }
|
||
}catch(_){/* noop */}
|
||
});
|
||
// Allow dismissing/auto-clearing the last-action chip
|
||
document.addEventListener('click', function(ev){
|
||
try{
|
||
var t = ev.target;
|
||
if (!t) return;
|
||
if (t.matches && t.matches('#last-action .chip')){
|
||
var c = document.getElementById('last-action');
|
||
if (c) c.innerHTML = '';
|
||
}
|
||
}catch(_){/* noop */}
|
||
});
|
||
setTimeout(function(){ try{ var c=document.getElementById('last-action'); if(c && c.firstElementChild){ c.innerHTML=''; } }catch(_){} }, 6000);
|
||
// Keyboard helpers: when a card-tile is focused, L toggles lock, R opens alternatives
|
||
document.addEventListener('keydown', function(e){
|
||
try{
|
||
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
||
var tag = (e.target && e.target.tagName) ? e.target.tagName.toLowerCase() : '';
|
||
// Ignore when typing in inputs/selects
|
||
if (tag === 'input' || tag === 'textarea' || tag === 'select') return;
|
||
var tile = document.activeElement && document.activeElement.closest ? document.activeElement.closest('.card-tile') : null;
|
||
if (!tile) return;
|
||
if (e.key === 'l' || e.key === 'L') {
|
||
e.preventDefault(); e.stopPropagation();
|
||
var lockFormBtn = tile.querySelector('.lock-box .btn-lock');
|
||
if (lockFormBtn) { lockFormBtn.click(); }
|
||
} else if (e.key === 'r' || e.key === 'R') {
|
||
e.preventDefault(); e.stopPropagation();
|
||
var altBtn = tile.querySelector('button[hx-get="/build/alternatives"]');
|
||
if (altBtn) { altBtn.click(); }
|
||
}
|
||
}catch(_){ }
|
||
});
|
||
</script>
|