feat(web,docs): visual summaries (curve, pips/sources incl. 'C', non‑land sources), tooltip copy, favicon; diagnostics (/healthz, request‑id, global handlers); fetches excluded, basics CSV fallback, list highlight polish; README/DOCKER/release-notes/CHANGELOG updated

This commit is contained in:
matt 2025-08-26 20:00:07 -07:00
parent 625f6abb13
commit 8d1f6a8ac4
27 changed files with 1704 additions and 154 deletions

View file

@ -5,7 +5,11 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>MTG Deckbuilder</title>
<script src="https://unpkg.com/htmx.org@1.9.12" onerror="var s=document.createElement('script');s.src='/static/vendor/htmx-1.9.12.min.js';document.head.appendChild(s);"></script>
<link rel="stylesheet" href="/static/styles.css?v=20250826-3" />
<link rel="stylesheet" href="/static/styles.css?v=20250826-4" />
<!-- Favicon -->
<link rel="icon" type="image/png" href="/static/favicon.png" />
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/static/favicon.png" />
</head>
<body>
<header class="top-banner">
@ -156,5 +160,19 @@
document.addEventListener('htmx:afterSwap', function() { attachCardHover(); });
})();
</script>
<script src="/static/app.js?v=20250826-2"></script>
<script>
// Show pending toast after full page reloads when actions replace the whole document
(function(){
try{
var raw = sessionStorage.getItem('mtg:toastAfterReload');
if (raw){
sessionStorage.removeItem('mtg:toastAfterReload');
var data = JSON.parse(raw);
if (data && data.msg){ window.toast && window.toast(data.msg, data.type||''); }
}
}catch(_){ }
})();
</script>
</body>
</html>

View file

@ -0,0 +1,25 @@
{# Build Stage Navigator: shows steps and allows jumping via HTMX #}
{% set labels = ['Choose Commander','Tags & Bracket','Ideal Counts','Review','Build'] %}
{% set index = step_index if step_index is defined else i if i is defined else 1 %}
{% set total = step_total if step_total is defined else n if n is defined else 5 %}
<nav class="stage-nav" aria-label="Build stages">
<ol>
{% for idx in range(1, total+1) %}
{% set name = labels[idx-1] if (labels|length)>=idx else ('Step ' ~ idx) %}
{% set is_cur = (idx == index) %}
{% set is_done = (idx < index) %}
<li class="stage-item{% if is_cur %} current{% endif %}{% if is_done %} done{% endif %}">
<button
class="stage-link"
{% if is_cur %}aria-current="step"{% endif %}
hx-get="/build/step{{ idx }}"
hx-target="#wizard"
hx-swap="innerHTML"
title="Go to {{ name }}">
<span class="idx">{{ idx }}</span>
<span class="name">{{ name }}</span>
</button>
</li>
{% endfor %}
</ol>
</nav>

View file

@ -1,5 +1,7 @@
<section>
{% set step_index = 1 %}{% set step_total = 5 %}
<h3>Step 1: Choose a Commander</h3>
{% include "build/_stage_navigator.html" %}
<form id="cmdr-search-form" hx-post="/build/step1" hx-target="#wizard" hx-swap="innerHTML" aria-label="Commander search form" role="search">
<label for="cmdr-search">Search by name</label>
@ -90,11 +92,11 @@
<div style="margin-top:.75rem;">
<form style="display:inline" hx-post="/build/step1/confirm" hx-target="#wizard" hx-swap="innerHTML">
<input type="hidden" name="name" value="{{ selected }}" />
<button>Use this commander</button>
<button class="btn-continue" data-action="continue">Use this commander</button>
</form>
<form style="display:inline" hx-post="/build/step1" hx-target="#wizard" hx-swap="innerHTML">
<input type="hidden" name="query" value="" />
<button>Back to search</button>
<button class="btn-back" data-action="back">Back to search</button>
</form>
<form action="/build" method="get" style="display:inline; margin-left:.5rem;">
<button type="submit">Start over</button>

View file

@ -1,4 +1,5 @@
<section>
{% set step_index = 2 %}{% set step_total = 5 %}
<h3>Step 2: Tags & Bracket</h3>
<div class="two-col two-col-left-rail">
<aside class="card-preview" data-card-name="{{ commander.name }}">
@ -6,7 +7,8 @@
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander.name|urlencode }}&format=image&version=normal" alt="{{ commander.name }} card image" />
</a>
</aside>
<div class="grow">
<div class="grow" data-skeleton>
{% include "build/_stage_navigator.html" %}
<div hx-get="/build/banner?step=Tags%20%26%20Bracket&i=2&n=5" hx-trigger="load"></div>
<form hx-post="/build/step2" hx-target="#wizard" hx-swap="innerHTML">
@ -88,7 +90,7 @@
</fieldset>
<div style="margin-top:1rem;">
<button type="submit">Continue to Ideals</button>
<button type="submit" class="btn-continue" data-action="continue">Continue to Ideals</button>
</div>
</form>

View file

@ -1,4 +1,5 @@
<section>
{% set step_index = 3 %}{% set step_total = 5 %}
<h3>Step 3: Ideal Counts</h3>
<div class="two-col two-col-left-rail">
<aside class="card-preview" data-card-name="{{ commander|urlencode }}">
@ -6,8 +7,9 @@
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=normal" alt="{{ commander }} card image" />
</a>
</aside>
<div class="grow">
<div class="grow" data-skeleton>
<div hx-get="/build/banner?step=Ideal%20Counts&i=3&n=5" hx-trigger="load"></div>
{% include "build/_stage_navigator.html" %}
@ -30,8 +32,8 @@
</fieldset>
<div style="margin-top:1rem; display:flex; gap:.5rem;">
<button type="submit">Continue to Review</button>
<button type="button" hx-get="/build/step2" hx-target="#wizard" hx-swap="innerHTML">Back</button>
<button type="submit" class="btn-continue" data-action="continue">Continue to Review</button>
<button type="button" class="btn-back" data-action="back" hx-get="/build/step2" hx-target="#wizard" hx-swap="innerHTML">Back</button>
</div>
</form>
<div style="margin-top:.5rem;">

View file

@ -1,4 +1,5 @@
<section>
{% set step_index = 4 %}{% set step_total = 5 %}
<h3>Step 4: Review</h3>
<div class="two-col two-col-left-rail">
<aside class="card-preview" data-card-name="{{ commander|urlencode }}">
@ -6,8 +7,9 @@
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=normal" alt="{{ commander }} card image" />
</a>
</aside>
<div class="grow">
<div class="grow" data-skeleton>
<div hx-get="/build/banner?step=Review&i=4&n=5" hx-trigger="load"></div>
{% include "build/_stage_navigator.html" %}
<h4>Chosen Ideals</h4>
<ul>
{% for key, label in labels.items() %}
@ -23,13 +25,13 @@
<input type="checkbox" name="prefer_owned" value="1" {% if prefer_owned %}checked{% endif %} onchange="this.form.requestSubmit();" />
Prefer owned cards (allow unowned fallback)
</label>
<a href="/owned" target="_blank" rel="noopener" class="muted">Manage Owned Library</a>
<a href="/owned" target="_blank" rel="noopener" class="btn">Manage Owned Library</a>
</form>
<div style="margin-top:1rem; display:flex; gap:.5rem;">
<form action="/build/step5/start" method="post" hx-post="/build/step5/start" hx-target="#wizard" hx-swap="innerHTML" style="display:inline; margin:0;">
<button type="submit">Build Deck</button>
<button type="submit" class="btn-continue" data-action="continue">Build Deck</button>
</form>
<button type="button" hx-get="/build/step3" hx-target="#wizard" hx-swap="innerHTML">Back</button>
<button type="button" class="btn-back" data-action="back" hx-get="/build/step3" hx-target="#wizard" hx-swap="innerHTML">Back</button>
<form action="/build" method="get" style="display:inline; margin:0;">
<button type="submit">Start over</button>
</form>

View file

@ -1,4 +1,5 @@
<section>
{% set step_index = 5 %}{% set step_total = 5 %}
<h3>Step 5: Build</h3>
<div class="two-col two-col-left-rail">
<aside class="card-preview">
@ -22,8 +23,9 @@
</div>
{% endif %}
</aside>
<div class="grow">
<div class="grow" data-skeleton>
<div hx-get="/build/banner?step=Build&i=5&n=5" hx-trigger="load"></div>
{% include "build/_stage_navigator.html" %}
<p>Commander: <strong>{{ commander }}</strong></p>
<p>Tags: {{ tags|default([])|join(', ') }}</p>
@ -33,13 +35,26 @@
<button type="button" hx-get="/build/step4" hx-target="#wizard" hx-swap="innerHTML" style="background:#374151; color:#e5e7eb; border:none; border-radius:6px; padding:.25rem .5rem; cursor:pointer; font-size:12px;" title="Change owned settings in Review">Edit in Review</button>
<div>Prefer-owned: <strong>{{ 'On' if prefer_owned else 'Off' }}</strong></div>
</div>
<span style="margin-left:auto;"><a href="/owned" target="_blank" rel="noopener" class="muted">Manage Owned Library</a></span>
<span style="margin-left:auto;"><a href="/owned" target="_blank" rel="noopener" class="btn">Manage Owned Library</a></span>
</div>
<p>Bracket: {{ bracket }}</p>
{% if i and n %}
<div class="muted" style="margin:.25rem 0 .5rem 0;">Stage {{ i }}/{{ n }}</div>
{% endif %}
<div style="display:flex; align-items:center; gap:.5rem; flex-wrap:wrap; margin:.25rem 0 .5rem 0;">
{% 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" style="background: var(--green-main);"></span> Deck {{ deck_count }}/100</span>
{% if added_total is not none %}
<span class="chip"><span class="dot" style="background: var(--blue-main);"></span> Added {{ added_total }}</span>
{% endif %}
</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 %}" aria-label="Deck progress" title="{{ deck_count }} of 100 cards" style="margin:.25rem 0 1rem 0;" data-pct="{{ pct_int }}">
<div class="bar"></div>
</div>
{% if status %}
<div style="margin-top:1rem;">
@ -47,26 +62,56 @@
</div>
{% endif %}
<!-- Controls moved back above the cards as requested -->
<div style="margin-top:1rem; display:flex; gap:.5rem; flex-wrap:wrap; align-items:center;">
<form hx-post="/build/step5/start" hx-target="#wizard" hx-swap="innerHTML" style="display:inline; margin-right:.5rem; display:flex; align-items:center; gap:.5rem;">
<!-- 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 style="display:flex;align-items:center;gap:.35rem;">
<input type="checkbox" name="show_reasons" data-pref="cards:show_reasons" checked /> Show reasons
</label>
<label style="display:flex;align-items:center;gap:.35rem;">
<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">AZ</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" style="position:sticky; top:0; z-index:5; background:linear-gradient(180deg, rgba(15,17,21,.95), rgba(15,17,21,.85)); border:1px solid var(--border); border-radius:10px; padding:.5rem; margin-top:1rem; display:flex; gap:.5rem; flex-wrap:wrap; align-items:center;">
<form hx-post="/build/step5/start" hx-target="#wizard" hx-swap="innerHTML" style="display:inline; margin-right:.5rem; display:flex; align-items:center; gap:.5rem;" onsubmit="try{ toast('Starting build…'); }catch(_){}">
<input type="hidden" name="show_skipped" value="{{ '1' if show_skipped else '0' }}" />
<button type="submit">Start Build</button>
<button type="submit" class="btn-continue" data-action="continue">Start Build</button>
</form>
<form hx-post="/build/step5/continue" hx-target="#wizard" hx-swap="innerHTML" style="display:inline; display:flex; align-items:center; gap:.5rem;">
<form hx-post="/build/step5/continue" hx-target="#wizard" hx-swap="innerHTML" style="display:inline; display:flex; align-items:center; gap:.5rem;" onsubmit="try{ toast('Continuing…'); }catch(_){}">
<input type="hidden" name="show_skipped" value="{{ '1' if show_skipped else '0' }}" />
<button type="submit" {% if status and status.startswith('Build complete') %}disabled{% endif %}>Continue</button>
<button type="submit" class="btn-continue" data-action="continue" {% if status and status.startswith('Build complete') %}disabled{% endif %}>Continue</button>
</form>
<form hx-post="/build/step5/rerun" hx-target="#wizard" hx-swap="innerHTML" style="display:inline; display:flex; align-items:center; gap:.5rem;">
<form hx-post="/build/step5/rerun" hx-target="#wizard" hx-swap="innerHTML" style="display:inline; display:flex; align-items:center; gap:.5rem;" onsubmit="try{ toast('Rerunning stage…'); }catch(_){}">
<input type="hidden" name="show_skipped" value="{{ '1' if show_skipped else '0' }}" />
<button type="submit" {% if status and status.startswith('Build complete') %}disabled{% endif %}>Rerun Stage</button>
<button type="submit" class="btn-rerun" data-action="rerun" {% if status and status.startswith('Build complete') %}disabled{% endif %}>Rerun Stage</button>
</form>
<label class="muted" style="display:flex; align-items:center; gap:.35rem; margin-left: .5rem;">
<input type="checkbox" name="__toggle_show_skipped" {% if show_skipped %}checked{% endif %}
<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" hx-get="/build/step4" hx-target="#wizard" hx-swap="innerHTML">Back</button>
<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 %}
@ -88,36 +133,55 @@
{% else %}
{% set heading = 'Additional Picks' %}
{% endif %}
<h5 style="margin:.5rem 0 .25rem 0;">{{ heading }}</h5>
<div class="card-grid">
<div class="group" data-group-key="{{ (role or 'other')|lower|replace(' ', '-') }}">
<div class="group-header">
<h5 style="margin:.5rem 0 .25rem 0;">{{ 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>
{% for c in g.list %}
{% set owned = (owned_set is defined and c.name and (c.name|lower in owned_set)) %}
<div class="card-tile{% if game_changers and (c.name in game_changers) %} game-changer{% endif %}" data-card-name="{{ c.name }}" data-role="{{ c.role or c.sub_role or '' }}" data-tags="{{ (c.tags|join(', ')) if c.tags else '' }}">
<div class="card-tile{% if game_changers and (c.name in game_changers) %} game-changer{% endif %}" data-card-name="{{ c.name }}" data-role="{{ c.role or c.sub_role or '' }}" data-tags="{{ (c.tags|join(', ')) if c.tags else '' }}" data-owned="{{ '1' if owned else '0' }}">
<a href="https://scryfall.com/search?q={{ c.name|urlencode }}" target="_blank" rel="noopener">
<img src="https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=normal" alt="{{ c.name }} image" width="160" />
</a>
<div class="owned-badge" title="{{ 'Owned' if owned else 'Not owned' }}" aria-label="{{ 'Owned' if owned else 'Not owned' }}">{% if owned %}✔{% else %}✖{% endif %}</div>
<div class="name">{{ c.name }}{% if c.count and c.count > 1 %} ×{{ c.count }}{% endif %}</div>
{% if c.reason %}<div class="reason">{{ c.reason }}</div>{% endif %}
{% if c.reason %}
<div style="display:flex; justify-content:center; margin-top:.25rem;">
<button type="button" class="btn-why" aria-expanded="false">Why?</button>
</div>
<div class="reason" role="region" aria-label="Reason">{{ c.reason }}</div>
{% endif %}
</div>
{% endfor %}
</div>
</div>
{% endfor %}
{% else %}
<div class="card-grid">
<div class="card-grid" data-skeleton>
{% for c in added_cards %}
{% set owned = (owned_set is defined and c.name and (c.name|lower in owned_set)) %}
<div class="card-tile{% if game_changers and (c.name in game_changers) %} game-changer{% endif %}" data-card-name="{{ c.name }}" data-role="{{ c.role or c.sub_role or '' }}" data-tags="{{ (c.tags|join(', ')) if c.tags else '' }}">
<div class="card-tile{% if game_changers and (c.name in game_changers) %} game-changer{% endif %}" data-card-name="{{ c.name }}" data-role="{{ c.role or c.sub_role or '' }}" data-tags="{{ (c.tags|join(', ')) if c.tags else '' }}" data-owned="{{ '1' if owned else '0' }}">
<a href="https://scryfall.com/search?q={{ c.name|urlencode }}" target="_blank" rel="noopener">
<img src="https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=normal" alt="{{ c.name }} image" width="160" />
</a>
<div class="owned-badge" title="{{ 'Owned' if owned else 'Not owned' }}" aria-label="{{ 'Owned' if owned else 'Not owned' }}">{% if owned %}✔{% else %}✖{% endif %}</div>
<div class="name">{{ c.name }}{% if c.count and c.count > 1 %} ×{{ c.count }}{% endif %}</div>
{% if c.reason %}<div class="reason">{{ c.reason }}</div>{% endif %}
{% if c.reason %}
<div style="display:flex; justify-content:center; margin-top:.25rem;">
<button type="button" class="btn-why" aria-expanded="false">Why?</button>
</div>
<div class="reason" role="region" aria-label="Reason">{{ c.reason }}</div>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
<div data-empty hidden role="status" aria-live="polite" class="muted" style="margin:.5rem 0 0;">
No cards match your filters.
</div>
{% endif %}
{% if show_logs and log %}

View file

@ -3,8 +3,23 @@
{% block content %}
<h2>Build a Deck</h2>
<div id="wizard">
<div hx-get="/build/step1" hx-trigger="load" hx-target="#wizard" hx-swap="innerHTML"></div>
<div hx-get="/build/banner?step=Build%20a%20Deck&i=1&n=5" hx-trigger="load"></div>
{% set step = last_step or 1 %}
{% if step == 1 %}
<div hx-get="/build/step1" hx-trigger="load" hx-target="#wizard" hx-swap="innerHTML"></div>
<div hx-get="/build/banner?step=Build%20a%20Deck&i=1&n=5" hx-trigger="load"></div>
{% elif step == 2 %}
<div hx-get="/build/step2" hx-trigger="load" hx-target="#wizard" hx-swap="innerHTML"></div>
<div hx-get="/build/banner?step=Build%20a%20Deck&i=2&n=5" hx-trigger="load"></div>
{% elif step == 3 %}
<div hx-get="/build/step3" hx-trigger="load" hx-target="#wizard" hx-swap="innerHTML"></div>
<div hx-get="/build/banner?step=Build%20a%20Deck&i=3&n=5" hx-trigger="load"></div>
{% elif step == 4 %}
<div hx-get="/build/step4" hx-trigger="load" hx-target="#wizard" hx-swap="innerHTML"></div>
<div hx-get="/build/banner?step=Build%20a%20Deck&i=4&n=5" hx-trigger="load"></div>
{% else %}
<div hx-get="/build/step5" hx-trigger="load" hx-target="#wizard" hx-swap="innerHTML"></div>
<div hx-get="/build/banner?step=Build%20a%20Deck&i=5&n=5" hx-trigger="load"></div>
{% endif %}
<noscript><p>Enable JavaScript to use the wizard.</p></noscript>
</div>
{% endblock %}

View file

@ -24,24 +24,36 @@
<a href="/owned/export.csv" download class="btn{% if count == 0 %} disabled{% endif %}" {% if count == 0 %}aria-disabled="true" onclick="return false;"{% endif %}>Export CSV</a>
<span class="muted">{{ count }} unique name{{ '' if count == 1 else 's' }} <span id="shown-count" style="margin-left:.25rem;">{% if count %}• {{ count }} shown{% endif %}</span></span>
</div>
{% if names and names|length %}
<div id="bulk-bar" style="display:flex; align-items:center; gap:.5rem; flex-wrap:wrap; margin:.25rem 0 .5rem 0;">
<label style="display:flex; align-items:center; gap:.4rem;">
<input type="checkbox" id="select-all" /> Select all shown
</label>
<button type="button" id="btn-remove-selected" class="btn" disabled>Remove selected</button>
<button type="button" id="btn-remove-visible" class="btn" disabled>Remove visible</button>
<button type="button" id="btn-export-visible" class="btn" disabled>Export visible TXT</button>
<button type="button" id="btn-export-visible-csv" class="btn" disabled>Export visible CSV</button>
</div>
{% endif %}
{% if names and names|length %}
<div class="filters" style="display:flex; flex-wrap:wrap; gap:8px; margin:.25rem 0 .5rem 0;">
<select id="sort-by" style="background:#0f1115; color:#e5e7eb; border:1px solid var(--border); border-radius:6px; padding:.3rem .5rem;">
<select id="sort-by" data-pref="owned:sort" style="background:#0f1115; color:#e5e7eb; border:1px solid var(--border); border-radius:6px; padding:.3rem .5rem;">
<option value="name">Sort: A → Z</option>
<option value="type">Sort: Type</option>
<option value="color">Sort: Color</option>
<option value="tags">Sort: Tags</option>
<option value="recent">Sort: Recently added</option>
</select>
<select id="filter-type" style="background:#0f1115; color:#e5e7eb; border:1px solid var(--border); border-radius:6px; padding:.3rem .5rem;">
<select id="filter-type" data-pref="owned:type" style="background:#0f1115; color:#e5e7eb; border:1px solid var(--border); border-radius:6px; padding:.3rem .5rem;">
<option value="">All Types</option>
{% for t in all_types %}<option value="{{ t }}">{{ t }}</option>{% endfor %}
</select>
<select id="filter-tag" style="background:#0f1115; color:#e5e7eb; border:1px solid var(--border); border-radius:6px; padding:.3rem .5rem; max-width:320px;">
<select id="filter-tag" data-pref="owned:tag" style="background:#0f1115; color:#e5e7eb; border:1px solid var(--border); border-radius:6px; padding:.3rem .5rem; max-width:320px;">
<option value="">All Themes</option>
{% for t in all_tags %}<option value="{{ t }}">{{ t }}</option>{% endfor %}
</select>
<select id="filter-color" style="background:#0f1115; color:#e5e7eb; border:1px solid var(--border); border-radius:6px; padding:.3rem .5rem;">
<select id="filter-color" data-pref="owned:color" style="background:#0f1115; color:#e5e7eb; border:1px solid var(--border); border-radius:6px; padding:.3rem .5rem;">
<option value="">All Colors</option>
{% for c in all_colors %}<option value="{{ c }}">{{ c }}</option>{% endfor %}
{% if color_combos and color_combos|length %}
@ -51,9 +63,10 @@
{% endfor %}
{% endif %}
</select>
<input id="filter-text" type="search" placeholder="Search name..." style="background:#0f1115; color:#e5e7eb; border:1px solid var(--border); border-radius:6px; padding:.3rem .5rem; flex:1; min-width:200px;" />
<input id="filter-text" data-pref="owned:q" type="search" placeholder="Search name..." style="background:#0f1115; color:#e5e7eb; border:1px solid var(--border); border-radius:6px; padding:.3rem .5rem; flex:1; min-width:200px;" />
<button type="button" id="clear-filters">Clear</button>
</div>
<div id="active-chips" class="muted" style="display:flex; flex-wrap:wrap; gap:6px; font-size:12px; margin:.25rem 0 .5rem 0;"></div>
{% endif %}
{% if names and names|length %}
@ -63,8 +76,21 @@
{% set tags = (tags_by_name.get(n, []) if tags_by_name else []) %}
{% set tline = (type_by_name.get(n, '') if type_by_name else '') %}
{% set cols = (colors_by_name.get(n, []) if colors_by_name else []) %}
<li style="break-inside: avoid; overflow-wrap:anywhere;" data-type="{{ tline }}" data-tags="{{ (tags or [])|join('|') }}" data-colors="{{ (cols or [])|join('') }}">
<span data-card-name="{{ n }}" {% if tags %}data-tags="{{ (tags or [])|join(', ') }}"{% endif %}>{{ n }}</span>
{% set added_ts = (added_at_map.get(n) if added_at_map else None) %}
<li style="break-inside: avoid; overflow-wrap:anywhere;" data-type="{{ tline }}" data-tags="{{ (tags or [])|join('|') }}" data-colors="{{ (cols or [])|join('') }}" data-added="{{ added_ts if added_ts else '' }}">
<label style="display:flex; align-items:center; gap:.4rem;">
<input type="checkbox" class="sel" />
<span data-card-name="{{ n }}" {% if tags %}data-tags="{{ (tags or [])|join(', ') }}"{% endif %}>{{ n }}</span>
</label>
{# Inline user tag badges #}
{% set utags = (user_tags_map.get(n, []) if user_tags_map else []) %}
{% if utags and utags|length %}
<div class="user-tags" style="display:flex; flex-wrap:wrap; gap:6px; margin:.25rem 0 .15rem 1.65rem;">
{% for t in utags %}
<span class="chip" data-name="{{ n }}" data-user-tag="{{ t }}" title="Click to remove tag" style="display:inline-flex; align-items:center; gap:6px; background:#0f1115; color:#e5e7eb; border:1px solid var(--border); border-radius:999px; padding:2px 8px; font-size:12px; cursor:pointer;">{{ t }} <span aria-hidden="true" style="opacity:.8;">×</span></span>
{% endfor %}
</div>
{% endif %}
{% if cols and cols|length %}
<span class="mana-group" aria-hidden="true" style="margin-left:.35rem; display:inline-flex; gap:4px; vertical-align:middle;">
{% for c in cols %}
@ -87,6 +113,12 @@
var grid = document.getElementById('owned-grid');
if (!grid) return;
var box = document.getElementById('owned-box');
var bulk = document.getElementById('bulk-bar');
var selAll = document.getElementById('select-all');
var btnRemoveSel = document.getElementById('btn-remove-selected');
var btnRemoveVis = document.getElementById('btn-remove-visible');
var btnExportVis = document.getElementById('btn-export-visible');
var btnExportVisCsv = document.getElementById('btn-export-visible-csv');
var fSort = document.getElementById('sort-by');
var fType = document.getElementById('filter-type');
var fTag = document.getElementById('filter-tag');
@ -94,6 +126,16 @@
var fText = document.getElementById('filter-text');
var btnClear = document.getElementById('clear-filters');
var shownCount = document.getElementById('shown-count');
var chips = document.getElementById('active-chips');
var tagInput;
// State helpers for URL hash and localStorage
var state = {
get: function(k, d){ try{ var v=localStorage.getItem('mtg:'+k); return v!==null?JSON.parse(v):d; }catch(e){ return d; } },
set: function(k, v){ try{ localStorage.setItem('mtg:'+k, JSON.stringify(v)); }catch(e){} },
inHash: function(obj){ try{ var p=new URLSearchParams((location.hash||'').replace(/^#/,'')); Object.keys(obj||{}).forEach(function(k){ p.set(k, obj[k]); }); location.hash=p.toString(); }catch(e){} },
readHash: function(){ try{ return new URLSearchParams((location.hash||'').replace(/^#/,'')); }catch(e){ return new URLSearchParams(); } }
};
// Resize the container to fill the viewport height
function sizeBox(){
@ -143,6 +185,10 @@
var total = 0;
Array.prototype.forEach.call(grid.children, function(li){ if (li.style.display !== 'none') total++; });
shownCount.textContent = (total > 0 ? '• ' + total + ' shown' : '');
// Enable/disable bulk buttons
var anyVisible = total > 0;
[btnRemoveVis, btnExportVis, btnExportVisCsv].forEach(function(b){ if (b) b.disabled = !anyVisible; });
updateSelectedState();
}
function apply(){
@ -156,7 +202,59 @@
});
resort();
updateShownCount();
renderChips();
// Persist
if (fSort && fSort.hasAttribute('data-pref')) state.set(fSort.getAttribute('data-pref'), fSort.value);
if (fType && fType.hasAttribute('data-pref')) state.set(fType.getAttribute('data-pref'), fType.value);
if (fTag && fTag.hasAttribute('data-pref')) state.set(fTag.getAttribute('data-pref'), fTag.value);
if (fColor && fColor.hasAttribute('data-pref')) state.set(fColor.getAttribute('data-pref'), fColor.value);
if (fText && fText.hasAttribute('data-pref')) state.set(fText.getAttribute('data-pref'), fText.value);
// Update URL hash
try{ state.inHash({ o_sort: (fSort?fSort.value:''), o_type: vt, o_tag: vtag, o_color: vc, o_q: vx }); }catch(_){ }
}
function renderChips(){
if (!chips) return;
var items = [];
if (fType && fType.value) items.push({ k:'Type', v: fType.value, clear: function(){ fType.value=''; apply(); } });
if (fTag && fTag.value) items.push({ k:'Theme', v: fTag.value, clear: function(){ fTag.value=''; apply(); } });
if (fColor && fColor.value) items.push({ k:'Colors', v: fColor.value, clear: function(){ fColor.value=''; apply(); } });
if (fText && fText.value) items.push({ k:'Search', v: fText.value, clear: function(){ fText.value=''; apply(); } });
chips.innerHTML = '';
if (!items.length){ chips.style.display='none'; return; }
chips.style.display='flex';
items.forEach(function(it){
var span = document.createElement('span');
span.style.border = '1px solid var(--border)';
span.style.borderRadius = '16px';
span.style.padding = '2px 8px';
span.style.background = '#0f1115';
span.textContent = it.k+': '+it.v+' ×';
span.style.cursor = 'pointer';
span.title = 'Clear '+it.k;
span.addEventListener('click', function(){ it.clear(); });
chips.appendChild(span);
});
}
// Bulk tagging controls
(function(){
var bar = document.getElementById('bulk-bar');
if (!bar) return;
var wrap = document.createElement('div');
wrap.style.display='flex'; wrap.style.alignItems='center'; wrap.style.gap='.5rem'; wrap.style.flexWrap='wrap';
var inp = document.createElement('input'); inp.type='text'; inp.placeholder='Tag…'; inp.id='bulk-tag-input';
inp.style.background='#0f1115'; inp.style.color='#e5e7eb'; inp.style.border='1px solid var(--border)'; inp.style.borderRadius='6px'; inp.style.padding='.3rem .5rem';
var addBtn = document.createElement('button'); addBtn.textContent='Add tag to selected'; addBtn.id='btn-tag-add'; addBtn.disabled=true;
var remBtn = document.createElement('button'); remBtn.textContent='Remove tag from selected'; remBtn.id='btn-tag-remove'; remBtn.disabled=true;
wrap.appendChild(inp); wrap.appendChild(addBtn); wrap.appendChild(remBtn);
bar.appendChild(wrap);
tagInput = inp;
function refreshTagBtns(){ var hasSel = getSelectedNames().length>0; var hasTag = !!(tagInput && tagInput.value && tagInput.value.trim()); addBtn.disabled = !(hasSel && hasTag); remBtn.disabled = !(hasSel && hasTag); }
if (tagInput) tagInput.addEventListener('input', refreshTagBtns);
document.addEventListener('change', function(e){ if (e.target && e.target.classList && e.target.classList.contains('sel')) refreshTagBtns(); });
addBtn.addEventListener('click', function(){ var names=getSelectedNames(); var t=(tagInput&&tagInput.value||'').trim(); if(!names.length||!t) return; fetch('/owned/tag/add',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({names:names, tag:t})}).then(function(r){return r.text();}).then(function(html){ document.documentElement.innerHTML = html; }).catch(function(){ alert('Tagging failed'); }); });
remBtn.addEventListener('click', function(){ var names=getSelectedNames(); var t=(tagInput&&tagInput.value||'').trim(); if(!names.length||!t) return; fetch('/owned/tag/remove',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({names:names, tag:t})}).then(function(r){return r.text();}).then(function(html){ document.documentElement.innerHTML = html; }).catch(function(){ alert('Untag failed'); }); });
})();
function resort(){
if (!fSort) return;
@ -169,10 +267,12 @@
function byType(a,b){ return (a.getAttribute('data-type')||'').toLowerCase().localeCompare((b.getAttribute('data-type')||'').toLowerCase()); }
function byColor(a,b){ return (a.getAttribute('data-colors')||'').localeCompare((b.getAttribute('data-colors')||'')); }
function byTags(a,b){ var ac=(a.getAttribute('data-tags')||'').split('|').filter(Boolean).length; var bc=(b.getAttribute('data-tags')||'').split('|').filter(Boolean).length; return ac-bc || byName(a,b); }
function byRecent(a,b){ var ta=parseInt(a.getAttribute('data-added')||'0',10); var tb=parseInt(b.getAttribute('data-added')||'0',10); return (tb-ta) || byName(a,b); }
var cmp = byName;
if (mode === 'type') cmp = byType;
else if (mode === 'color') cmp = byColor;
else if (mode === 'tags') cmp = byTags;
else if (mode === 'recent') cmp = byRecent;
visible.sort(cmp);
// Re-append in new order
var frag = document.createDocumentFragment();
@ -181,14 +281,100 @@
grid.appendChild(frag);
}
if (fSort) fSort.addEventListener('change', function(){ resort(); });
function getVisibleNames(){
var out=[];
Array.prototype.forEach.call(grid.children, function(li){
if (li.style.display === 'none') return;
var span = li.querySelector('[data-card-name]');
if (span) out.push(span.getAttribute('data-card-name'));
});
return out;
}
function getSelectedNames(){
var out=[];
Array.prototype.forEach.call(grid.children, function(li){
var cb = li.querySelector('input.sel');
if (cb && cb.checked){ var span = li.querySelector('[data-card-name]'); if (span) out.push(span.getAttribute('data-card-name')); }
});
return out;
}
function updateSelectedState(){
if (!bulk) return;
var selected = getSelectedNames();
if (btnRemoveSel) btnRemoveSel.disabled = selected.length === 0;
if (selAll){
// Reflect if all visible are selected
var vis = getVisibleNames();
selAll.checked = (vis.length>0 && selected.length === vis.length);
selAll.indeterminate = (selected.length>0 && selected.length < vis.length);
}
}
if (selAll){
selAll.addEventListener('change', function(){
var on = !!selAll.checked;
Array.prototype.forEach.call(grid.children, function(li){ if (li.style.display==='none') return; var cb=li.querySelector('input.sel'); if (cb) cb.checked = on; });
updateSelectedState();
});
}
grid.addEventListener('change', function(e){ if (e.target && e.target.classList && e.target.classList.contains('sel')) updateSelectedState(); });
function postJSON(url, body){ return fetch(url, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body||{}) }).then(function(r){ if (r.ok) return r.text(); throw new Error('Request failed'); }); }
function formPost(url, names){
var fd = new FormData(); fd.append('names', names.join(','));
return fetch(url, { method:'POST', body: fd }).then(function(r){ if (r.ok) return r.text(); throw new Error('Request failed'); });
}
function confirmRemove(count){
return window.confirm('Remove '+count+' item'+(count===1?'':'s')+' from Owned? This cannot be undone.');
}
if (btnRemoveVis) btnRemoveVis.addEventListener('click', function(){ var names=getVisibleNames(); if(!names.length) return; if(!confirmRemove(names.length)) return; sessionStorage.setItem('mtg:toastAfterReload', JSON.stringify({msg:'Removed '+names.length+' visible name'+(names.length===1?'':'s')+'.', type:'success'})); formPost('/owned/remove', names).then(function(html){ document.documentElement.innerHTML = html; }).catch(function(){ alert('Remove failed'); }); });
if (btnRemoveSel) btnRemoveSel.addEventListener('click', function(){ var names=getSelectedNames(); if(!names.length) return; if(!confirmRemove(names.length)) return; sessionStorage.setItem('mtg:toastAfterReload', JSON.stringify({msg:'Removed '+names.length+' selected name'+(names.length===1?'':'s')+'.', type:'success'})); formPost('/owned/remove', names).then(function(html){ document.documentElement.innerHTML = html; }).catch(function(){ alert('Remove failed'); }); });
if (btnExportVis) btnExportVis.addEventListener('click', function(){ var names=getVisibleNames(); if(!names.length) return; postJSON('/owned/export-visible', { names: names }).then(function(txt){ var a=document.createElement('a'); a.href=URL.createObjectURL(new Blob([txt],{type:'text/plain'})); a.download='owned_visible.txt'; a.click(); }); });
if (btnExportVisCsv) btnExportVisCsv.addEventListener('click', function(){ var names=getVisibleNames(); if(!names.length) return; postJSON('/owned/export-visible.csv', { names: names }).then(function(csv){ var a=document.createElement('a'); a.href=URL.createObjectURL(new Blob([csv],{type:'text/csv'})); a.download='owned_visible.csv'; a.click(); }); });
// Keyboard helpers: '/' focus search, Esc clear filters
document.addEventListener('keydown', function(e){
if (e.target && (/input|textarea|select/i).test(e.target.tagName)) return;
if (e.key === '/') { if (fText){ e.preventDefault(); fText.focus(); fText.select && fText.select(); } }
else if (e.key === 'Escape'){ if (fText && fText.value){ fText.value=''; apply(); } }
});
// Hydrate from URL hash/localStorage
try{
var params = state.readHash();
var hv;
if (fSort){ hv = params.get('o_sort'); if (!hv) hv = state.get(fSort.getAttribute('data-pref'), 'name'); if (hv) fSort.value = hv; }
if (fType){ hv = params.get('o_type'); if (!hv) hv = state.get(fType.getAttribute('data-pref'), ''); if (hv !== null && typeof hv !== 'undefined') fType.value = hv; }
if (fTag){ hv = params.get('o_tag'); if (!hv) hv = state.get(fTag.getAttribute('data-pref'), ''); if (hv !== null && typeof hv !== 'undefined') fTag.value = hv; }
if (fColor){ hv = params.get('o_color'); if (!hv) hv = state.get(fColor.getAttribute('data-pref'), ''); if (hv !== null && typeof hv !== 'undefined') fColor.value = hv; }
if (fText){ hv = params.get('o_q'); if (!hv && hv !== '') hv = state.get(fText.getAttribute('data-pref'), ''); if (typeof hv === 'string') fText.value = hv; }
}catch(_){ }
if (fSort) fSort.addEventListener('change', function(){ resort(); apply(); });
if (fType) fType.addEventListener('change', apply);
if (fTag) fTag.addEventListener('change', apply);
if (fColor) fColor.addEventListener('change', apply);
if (fText) fText.addEventListener('input', apply);
if (btnClear) btnClear.addEventListener('click', function(){ if(fSort)fSort.value='name'; if(fType)fType.value=''; if(fTag)fTag.value=''; if(fColor)fColor.value=''; if(fText)fText.value=''; apply(); });
if (btnClear) btnClear.addEventListener('click', function(){
if(fSort)fSort.value='name'; if(fType)fType.value=''; if(fTag)fTag.value=''; if(fColor)fColor.value=''; if(fText)fText.value='';
apply();
});
// Initial state
updateShownCount();
apply();
// Delegated click: quick remove a user tag chip
grid.addEventListener('click', function(e){
var chip = e.target.closest && e.target.closest('.user-tags .chip');
if (!chip) return;
var name = chip.getAttribute('data-name');
var tag = chip.getAttribute('data-user-tag');
if (!name || !tag) return;
if (!window.confirm('Remove tag \''+tag+'\' from "'+name+'"?')) return;
fetch('/owned/tag/remove', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ names:[name], tag: tag }) })
.then(function(r){ return r.text(); })
.then(function(html){ sessionStorage.setItem('mtg:toastAfterReload', JSON.stringify({msg:'Removed tag \''+tag+'\' from '+name+'.', type:'success'})); document.documentElement.innerHTML = html; })
.catch(function(){ alert('Untag failed'); });
});
})();
</script>
<style>

View file

@ -147,7 +147,7 @@
<!-- Pips Panel -->
<div class="mana-panel" style="border:1px solid var(--border); border-radius:8px; padding:.6rem; background:#0f1115;">
<div class="muted" style="margin-bottom:.35rem; font-weight:600;">Mana Pips (non-lands)</div>
{% set pd = summary.pip_distribution %}
{% set pd = summary.pip_distribution %}
{% if pd %}
{% set colors = deck_colors if deck_colors else ['W','U','B','R','G'] %}
<div style="display:flex; gap:14px; align-items:flex-end; height:140px;">
@ -157,14 +157,21 @@
<div style="text-align:center;">
<svg width="28" height="120" aria-label="{{ color }} {{ pct }}%">
{% set count_val = (pd.counts[color] if pd.counts and color in pd.counts else 0) %}
{% set pc = pd['cards'] if 'cards' in pd else None %}
{% set c_cards = (pc[color] if pc and (color in pc) else []) %}
{% set parts = [] %}
{% for c in c_cards %}
{% set _ = parts.append(c.name ~ ((" ×" ~ c.count) if c.count and c.count>1 else '')) %}
{% endfor %}
{% set cards_line = parts|join(' • ') %}
{% set pct_f = (pd.weights[color] * 100) if pd.weights and color in pd.weights else 0 %}
<rect x="2" y="2" width="24" height="116" fill="#14171c" stroke="var(--border)" rx="4" ry="4"
data-type="pips" data-color="{{ color }}" data-count="{{ '%.1f' % count_val }}" data-pct="{{ '%.1f' % pct_f }}"></rect>
<rect x="2" y="2" width="24" height="116" fill="#14171c" stroke="var(--border)" rx="4" ry="4"
data-type="pips" data-color="{{ color }}" data-count="{{ '%.1f' % count_val }}" data-pct="{{ '%.1f' % pct_f }}" data-cards="{{ cards_line }}"></rect>
{% set h = (pct * 1.0) | int %}
{% set bar_h = (h if h>2 else 2) %}
{% set y = 118 - bar_h %}
<rect x="2" y="{{ y }}" width="24" height="{{ bar_h }}" fill="#3b82f6" rx="4" ry="4"
data-type="pips" data-color="{{ color }}" data-count="{{ '%.1f' % count_val }}" data-pct="{{ '%.1f' % pct_f }}"></rect>
<rect x="2" y="{{ y }}" width="24" height="{{ bar_h }}" fill="#3b82f6" rx="4" ry="4"
data-type="pips" data-color="{{ color }}" data-count="{{ '%.1f' % count_val }}" data-pct="{{ '%.1f' % pct_f }}" data-cards="{{ cards_line }}"></rect>
</svg>
<div class="muted" style="margin-top:.25rem;">{{ color }}</div>
</div>
@ -177,29 +184,45 @@
<!-- Sources Panel -->
<div class="mana-panel" style="border:1px solid var(--border); border-radius:8px; padding:.6rem; background:#0f1115;">
<div class="muted" style="margin-bottom:.35rem; font-weight:600;">Mana Sources</div>
{% set mg = summary.mana_generation %}
<div style="display:flex; align-items:center; justify-content:space-between; gap:.75rem; margin-bottom:.35rem;">
<div class="muted" style="font-weight:600;">Mana Sources</div>
<label class="muted" style="font-size:12px; display:flex; align-items:center; gap:.35rem; cursor:pointer;">
<input type="checkbox" id="toggle-show-c" /> Show colorless (C)
</label>
</div>
{% set mg = summary.mana_generation %}
{% if mg %}
{% set colors = deck_colors if deck_colors else ['W','U','B','R','G'] %}
{# If colorless sources exist, append 'C' to colors for display #}
{% if 'C' in mg and (mg.get('C', 0) > 0) and ('C' not in colors) %}
{% set colors = colors + ['C'] %}
{% endif %}
{% set ns = namespace(max_src=0) %}
{% for color in colors %}
{% set val = mg.get(color, 0) %}
{% if val > ns.max_src %}{% set ns.max_src = val %}{% endif %}
{% endfor %}
{% set denom = (ns.max_src if ns.max_src and ns.max_src > 0 else 1) %}
<div style="display:flex; gap:14px; align-items:flex-end; height:140px;">
<div class="sources-bars" style="display:flex; gap:14px; align-items:flex-end; height:140px;">
{% for color in colors %}
{% set val = mg.get(color, 0) %}
{% set pct = (val * 100 / denom) | int %}
<div style="text-align:center;">
<div style="text-align:center;" data-color="{{ color }}">
<svg width="28" height="120" aria-label="{{ color }} {{ val }}">
{% set pct_f = (100.0 * (val / (mg.total_sources or 1))) %}
<rect x="2" y="2" width="24" height="116" fill="#14171c" stroke="var(--border)" rx="4" ry="4"
data-type="sources" data-color="{{ color }}" data-val="{{ val }}" data-pct="{{ '%.1f' % pct_f }}"></rect>
{% set mgc = mg['cards'] if 'cards' in mg else None %}
{% set c_cards = (mgc[color] if mgc and (color in mgc) else []) %}
{% set parts = [] %}
{% for c in c_cards %}
{% set _ = parts.append(c.name ~ ((" ×" ~ c.count) if c.count and c.count>1 else '')) %}
{% endfor %}
{% set cards_line = parts|join(' • ') %}
<rect x="2" y="2" width="24" height="116" fill="#14171c" stroke="var(--border)" rx="4" ry="4"
data-type="sources" data-color="{{ color }}" data-val="{{ val }}" data-pct="{{ '%.1f' % pct_f }}" data-cards="{{ cards_line }}"></rect>
{% set bar_h = (pct if pct>2 else 2) %}
{% set y = 118 - bar_h %}
<rect x="2" y="{{ y }}" width="24" height="{{ bar_h }}" fill="#10b981" rx="4" ry="4"
data-type="sources" data-color="{{ color }}" data-val="{{ val }}" data-pct="{{ '%.1f' % pct_f }}"></rect>
<rect x="2" y="{{ y }}" width="24" height="{{ bar_h }}" fill="#10b981" rx="4" ry="4"
data-type="sources" data-color="{{ color }}" data-val="{{ val }}" data-pct="{{ '%.1f' % pct_f }}" data-cards="{{ cards_line }}"></rect>
</svg>
<div class="muted" style="margin-top:.25rem;">{{ color }}</div>
</div>
@ -351,6 +374,12 @@
</section>
<style>
.chart-tooltip { position: fixed; pointer-events: none; background: #0f1115; color: #e5e7eb; border: 1px solid var(--border); padding: .4rem .55rem; border-radius: 6px; font-size: 12px; line-height: 1.3; white-space: pre-line; z-index: 9999; display: none; box-shadow: 0 4px 16px rgba(0,0,0,.4); }
/* Cross-highlight from charts to cards */
.chart-highlight { outline: 2px solid #f59e0b; outline-offset: 2px; border-radius: 6px; background: rgba(245,158,11,.08); }
/* For list view, wrap highlight visually */
#typeview-list [data-card-name].chart-highlight { display:inline-block; padding: 2px 4px; border-radius: 6px; }
/* Ensure stack-card gets visible highlight */
.stack-card.chart-highlight { box-shadow: 0 0 0 2px #f59e0b, 0 6px 18px rgba(0,0,0,.55); }
</style>
<script>
(function() {
@ -364,7 +393,11 @@
}
return tip;
}
var tip = ensureTip();
var tip = ensureTip();
var hoverTimer = null;
var lastNames = [];
var lastType = '';
function clearHoverTimer(){ if (hoverTimer) { clearTimeout(hoverTimer); hoverTimer = null; } }
function position(e) {
tip.style.display = 'block';
var x = e.clientX + 12, y = e.clientY + 12;
@ -376,29 +409,147 @@
if (x + rect.width + 8 > vw) tip.style.left = (e.clientX - rect.width - 12) + 'px';
if (y + rect.height + 8 > vh) tip.style.top = (e.clientY - rect.height - 12) + 'px';
}
function compose(el) {
function buildTip(el) {
// Render tooltip with safe DOM and a Copy button for card list
tip.innerHTML = '';
var t = el.getAttribute('data-type');
var header = document.createElement('div');
header.style.fontWeight = '600';
header.style.marginBottom = '.25rem';
var listText = '';
if (t === 'pips') {
return el.dataset.color + ': ' + el.dataset.count + ' (' + el.dataset.pct + '%)';
header.textContent = el.dataset.color + ': ' + (el.dataset.count || '0') + ' (' + (el.dataset.pct || '0') + '%)';
listText = (el.dataset.cards || '').split(' • ').filter(Boolean).join('\n');
} else if (t === 'sources') {
header.textContent = el.dataset.color + ': ' + (el.dataset.val || '0') + ' (' + (el.dataset.pct || '0') + '%)';
listText = (el.dataset.cards || '').split(' • ').filter(Boolean).join('\n');
} else if (t === 'curve') {
header.textContent = el.dataset.label + ': ' + (el.dataset.val || '0') + ' (' + (el.dataset.pct || '0') + '%)';
listText = (el.dataset.cards || '').split(' • ').filter(Boolean).join('\n');
} else {
header.textContent = el.getAttribute('aria-label') || '';
}
if (t === 'sources') {
return el.dataset.color + ': ' + el.dataset.val + ' (' + el.dataset.pct + '%)';
tip.appendChild(header);
if (listText) {
var pre = document.createElement('pre');
pre.style.margin = '0 0 .35rem 0';
pre.style.whiteSpace = 'pre-wrap';
pre.textContent = listText;
tip.appendChild(pre);
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.fontSize = '12px';
btn.style.padding = '.2rem .4rem';
btn.style.border = '1px solid var(--border)';
btn.style.background = '#12161c';
btn.style.color = '#e5e7eb';
btn.style.borderRadius = '4px';
btn.addEventListener('click', function(e){
e.stopPropagation();
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(listText);
} else {
var ta = document.createElement('textarea');
ta.value = listText; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta);
}
btn.textContent = 'Copied!';
setTimeout(function(){ btn.textContent = 'Copy'; }, 1200);
} catch(_) {}
});
tip.appendChild(btn);
}
if (t === 'curve') {
var cards = (el.dataset.cards || '').split(' • ').join('\n');
return el.dataset.label + ': ' + el.dataset.val + ' (' + el.dataset.pct + '%)' + (cards ? '\n' + cards : '');
}
return el.getAttribute('aria-label') || '';
}
function normalizeList(list) {
if (!Array.isArray(list)) return [];
return list.map(function(n){
if (!n) return '';
var s = String(n);
// Strip trailing " ×<num>" count suffix if present
s = s.replace(/\s×\d+$/,'');
return s.trim();
}).filter(Boolean);
}
function attach() {
document.querySelectorAll('[data-type]').forEach(function(el) {
el.addEventListener('mouseenter', function(e) {
tip.textContent = compose(el);
buildTip(el);
position(e);
// Cross-highlight for mana curve bars -> card items
try {
if (el.getAttribute('data-type') === 'curve') {
lastNames = normalizeList((el.dataset.cards || '').split(' • ').filter(Boolean));
lastType = 'curve';
highlightNames(lastNames, true);
} else if (el.getAttribute('data-type') === 'pips' || el.getAttribute('data-type') === 'sources') {
lastNames = normalizeList((el.dataset.cards || '').split(' • ').filter(Boolean));
lastType = el.getAttribute('data-type');
highlightNames(lastNames, true);
}
} catch(_) {}
});
el.addEventListener('mousemove', position);
el.addEventListener('mouseleave', function() { tip.style.display = 'none'; });
el.addEventListener('mouseleave', function() {
clearHoverTimer();
hoverTimer = setTimeout(function(){
tip.style.display = 'none';
try { if (lastNames && lastNames.length) highlightNames(lastNames, false); } catch(_) {}
lastNames = []; lastType = '';
}, 200);
});
});
// Keep tooltip open while hovering it
tip.addEventListener('mouseenter', function(){ clearHoverTimer(); });
tip.addEventListener('mouseleave', function(){
tip.style.display = 'none';
try { if (lastNames && lastNames.length) highlightNames(lastNames, false); } catch(_) {}
lastNames = []; lastType = '';
});
// Initialize Show C toggle
initShowCToggle();
}
function initShowCToggle(){
var cb = document.getElementById('toggle-show-c');
var container = document.querySelector('.sources-bars');
if (!cb || !container) return;
// Default ON; restore prior state
var pref = 'true';
try { var v = localStorage.getItem('showColorlessC'); if (v!==null) pref = v; } catch(_) {}
cb.checked = (pref !== 'false');
function apply(){
var on = cb.checked;
try { localStorage.setItem('showColorlessC', String(on)); } catch(_) {}
container.querySelectorAll('[data-color="C"]').forEach(function(el){
el.style.display = on ? '' : 'none';
});
}
cb.addEventListener('change', apply);
apply();
}
function highlightNames(names, on){
if (!Array.isArray(names) || names.length === 0) return;
// List view spans
try {
document.querySelectorAll('#typeview-list [data-card-name]').forEach(function(it){
var n = it.getAttribute('data-card-name');
if (!n) return;
var match = names.indexOf(n) !== -1;
// Toggle highlight only on the inline span so it doesn't fill the entire grid cell
it.classList.toggle('chart-highlight', !!(on && match));
if (!on && !match) it.classList.remove('chart-highlight');
});
} catch(_) {}
// Thumbs view images
try {
document.querySelectorAll('#typeview-thumbs [data-card-name]').forEach(function(it){
var n = it.getAttribute('data-card-name');
if (!n) return;
var tile = it.closest('.stack-card') || it;
var match = names.indexOf(n) !== -1;
tile.classList.toggle('chart-highlight', !!(on && match));
if (!on && !match) tile.classList.remove('chart-highlight');
});
} catch(_) {}
}
attach();
document.addEventListener('htmx:afterSwap', function() { attach(); });