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

@ -66,13 +66,15 @@ def normalize_theme_list(raw) -> list[str]:
def compute_color_source_matrix(card_library: Dict[str, dict], full_df) -> Dict[str, Dict[str, int]]:
"""Build a matrix mapping land name -> {color: 0/1} indicating if that land
can (reliably) produce each color.
"""Build a matrix mapping card name -> {color: 0/1} indicating if that card
can (reliably) produce each color of mana on the battlefield.
Heuristics:
- Presence of basic land types in type line grants that color.
- Text containing "add one mana of any color/colour" grants all colors.
- Explicit mana symbols in rules text (e.g. "{R}") grant that color.
Notes:
- Includes lands and non-lands (artifacts/creatures/enchantments/planeswalkers) that produce mana.
- Excludes instants/sorceries (rituals) by design; this is a "source" count, not ramp burst.
- Any-color effects set W/U/B/R/G (not C). Colorless '{C}' is tracked separately.
- For lands, we also infer from basic land types in the type line. For non-lands, we rely on text.
- Fallback name mapping applies only to exact basic lands (incl. Snow-Covered) and Wastes.
Parameters
----------
@ -89,29 +91,84 @@ def compute_color_source_matrix(card_library: Dict[str, dict], full_df) -> Dict[
if nm and nm not in lookup:
lookup[nm] = r
for name, entry in card_library.items():
if 'land' not in str(entry.get('Card Type', '')).lower():
continue
row = lookup.get(name, {})
tline = str(row.get('type', row.get('type_line', ''))).lower()
text_field = str(row.get('text', row.get('oracleText', ''))).lower()
colors = {c: 0 for c in COLOR_LETTERS}
if 'plains' in tline:
colors['W'] = 1
if 'island' in tline:
colors['U'] = 1
if 'swamp' in tline:
colors['B'] = 1
if 'mountain' in tline:
colors['R'] = 1
if 'forest' in tline:
colors['G'] = 1
if 'add one mana of any color' in text_field or 'add one mana of any colour' in text_field:
for k in colors:
entry_type = str(entry.get('Card Type') or entry.get('Type') or '').lower()
tline_full = str(row.get('type', row.get('type_line', '')) or '').lower()
# Land or permanent that could produce mana via text
is_land = ('land' in entry_type) or ('land' in tline_full)
text_field = str(row.get('text', row.get('oracleText', '')) or '').lower()
# Skip obvious non-permanents (rituals etc.)
if (not is_land) and ('instant' in entry_type or 'sorcery' in entry_type or 'instant' in tline_full or 'sorcery' in tline_full):
continue
# Keep only candidates that are lands OR whose text indicates mana production
produces_from_text = False
tf = text_field
if tf:
# Common patterns: "Add {G}", "Add {C}{C}", "Add one mana of any color/colour"
produces_from_text = (
('add one mana of any color' in tf) or
('add one mana of any colour' in tf) or
('add ' in tf and ('{w}' in tf or '{u}' in tf or '{b}' in tf or '{r}' in tf or '{g}' in tf or '{c}' in tf))
)
if not (is_land or produces_from_text):
continue
# Combine entry type and snapshot type line for robust parsing
tline = (entry_type + ' ' + tline_full).strip()
colors = {c: 0 for c in (COLOR_LETTERS + ['C'])}
# Land type-based inference
if is_land:
if 'plains' in tline:
colors['W'] = 1
if 'island' in tline:
colors['U'] = 1
if 'swamp' in tline:
colors['B'] = 1
if 'mountain' in tline:
colors['R'] = 1
if 'forest' in tline:
colors['G'] = 1
# Text-based inference for both lands and non-lands
if (
'add one mana of any color' in tf or
'add one mana of any colour' in tf or
('add' in tf and ('mana of any color' in tf or 'mana of any one color' in tf or 'any color of mana' in tf))
):
for k in COLOR_LETTERS:
colors[k] = 1
for sym, c in [(' {w}', 'W'), (' {u}', 'U'), (' {b}', 'B'), (' {r}', 'R'), (' {g}', 'G')]:
if sym in text_field:
colors[c] = 1
matrix[name] = colors
# Explicit colored/colorless symbols in add context
if 'add' in tf:
if '{w}' in tf:
colors['W'] = 1
if '{u}' in tf:
colors['U'] = 1
if '{b}' in tf:
colors['B'] = 1
if '{r}' in tf:
colors['R'] = 1
if '{g}' in tf:
colors['G'] = 1
if '{c}' in tf or 'colorless' in tf:
colors['C'] = 1
# Fallback: infer only for exact basic land names (incl. Snow-Covered) and Wastes
if not any(colors.values()) and is_land:
nm = str(name)
base = nm
if nm.startswith('Snow-Covered '):
base = nm[len('Snow-Covered '):]
mapping = {
'Plains': 'W',
'Island': 'U',
'Swamp': 'B',
'Mountain': 'R',
'Forest': 'G',
'Wastes': 'C',
}
col = mapping.get(base)
if col:
colors[col] = 1
# Only include cards that produced at least one color
if any(colors.values()):
matrix[name] = colors
return matrix

View file

@ -201,6 +201,8 @@ class ReportingMixin:
# Pip distribution (counts and weights) for non-land spells only
pip_counts = {c: 0 for c in ('W','U','B','R','G')}
# For UI cross-highlighting: map color -> list of cards that have that color pip in their cost
pip_cards: Dict[str, list] = {c: [] for c in ('W','U','B','R','G')}
import re as _re_local
total_pips = 0.0
for name, info in self.card_library.items():
@ -210,11 +212,14 @@ class ReportingMixin:
mana_cost = info.get('Mana Cost') or info.get('mana_cost') or ''
if not isinstance(mana_cost, str):
continue
# Track which colors appear for this card's mana cost for card listing
colors_for_card = set()
for match in _re_local.findall(r'\{([^}]+)\}', mana_cost):
sym = match.upper()
if len(sym) == 1 and sym in pip_counts:
pip_counts[sym] += 1
total_pips += 1
colors_for_card.add(sym)
elif '/' in sym:
parts = [p for p in sym.split('/') if p in pip_counts]
if parts:
@ -222,6 +227,17 @@ class ReportingMixin:
for p in parts:
pip_counts[p] += weight_each
total_pips += weight_each
colors_for_card.add(p)
elif sym.endswith('P') and len(sym) == 2: # e.g. WP (Phyrexian) -> treat as that color
base = sym[0]
if base in pip_counts:
pip_counts[base] += 1
total_pips += 1
colors_for_card.add(base)
if colors_for_card:
cnt = int(info.get('Count', 1))
for c in colors_for_card:
pip_cards[c].append({'name': name, 'count': cnt})
if total_pips <= 0:
# Fallback to even distribution across color identity
colors = [c for c in ('W','U','B','R','G') if c in (getattr(self, 'color_identity', []) or [])]
@ -238,12 +254,15 @@ class ReportingMixin:
matrix = _bu.compute_color_source_matrix(self.card_library, full_df)
except Exception:
matrix = {}
source_counts = {c: 0 for c in ('W','U','B','R','G')}
source_counts = {c: 0 for c in ('W','U','B','R','G','C')}
# For UI cross-highlighting: color -> list of cards that produce that color (typically lands, possibly others)
source_cards: Dict[str, list] = {c: [] for c in ('W','U','B','R','G','C')}
for name, flags in matrix.items():
copies = int(self.card_library.get(name, {}).get('Count', 1))
for c in source_counts:
for c in source_counts.keys():
if int(flags.get(c, 0)):
source_counts[c] += copies
source_cards[c].append({'name': name, 'count': copies})
total_sources = sum(source_counts.values())
# Mana curve (non-land spells)
@ -282,10 +301,12 @@ class ReportingMixin:
'pip_distribution': {
'counts': pip_counts,
'weights': pip_weights,
'cards': pip_cards,
},
'mana_generation': {
**source_counts,
'total_sources': total_sources,
'cards': source_cards,
},
'mana_curve': {
**curve_counts,
@ -393,6 +414,15 @@ class ReportingMixin:
except Exception:
owned_set_lower = set()
# Fallback oracle text for basic lands to ensure CSV has meaningful text
BASIC_TEXT = {
'Plains': '({T}: Add {W}.)',
'Island': '({T}: Add {U}.)',
'Swamp': '({T}: Add {B}.)',
'Mountain': '({T}: Add {R}.)',
'Forest': '({T}: Add {G}.)',
'Wastes': '({T}: Add {C}.)',
}
for name, info in self.card_library.items():
base_type = info.get('Card Type') or info.get('Type', '')
base_mc = info.get('Mana Cost', '')
@ -423,6 +453,9 @@ class ReportingMixin:
power = row.get('power', '') or ''
toughness = row.get('toughness', '') or ''
text_field = row.get('text', row.get('oracleText', '')) or ''
# If still no text and this is a basic, inject fallback oracle snippet
if (not text_field) and (str(name) in BASIC_TEXT):
text_field = BASIC_TEXT[str(name)]
# Normalize and coerce text
if isinstance(text_field, str):
cleaned = text_field

View file

@ -1,12 +1,15 @@
from __future__ import annotations
from fastapi import FastAPI, Request
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import HTMLResponse, FileResponse, PlainTextResponse, JSONResponse
from fastapi.templating import Jinja2Templates
from fastapi.staticfiles import StaticFiles
from pathlib import Path
import os
import json as _json
import time
import uuid
import logging
# Resolve template/static dirs relative to this file
_THIS_DIR = Path(__file__).resolve().parent
@ -37,16 +40,39 @@ templates.env.globals.update({
"show_setup": SHOW_SETUP,
})
# --- Diagnostics: request-id and uptime ---
_APP_START_TIME = time.time()
@app.middleware("http")
async def request_id_middleware(request: Request, call_next):
"""Assign or propagate a request id and attach to response headers."""
rid = request.headers.get("X-Request-ID") or uuid.uuid4().hex
request.state.request_id = rid
try:
response = await call_next(request)
except Exception as ex:
# Log and re-raise so FastAPI exception handlers can format the response.
logging.getLogger("web").error(f"Unhandled error [rid={rid}]: {ex}", exc_info=True)
raise
response.headers["X-Request-ID"] = rid
return response
@app.get("/", response_class=HTMLResponse)
async def home(request: Request) -> HTMLResponse:
return templates.TemplateResponse("home.html", {"request": request, "version": os.getenv("APP_VERSION", "dev")})
# Simple health check
# Simple health check (hardened)
@app.get("/healthz")
async def healthz():
return {"status": "ok"}
try:
version = os.getenv("APP_VERSION", "dev")
uptime_s = int(time.time() - _APP_START_TIME)
return {"status": "ok", "version": version, "uptime_seconds": uptime_s}
except Exception:
# Avoid throwing from health
return {"status": "degraded"}
# Lightweight setup/tagging status endpoint
@app.get("/status/setup")
@ -97,6 +123,45 @@ app.include_router(decks_routes.router)
app.include_router(setup_routes.router)
app.include_router(owned_routes.router)
# --- Exception handling ---
@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException):
rid = getattr(request.state, "request_id", None) or uuid.uuid4().hex
logging.getLogger("web").warning(
f"HTTPException [rid={rid}] {exc.status_code} {request.method} {request.url.path}: {exc.detail}"
)
# Return JSON structure suitable for HTMX or API consumers
return JSONResponse(
status_code=exc.status_code,
content={
"error": True,
"status": exc.status_code,
"detail": exc.detail,
"request_id": rid,
"path": str(request.url.path),
},
headers={"X-Request-ID": rid},
)
@app.exception_handler(Exception)
async def unhandled_exception_handler(request: Request, exc: Exception):
rid = getattr(request.state, "request_id", None) or uuid.uuid4().hex
logging.getLogger("web").error(
f"Unhandled exception [rid={rid}] {request.method} {request.url.path}", exc_info=True
)
return JSONResponse(
status_code=500,
content={
"error": True,
"status": 500,
"detail": "Internal Server Error",
"request_id": rid,
"path": str(request.url.path),
},
headers={"X-Request-ID": rid},
)
# Lightweight file download endpoint for exports
@app.get("/files")
async def get_file(path: str):
@ -118,3 +183,16 @@ async def get_file(path: str):
return FileResponse(path)
except Exception:
return PlainTextResponse("Error serving file", status_code=500)
# Serve /favicon.ico from static (prefer .ico, fallback to .png)
@app.get("/favicon.ico")
async def favicon():
try:
ico = _STATIC_DIR / "favicon.ico"
png = _STATIC_DIR / "favicon.png"
target = ico if ico.exists() else (png if png.exists() else None)
if target is None:
return PlainTextResponse("Not found", status_code=404)
return FileResponse(str(target))
except Exception:
return PlainTextResponse("Error", status_code=500)

View file

@ -15,9 +15,28 @@ router = APIRouter(prefix="/build")
async def build_index(request: Request) -> HTMLResponse:
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
# Determine last step (fallback heuristics if not set)
last_step = sess.get("last_step")
if not last_step:
if sess.get("build_ctx"):
last_step = 5
elif sess.get("ideals"):
last_step = 4
elif sess.get("bracket"):
last_step = 3
elif sess.get("commander"):
last_step = 2
else:
last_step = 1
resp = templates.TemplateResponse(
"build/index.html",
{"request": request, "sid": sid, "commander": sess.get("commander"), "tags": sess.get("tags", [])},
{
"request": request,
"sid": sid,
"commander": sess.get("commander"),
"tags": sess.get("tags", []),
"last_step": last_step,
},
)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
@ -25,7 +44,12 @@ async def build_index(request: Request) -> HTMLResponse:
@router.get("/step1", response_class=HTMLResponse)
async def build_step1(request: Request) -> HTMLResponse:
return templates.TemplateResponse("build/_step1.html", {"request": request, "candidates": []})
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
sess["last_step"] = 1
resp = templates.TemplateResponse("build/_step1.html", {"request": request, "candidates": []})
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
@router.post("/step1", response_class=HTMLResponse)
@ -45,7 +69,10 @@ async def build_step1_search(
top_name = candidates[0][0]
res = orch.commander_select(top_name)
if res.get("ok"):
return templates.TemplateResponse(
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
sess["last_step"] = 2
resp = templates.TemplateResponse(
"build/_step2.html",
{
"request": request,
@ -56,7 +83,12 @@ async def build_step1_search(
"brackets": orch.bracket_options(),
},
)
return templates.TemplateResponse(
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
sess["last_step"] = 1
resp = templates.TemplateResponse(
"build/_step1.html",
{
"request": request,
@ -67,24 +99,39 @@ async def build_step1_search(
"count": len(candidates) if candidates else 0,
},
)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
@router.post("/step1/inspect", response_class=HTMLResponse)
async def build_step1_inspect(request: Request, name: str = Form(...)) -> HTMLResponse:
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
sess["last_step"] = 1
info = orch.commander_inspect(name)
return templates.TemplateResponse(
resp = templates.TemplateResponse(
"build/_step1.html",
{"request": request, "inspect": info, "selected": name, "tags": orch.tags_for_commander(name)},
)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
@router.post("/step1/confirm", response_class=HTMLResponse)
async def build_step1_confirm(request: Request, name: str = Form(...)) -> HTMLResponse:
res = orch.commander_select(name)
if not res.get("ok"):
return templates.TemplateResponse("build/_step1.html", {"request": request, "error": res.get("error"), "selected": name})
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
sess["last_step"] = 1
resp = templates.TemplateResponse("build/_step1.html", {"request": request, "error": res.get("error"), "selected": name})
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
# Proceed to step2 placeholder
return templates.TemplateResponse(
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
sess["last_step"] = 2
resp = templates.TemplateResponse(
"build/_step2.html",
{
"request": request,
@ -95,19 +142,24 @@ async def build_step1_confirm(request: Request, name: str = Form(...)) -> HTMLRe
"brackets": orch.bracket_options(),
},
)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
@router.get("/step2", response_class=HTMLResponse)
async def build_step2_get(request: Request) -> HTMLResponse:
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
sess["last_step"] = 2
commander = sess.get("commander")
if not commander:
# Fallback to step1 if no commander in session
return templates.TemplateResponse("build/_step1.html", {"request": request, "candidates": []})
resp = templates.TemplateResponse("build/_step1.html", {"request": request, "candidates": []})
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
tags = orch.tags_for_commander(commander)
selected = sess.get("tags", [])
return templates.TemplateResponse(
resp = templates.TemplateResponse(
"build/_step2.html",
{
"request": request,
@ -123,6 +175,8 @@ async def build_step2_get(request: Request) -> HTMLResponse:
"tag_mode": sess.get("tag_mode", "AND"),
},
)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
@router.post("/step2", response_class=HTMLResponse)
@ -138,7 +192,10 @@ async def build_step2_submit(
# Validate primary tag selection if tags are available
available_tags = orch.tags_for_commander(commander)
if available_tags and not (primary_tag and primary_tag.strip()):
return templates.TemplateResponse(
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
sess["last_step"] = 2
resp = templates.TemplateResponse(
"build/_step2.html",
{
"request": request,
@ -155,6 +212,8 @@ async def build_step2_submit(
"tag_mode": (tag_mode or "AND"),
},
)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
# Save selection to session (basic MVP; real build will use this later)
sid = request.cookies.get("sid") or new_sid()
@ -164,7 +223,8 @@ async def build_step2_submit(
sess["tag_mode"] = (tag_mode or "AND").upper()
sess["bracket"] = int(bracket)
# Proceed to Step 3 placeholder for now
return templates.TemplateResponse(
sess["last_step"] = 3
resp = templates.TemplateResponse(
"build/_step3.html",
{
"request": request,
@ -176,6 +236,8 @@ async def build_step2_submit(
"values": orch.ideal_defaults(),
},
)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
@router.post("/step3", response_class=HTMLResponse)
@ -220,7 +282,8 @@ async def build_step3_submit(
if errors:
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
return templates.TemplateResponse(
sess["last_step"] = 3
resp = templates.TemplateResponse(
"build/_step3.html",
{
"request": request,
@ -233,6 +296,8 @@ async def build_step3_submit(
"bracket": sess.get("bracket"),
},
)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
# Save to session
sid = request.cookies.get("sid") or new_sid()
@ -240,7 +305,8 @@ async def build_step3_submit(
sess["ideals"] = submitted
# Proceed to review (Step 4)
return templates.TemplateResponse(
sess["last_step"] = 4
resp = templates.TemplateResponse(
"build/_step4.html",
{
"request": request,
@ -249,12 +315,15 @@ async def build_step3_submit(
"commander": sess.get("commander"),
},
)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
return resp
@router.get("/step3", response_class=HTMLResponse)
async def build_step3_get(request: Request) -> HTMLResponse:
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
sess["last_step"] = 3
defaults = orch.ideal_defaults()
values = sess.get("ideals") or defaults
resp = templates.TemplateResponse(
@ -277,6 +346,7 @@ async def build_step3_get(request: Request) -> HTMLResponse:
async def build_step4_get(request: Request) -> HTMLResponse:
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
sess["last_step"] = 4
labels = orch.ideal_labels()
values = sess.get("ideals") or orch.ideal_defaults()
commander = sess.get("commander")
@ -302,6 +372,7 @@ async def build_toggle_owned_review(
"""Toggle 'use owned only' and/or 'prefer owned' flags from the Review step and re-render Step 4."""
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
sess["last_step"] = 4
only_val = True if (use_owned_only and str(use_owned_only).strip() in ("1","true","on","yes")) else False
pref_val = True if (prefer_owned and str(prefer_owned).strip() in ("1","true","on","yes")) else False
sess["use_owned_only"] = only_val
@ -329,6 +400,7 @@ async def build_toggle_owned_review(
async def build_step5_get(request: Request) -> HTMLResponse:
sid = request.cookies.get("sid") or new_sid()
sess = get_session(sid)
sess["last_step"] = 5
resp = templates.TemplateResponse(
"build/_step5.html",
{
@ -344,6 +416,12 @@ async def build_step5_get(request: Request) -> HTMLResponse:
"stage_label": None,
"log": None,
"added_cards": [],
"i": None,
"n": None,
"total_cards": None,
"added_total": 0,
"show_skipped": False,
"skipped": False,
"game_changers": bc.GAME_CHANGERS,
},
)
@ -383,10 +461,12 @@ async def build_step5_continue(request: Request) -> HTMLResponse:
prefer_owned=prefer,
owned_names=owned_names,
)
show_skipped = True if (request.query_params.get('show_skipped') == '1' or (await request.form().get('show_skipped', None) == '1') if hasattr(request, 'form') else False) else False
# Read show_skipped from either query or form safely
show_skipped = True if (request.query_params.get('show_skipped') == '1') else False
try:
form = await request.form()
show_skipped = True if (form.get('show_skipped') == '1') else show_skipped
if form and form.get('show_skipped') == '1':
show_skipped = True
except Exception:
pass
res = orch.run_stage(sess["build_ctx"], rerun=False, show_skipped=show_skipped)
@ -400,6 +480,9 @@ async def build_step5_continue(request: Request) -> HTMLResponse:
csv_path = res.get("csv_path") if res.get("done") else None
txt_path = res.get("txt_path") if res.get("done") else None
summary = res.get("summary") if res.get("done") else None
total_cards = res.get("total_cards")
added_total = res.get("added_total")
sess["last_step"] = 5
resp = templates.TemplateResponse(
"build/_step5.html",
{
@ -422,6 +505,9 @@ async def build_step5_continue(request: Request) -> HTMLResponse:
"summary": summary,
"game_changers": bc.GAME_CHANGERS,
"show_skipped": show_skipped,
"total_cards": total_cards,
"added_total": added_total,
"skipped": bool(res.get("skipped")),
},
)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
@ -474,6 +560,9 @@ async def build_step5_rerun(request: Request) -> HTMLResponse:
csv_path = res.get("csv_path") if res.get("done") else None
txt_path = res.get("txt_path") if res.get("done") else None
summary = res.get("summary") if res.get("done") else None
total_cards = res.get("total_cards")
added_total = res.get("added_total")
sess["last_step"] = 5
resp = templates.TemplateResponse(
"build/_step5.html",
{
@ -496,6 +585,9 @@ async def build_step5_rerun(request: Request) -> HTMLResponse:
"summary": summary,
"game_changers": bc.GAME_CHANGERS,
"show_skipped": show_skipped,
"total_cards": total_cards,
"added_total": added_total,
"skipped": bool(res.get("skipped")),
},
)
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
@ -554,6 +646,7 @@ async def build_step5_start(request: Request) -> HTMLResponse:
csv_path = res.get("csv_path") if res.get("done") else None
txt_path = res.get("txt_path") if res.get("done") else None
summary = res.get("summary") if res.get("done") else None
sess["last_step"] = 5
resp = templates.TemplateResponse(
"build/_step5.html",
{

View file

@ -76,6 +76,8 @@ def _build_owned_context(request: Request, notice: str | None = None, error: str
"""
# Read enriched data from the store (fast path; avoids per-request CSV parsing)
names, tags_by_name, type_by_name, colors_by_name = store.get_enriched()
added_at_map = store.get_added_at_map()
user_tags_map = store.get_user_tags_map()
# Default sort by name (case-insensitive)
names_sorted = sorted(names, key=lambda s: s.lower())
# Build filter option sets
@ -95,6 +97,8 @@ def _build_owned_context(request: Request, notice: str | None = None, error: str
"all_tags": all_tags,
"all_colors": all_colors,
"color_combos": combos,
"added_at_map": added_at_map,
"user_tags_map": user_tags_map,
}
if notice:
ctx["notice"] = notice
@ -139,6 +143,85 @@ async def owned_clear(request: Request) -> HTMLResponse:
return templates.TemplateResponse("owned/index.html", ctx)
@router.post("/remove", response_class=HTMLResponse)
async def owned_remove(request: Request) -> HTMLResponse:
"""Remove a set of names provided as JSON or form data under 'names'."""
try:
names: list[str] = []
# Try JSON first
try:
payload = await request.json()
if isinstance(payload, dict) and isinstance(payload.get("names"), list):
names = [str(x) for x in payload.get("names")]
elif isinstance(payload, list):
names = [str(x) for x in payload]
except Exception:
# Fallback to form field 'names' as comma-separated
form = await request.form()
raw = form.get("names") or ""
if raw:
names = [s.strip() for s in str(raw).split(',') if s.strip()]
removed, total = store.remove_names(names)
notice = f"Removed {removed} name(s). Total: {total}."
ctx = _build_owned_context(request, notice=notice)
return templates.TemplateResponse("owned/index.html", ctx)
except Exception as e:
ctx = _build_owned_context(request, error=f"Remove failed: {e}")
return templates.TemplateResponse("owned/index.html", ctx)
@router.post("/tag/add", response_class=HTMLResponse)
async def owned_tag_add(request: Request) -> HTMLResponse:
try:
names: list[str] = []
tag: str = ""
try:
payload = await request.json()
if isinstance(payload, dict):
if isinstance(payload.get("names"), list):
names = [str(x) for x in payload.get("names")]
tag = str(payload.get("tag") or "").strip()
except Exception:
form = await request.form()
raw = form.get("names") or ""
if raw:
names = [s.strip() for s in str(raw).split(',') if s.strip()]
tag = str(form.get("tag") or "").strip()
updated = store.add_user_tag(names, tag)
notice = f"Added tag '{tag}' to {updated} name(s)."
ctx = _build_owned_context(request, notice=notice)
return templates.TemplateResponse("owned/index.html", ctx)
except Exception as e:
ctx = _build_owned_context(request, error=f"Tag add failed: {e}")
return templates.TemplateResponse("owned/index.html", ctx)
@router.post("/tag/remove", response_class=HTMLResponse)
async def owned_tag_remove(request: Request) -> HTMLResponse:
try:
names: list[str] = []
tag: str = ""
try:
payload = await request.json()
if isinstance(payload, dict):
if isinstance(payload.get("names"), list):
names = [str(x) for x in payload.get("names")]
tag = str(payload.get("tag") or "").strip()
except Exception:
form = await request.form()
raw = form.get("names") or ""
if raw:
names = [s.strip() for s in str(raw).split(',') if s.strip()]
tag = str(form.get("tag") or "").strip()
updated = store.remove_user_tag(names, tag)
notice = f"Removed tag '{tag}' from {updated} name(s)."
ctx = _build_owned_context(request, notice=notice)
return templates.TemplateResponse("owned/index.html", ctx)
except Exception as e:
ctx = _build_owned_context(request, error=f"Tag remove failed: {e}")
return templates.TemplateResponse("owned/index.html", ctx)
# Legacy /owned/use route removed; owned-only toggle now lives on the Builder Review step.
@ -177,3 +260,69 @@ async def owned_export_csv() -> Response:
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": "attachment; filename=owned_cards.csv"},
)
@router.post("/export-visible")
async def owned_export_visible_txt(request: Request) -> Response:
"""Download the provided names (visible subset) as TXT."""
try:
names: list[str] = []
try:
payload = await request.json()
if isinstance(payload, dict) and isinstance(payload.get("names"), list):
names = [str(x) for x in payload.get("names")]
elif isinstance(payload, list):
names = [str(x) for x in payload]
except Exception:
form = await request.form()
raw = form.get("names") or ""
if raw:
names = [s.strip() for s in str(raw).split(',') if s.strip()]
# Stable case-insensitive sort
lines = "\n".join(sorted((names or []), key=lambda s: s.lower()))
return Response(
content=lines + ("\n" if lines else ""),
media_type="text/plain; charset=utf-8",
headers={"Content-Disposition": "attachment; filename=owned_visible.txt"},
)
except Exception:
# On error return empty file
return Response(content="", media_type="text/plain; charset=utf-8")
@router.post("/export-visible.csv")
async def owned_export_visible_csv(request: Request) -> Response:
"""Download the provided names (visible subset) with enrichment as CSV."""
try:
names: list[str] = []
try:
payload = await request.json()
if isinstance(payload, dict) and isinstance(payload.get("names"), list):
names = [str(x) for x in payload.get("names")]
elif isinstance(payload, list):
names = [str(x) for x in payload]
except Exception:
form = await request.form()
raw = form.get("names") or ""
if raw:
names = [s.strip() for s in str(raw).split(',') if s.strip()]
# Build CSV using current enrichment
all_names, tags_by_name, type_by_name, colors_by_name = store.get_enriched()
import csv
from io import StringIO
buf = StringIO()
writer = csv.writer(buf)
writer.writerow(["Name", "Type", "Colors", "Tags"])
for n in sorted((names or []), key=lambda s: s.lower()):
tline = type_by_name.get(n, "")
cols = ''.join(colors_by_name.get(n, []) or [])
tags = '|'.join(tags_by_name.get(n, []) or [])
writer.writerow([n, tline, cols, tags])
content = buf.getvalue()
return Response(
content=content,
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": "attachment; filename=owned_visible.csv"},
)
except Exception:
return Response(content="", media_type="text/csv; charset=utf-8")

View file

@ -548,55 +548,92 @@ def _ensure_setup_ready(out, force: bool = False) -> None:
out(f"Initial setup failed: {e}")
_write_status({"running": False, "phase": "error", "message": f"Initial setup failed: {e}"})
return
# Tagging with granular color progress
# Tagging with progress; support parallel workers for speed
try:
from tagging import tagger as _tagger # type: ignore
from settings import COLORS as _COLORS # type: ignore
colors = list(_COLORS)
total = len(colors)
use_parallel = str(os.getenv('WEB_TAG_PARALLEL', '1')).strip().lower() in {"1","true","yes","on"}
max_workers_env = os.getenv('WEB_TAG_WORKERS')
try:
max_workers = int(max_workers_env) if max_workers_env else None
except Exception:
max_workers = None
_write_status({
"running": True,
"phase": "tagging",
"message": "Tagging cards (this may take a while)...",
"message": "Tagging cards (this may take a while)..." if not use_parallel else "Tagging cards in parallel...",
"color": None,
"percent": 0,
"color_idx": 0,
"color_total": total,
"tagging_started_at": _dt.now().isoformat(timespec='seconds')
})
for idx, _color in enumerate(colors, start=1):
if use_parallel:
try:
pct = int((idx - 1) * 100 / max(1, total))
# Estimate ETA based on average time per completed color
eta_s = None
try:
from datetime import datetime as __dt
ts = __dt.fromisoformat(json.load(open(os.path.join('csv_files', '.setup_status.json'), 'r', encoding='utf-8')).get('tagging_started_at')) # type: ignore
elapsed = max(0.0, (_dt.now() - ts).total_seconds())
completed = max(0, idx - 1)
if completed > 0:
avg = elapsed / completed
remaining = max(0, total - completed)
eta_s = int(avg * remaining)
except Exception:
eta_s = None
payload = {
"running": True,
"phase": "tagging",
"message": f"Tagging {_color}...",
"color": _color,
"percent": pct,
"color_idx": idx,
"color_total": total,
}
if eta_s is not None:
payload["eta_seconds"] = eta_s
_write_status(payload)
_tagger.load_dataframe(_color)
import concurrent.futures as _f
completed = 0
with _f.ProcessPoolExecutor(max_workers=max_workers) as ex:
fut_map = {ex.submit(_tagger.load_dataframe, c): c for c in colors}
for fut in _f.as_completed(fut_map):
c = fut_map[fut]
try:
fut.result()
completed += 1
pct = int(completed * 100 / max(1, total))
_write_status({
"running": True,
"phase": "tagging",
"message": f"Tagged {c}",
"color": c,
"percent": pct,
"color_idx": completed,
"color_total": total,
})
except Exception as e:
out(f"Parallel tagging failed for {c}: {e}")
_write_status({"running": False, "phase": "error", "message": f"Tagging {c} failed: {e}", "color": c})
return
except Exception as e:
out(f"Tagging {_color} failed: {e}")
_write_status({"running": False, "phase": "error", "message": f"Tagging {_color} failed: {e}", "color": _color})
return
out(f"Parallel tagging init failed: {e}; falling back to sequential")
use_parallel = False
if not use_parallel:
for idx, _color in enumerate(colors, start=1):
try:
pct = int((idx - 1) * 100 / max(1, total))
# Estimate ETA based on average time per completed color
eta_s = None
try:
from datetime import datetime as __dt
ts = __dt.fromisoformat(json.load(open(os.path.join('csv_files', '.setup_status.json'), 'r', encoding='utf-8')).get('tagging_started_at')) # type: ignore
elapsed = max(0.0, (_dt.now() - ts).total_seconds())
completed = max(0, idx - 1)
if completed > 0:
avg = elapsed / completed
remaining = max(0, total - completed)
eta_s = int(avg * remaining)
except Exception:
eta_s = None
payload = {
"running": True,
"phase": "tagging",
"message": f"Tagging {_color}...",
"color": _color,
"percent": pct,
"color_idx": idx,
"color_total": total,
}
if eta_s is not None:
payload["eta_seconds"] = eta_s
_write_status(payload)
_tagger.load_dataframe(_color)
except Exception as e:
out(f"Tagging {_color} failed: {e}")
_write_status({"running": False, "phase": "error", "message": f"Tagging {_color} failed: {e}", "color": _color})
return
except Exception as e:
out(f"Tagging failed to start: {e}")
_write_status({"running": False, "phase": "error", "message": f"Tagging failed to start: {e}"})
@ -1117,6 +1154,21 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
# If this stage added cards, present it and advance idx
if added_cards:
# Progress counts
try:
total_cards = 0
for _n, _e in getattr(b, 'card_library', {}).items():
try:
total_cards += int(_e.get('Count', 1))
except Exception:
total_cards += 1
except Exception:
total_cards = None
added_total = 0
try:
added_total = sum(int(c.get('count', 0) or 0) for c in added_cards)
except Exception:
added_total = 0
ctx["snapshot"] = snap_before # snapshot for rerun
ctx["idx"] = i + 1
ctx["last_visible_idx"] = i + 1
@ -1127,10 +1179,22 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
"added_cards": added_cards,
"idx": i + 1,
"total": len(stages),
"total_cards": total_cards,
"added_total": added_total,
}
# No cards added: either skip or surface as a 'skipped' stage
if show_skipped:
# Progress counts even when skipped
try:
total_cards = 0
for _n, _e in getattr(b, 'card_library', {}).items():
try:
total_cards += int(_e.get('Count', 1))
except Exception:
total_cards += 1
except Exception:
total_cards = None
ctx["snapshot"] = snap_before
ctx["idx"] = i + 1
ctx["last_visible_idx"] = i + 1
@ -1142,6 +1206,8 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
"skipped": True,
"idx": i + 1,
"total": len(stages),
"total_cards": total_cards,
"added_total": 0,
}
# No cards added and not showing skipped: advance to next
@ -1194,6 +1260,16 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
_json.dump(payload, f, ensure_ascii=False, indent=2)
except Exception:
pass
# Final progress
try:
total_cards = 0
for _n, _e in getattr(b, 'card_library', {}).items():
try:
total_cards += int(_e.get('Count', 1))
except Exception:
total_cards += 1
except Exception:
total_cards = None
return {
"done": True,
"label": "Complete",
@ -1203,4 +1279,6 @@ def run_stage(ctx: Dict[str, Any], rerun: bool = False, show_skipped: bool = Fal
"csv_path": ctx.get("csv_path"),
"txt_path": ctx.get("txt_path"),
"summary": summary,
"total_cards": total_cards,
"added_total": 0,
}

View file

@ -4,6 +4,7 @@ from pathlib import Path
from typing import Iterable, List, Tuple, Dict
import json
import os
import time
def _owned_dir() -> Path:
@ -108,6 +109,16 @@ def add_names(names: Iterable[str]) -> Tuple[int, int]:
data["names"] = cur
if "meta" not in data or not isinstance(data.get("meta"), dict):
data["meta"] = {}
meta = data["meta"]
now = int(time.time())
# Ensure newly added names have an added_at
for s in cur:
info = meta.get(s)
if not info:
meta[s] = {"added_at": now}
else:
if "added_at" not in info:
info["added_at"] = now
_save_raw(data)
return added, len(cur)
@ -263,10 +274,16 @@ def add_and_enrich(names: Iterable[str]) -> Tuple[int, int]:
continue
# Enrich
meta = data.get("meta") or {}
now = int(time.time())
if new_names:
enriched = _enrich_from_csvs(new_names)
for nm, info in enriched.items():
meta[nm] = info
# Stamp added_at for new names if missing
for nm in new_names:
entry = meta.setdefault(nm, {})
if "added_at" not in entry:
entry["added_at"] = now
data["names"] = current_names
data["meta"] = meta
_save_raw(data)
@ -285,7 +302,15 @@ def get_enriched() -> Tuple[List[str], Dict[str, List[str]], Dict[str, str], Dic
colors_by_name: Dict[str, List[str]] = {}
for n in names:
info = meta.get(n) or {}
tags = info.get('tags') or []
tags = (info.get('tags') or [])
user_tags = (info.get('user_tags') or [])
if user_tags:
# merge user tags (unique, case-insensitive)
seen = {str(t).lower() for t in tags}
for ut in user_tags:
if str(ut).lower() not in seen:
(tags or []).append(str(ut))
seen.add(str(ut).lower())
typ = info.get('type') or None
cols = info.get('colors') or []
if tags:
@ -297,6 +322,114 @@ def get_enriched() -> Tuple[List[str], Dict[str, List[str]], Dict[str, str], Dic
return names, tags_by_name, type_by_name, colors_by_name
def add_user_tag(names: Iterable[str], tag: str) -> int:
"""Add a user-defined tag to the given names; returns number of names updated."""
t = str(tag or '').strip()
if not t:
return 0
data = _load_raw()
cur = [str(x).strip() for x in (data.get('names') or []) if str(x).strip()]
target = {str(n).strip().lower() for n in (names or []) if str(n).strip()}
meta = data.get('meta') or {}
updated = 0
for s in cur:
if s.lower() not in target:
continue
entry = meta.setdefault(s, {})
arr = entry.get('user_tags') or []
if not any(str(x).strip().lower() == t.lower() for x in arr):
arr.append(t)
entry['user_tags'] = arr
updated += 1
data['meta'] = meta
_save_raw(data)
return updated
def remove_user_tag(names: Iterable[str], tag: str) -> int:
"""Remove a user-defined tag from the given names; returns number of names updated."""
t = str(tag or '').strip()
if not t:
return 0
data = _load_raw()
cur = [str(x).strip() for x in (data.get('names') or []) if str(x).strip()]
target = {str(n).strip().lower() for n in (names or []) if str(n).strip()}
meta = data.get('meta') or {}
updated = 0
for s in cur:
if s.lower() not in target:
continue
entry = meta.get(s) or {}
arr = [x for x in (entry.get('user_tags') or []) if str(x)]
before = len(arr)
arr = [x for x in arr if str(x).strip().lower() != t.lower()]
if len(arr) != before:
entry['user_tags'] = arr
meta[s] = entry
updated += 1
data['meta'] = meta
_save_raw(data)
return updated
def get_added_at_map() -> Dict[str, int]:
"""Return a mapping of name -> added_at unix timestamp (if known)."""
data = _load_raw()
meta: Dict[str, Dict[str, object]] = data.get("meta") or {}
out: Dict[str, int] = {}
for n, info in meta.items():
try:
ts = info.get("added_at")
if isinstance(ts, (int, float)):
out[n] = int(ts)
except Exception:
continue
return out
def remove_names(names: Iterable[str]) -> Tuple[int, int]:
"""Remove a batch of names; returns (removed_count, total_after)."""
target = {str(n).strip().lower() for n in (names or []) if str(n).strip()}
if not target:
return 0, len(get_names())
data = _load_raw()
cur = [str(x).strip() for x in (data.get("names") or []) if str(x).strip()]
before = len(cur)
cur_kept: List[str] = []
for s in cur:
if s.lower() in target:
continue
cur_kept.append(s)
removed = before - len(cur_kept)
data["names"] = cur_kept
meta = data.get("meta") or {}
# Drop meta entries for removed names
for s in list(meta.keys()):
try:
if s.lower() in target:
meta.pop(s, None)
except Exception:
continue
data["meta"] = meta
_save_raw(data)
return removed, len(cur_kept)
def get_user_tags_map() -> Dict[str, list[str]]:
"""Return a mapping of name -> list of user-defined tags (if any)."""
data = _load_raw()
meta: Dict[str, Dict[str, object]] = data.get("meta") or {}
out: Dict[str, list[str]] = {}
for n, info in meta.items():
try:
arr = [x for x in (info.get("user_tags") or []) if str(x)]
if arr:
out[n] = [str(x) for x in arr]
except Exception:
continue
return out
def parse_txt_bytes(content: bytes) -> List[str]:
out: List[str] = []
try:

329
code/web/static/app.js Normal file
View file

@ -0,0 +1,329 @@
/* Core app enhancements: tokens, toasts, shortcuts, state, skeletons */
(function(){
// Design tokens fallback (in case CSS variables missing in older browsers)
// No-op here since styles.css defines variables; kept for future JS reads.
// State persistence helpers (localStorage + URL hash)
var state = {
get: function(key, def){
try { var v = localStorage.getItem('mtg:'+key); return v !== null ? JSON.parse(v) : def; } catch(e){ return def; }
},
set: function(key, val){
try { localStorage.setItem('mtg:'+key, JSON.stringify(val)); } catch(e){}
},
inHash: function(obj){
// Merge obj into location.hash as query-like params
try {
var params = new URLSearchParams((location.hash||'').replace(/^#/, ''));
Object.keys(obj||{}).forEach(function(k){ params.set(k, obj[k]); });
location.hash = params.toString();
} catch(e){}
},
readHash: function(){
try { return new URLSearchParams((location.hash||'').replace(/^#/, '')); } catch(e){ return new URLSearchParams(); }
}
};
window.__mtgState = state;
// Toast system
var toastHost;
function ensureToastHost(){
if (!toastHost){
toastHost = document.createElement('div');
toastHost.className = 'toast-host';
document.body.appendChild(toastHost);
}
return toastHost;
}
function toast(msg, type, opts){
ensureToastHost();
var t = document.createElement('div');
t.className = 'toast' + (type ? ' '+type : '');
t.setAttribute('role','status');
t.setAttribute('aria-live','polite');
t.textContent = msg;
toastHost.appendChild(t);
var delay = (opts && opts.duration) || 2600;
setTimeout(function(){ t.classList.add('hide'); setTimeout(function(){ t.remove(); }, 300); }, delay);
return t;
}
window.toast = toast;
// Global HTMX error handling => toast
document.addEventListener('htmx:responseError', function(e){
var detail = e.detail || {}; var xhr = detail.xhr || {};
var msg = 'Action failed';
try { if (xhr.responseText) msg += ': ' + xhr.responseText.slice(0,140); } catch(_){}
toast(msg, 'error', { duration: 5000 });
});
document.addEventListener('htmx:sendError', function(){ toast('Network error', 'error', { duration: 4000 }); });
// Keyboard shortcuts
var keymap = {
' ': function(){ var el = document.querySelector('[data-action="continue"], .btn-continue'); if (el) el.click(); },
'r': function(){ var el = document.querySelector('[data-action="rerun"], .btn-rerun'); if (el) el.click(); },
'b': function(){ var el = document.querySelector('[data-action="back"], .btn-back'); if (el) el.click(); },
'l': function(){ var el = document.querySelector('[data-action="toggle-logs"], .btn-logs'); if (el) el.click(); },
};
document.addEventListener('keydown', function(e){
if (e.target && (/input|textarea|select/i).test(e.target.tagName)) return; // don't hijack inputs
var k = e.key.toLowerCase();
if (keymap[k]){ e.preventDefault(); keymap[k](); }
});
// Focus ring visibility for keyboard nav
function addFocusVisible(){
var hadKeyboardEvent = false;
function onKeyDown(){ hadKeyboardEvent = true; }
function onPointer(){ hadKeyboardEvent = false; }
function onFocus(e){ if (hadKeyboardEvent) e.target.classList.add('focus-visible'); }
function onBlur(e){ e.target.classList.remove('focus-visible'); }
window.addEventListener('keydown', onKeyDown, true);
window.addEventListener('mousedown', onPointer, true);
window.addEventListener('pointerdown', onPointer, true);
window.addEventListener('touchstart', onPointer, true);
document.addEventListener('focusin', onFocus);
document.addEventListener('focusout', onBlur);
}
addFocusVisible();
// Skeleton utility: swap placeholders before HTMX swaps or on explicit triggers
function showSkeletons(container){
(container || document).querySelectorAll('[data-skeleton]')
.forEach(function(el){ el.classList.add('is-loading'); });
}
function hideSkeletons(container){
(container || document).querySelectorAll('[data-skeleton]')
.forEach(function(el){ el.classList.remove('is-loading'); });
}
window.skeletons = { show: showSkeletons, hide: hideSkeletons };
document.addEventListener('htmx:beforeRequest', function(e){ showSkeletons(e.target); });
document.addEventListener('htmx:afterSwap', function(e){ hideSkeletons(e.target); });
// Example: persist "show skipped" toggle if present
document.addEventListener('change', function(e){
var el = e.target;
if (el && el.matches('[data-pref]')){
var key = el.getAttribute('data-pref');
var val = (el.type === 'checkbox') ? !!el.checked : el.value;
state.set(key, val);
state.inHash((function(o){ o[key] = val; return o; })({}));
}
});
// On load, initialize any data-pref elements
document.addEventListener('DOMContentLoaded', function(){
document.querySelectorAll('[data-pref]').forEach(function(el){
var key = el.getAttribute('data-pref');
var saved = state.get(key, undefined);
if (typeof saved !== 'undefined'){
if (el.type === 'checkbox') el.checked = !!saved; else el.value = saved;
}
});
hydrateProgress(document);
syncShowSkipped(document);
initCardFilters(document);
});
// Hydrate progress bars with width based on data-pct
function hydrateProgress(root){
(root || document).querySelectorAll('.progress[data-pct]')
.forEach(function(p){
var pct = parseInt(p.getAttribute('data-pct') || '0', 10);
if (isNaN(pct) || pct < 0) pct = 0; if (pct > 100) pct = 100;
var bar = p.querySelector('.bar'); if (!bar) return;
// Animate width for a bit of delight
requestAnimationFrame(function(){ bar.style.width = pct + '%'; });
});
}
// Keep hidden inputs for show_skipped in sync with the sticky checkbox
function syncShowSkipped(root){
var cb = (root || document).querySelector('input[name="__toggle_show_skipped"][data-pref]');
if (!cb) return;
var val = cb.checked ? '1' : '0';
(root || document).querySelectorAll('section form').forEach(function(f){
var h = f.querySelector('input[name="show_skipped"]');
if (h) h.value = val;
});
}
document.addEventListener('htmx:afterSwap', function(e){
hydrateProgress(e.target);
syncShowSkipped(e.target);
initCardFilters(e.target);
});
// --- Card grid filters, reasons, and collapsible groups ---
function initCardFilters(root){
var section = (root || document).querySelector('section');
if (!section) return;
var toolbar = section.querySelector('.cards-toolbar');
if (!toolbar) return; // nothing to do
var q = toolbar.querySelector('input[name="filter_query"]');
var ownedSel = toolbar.querySelector('select[name="filter_owned"]');
var showReasons = toolbar.querySelector('input[name="show_reasons"]');
var collapseGroups = toolbar.querySelector('input[name="collapse_groups"]');
var resultsEl = toolbar.querySelector('[data-results]');
var emptyEl = section.querySelector('[data-empty]');
var sortSel = toolbar.querySelector('select[name="filter_sort"]');
var chipOwned = toolbar.querySelector('[data-chip-owned="owned"]');
var chipNot = toolbar.querySelector('[data-chip-owned="not"]');
var chipAll = toolbar.querySelector('[data-chip-owned="all"]');
var chipClear = toolbar.querySelector('[data-chip-clear]');
function getVal(el){ return el ? (el.type === 'checkbox' ? !!el.checked : (el.value||'')) : ''; }
// Read URL hash on first init to hydrate controls
try {
var params = window.__mtgState.readHash();
if (params){
var hv = params.get('q'); if (q && hv !== null) q.value = hv;
hv = params.get('owned'); if (ownedSel && hv) ownedSel.value = hv;
hv = params.get('showreasons'); if (showReasons && hv !== null) showReasons.checked = (hv === '1');
hv = params.get('collapse'); if (collapseGroups && hv !== null) collapseGroups.checked = (hv === '1');
hv = params.get('sort'); if (sortSel && hv) sortSel.value = hv;
}
} catch(_){}
function apply(){
var query = (getVal(q)+ '').toLowerCase().trim();
var ownedMode = (getVal(ownedSel) || 'all');
var showR = !!getVal(showReasons);
var collapse = !!getVal(collapseGroups);
var sortMode = (getVal(sortSel) || 'az');
// Toggle reasons visibility via section class
section.classList.toggle('hide-reasons', !showR);
// Collapse or expand all groups if toggle exists; when not collapsed, restore per-group stored state
section.querySelectorAll('.group').forEach(function(wrapper){
var grid = wrapper.querySelector('.group-grid'); if (!grid) return;
var key = wrapper.getAttribute('data-group-key');
if (collapse){
grid.setAttribute('data-collapsed','1');
} else {
// restore stored
if (key){
var stored = state.get('cards:group:'+key, null);
if (stored === true){ grid.setAttribute('data-collapsed','1'); }
else { grid.removeAttribute('data-collapsed'); }
} else {
grid.removeAttribute('data-collapsed');
}
}
});
// Filter tiles
var tiles = section.querySelectorAll('.card-grid .card-tile');
var visible = 0;
tiles.forEach(function(tile){
var name = (tile.getAttribute('data-card-name')||'').toLowerCase();
var role = (tile.getAttribute('data-role')||'').toLowerCase();
var tags = (tile.getAttribute('data-tags')||'').toLowerCase();
var owned = tile.getAttribute('data-owned') === '1';
var text = name + ' ' + role + ' ' + tags;
var qOk = !query || text.indexOf(query) !== -1;
var oOk = (ownedMode === 'all') || (ownedMode === 'owned' && owned) || (ownedMode === 'not' && !owned);
var show = qOk && oOk;
tile.style.display = show ? '' : 'none';
if (show) visible++;
});
// Sort within each grid
function keyFor(tile){
var name = (tile.getAttribute('data-card-name')||'');
var owned = tile.getAttribute('data-owned') === '1' ? 1 : 0;
var gc = tile.classList.contains('game-changer') ? 1 : 0;
return { name: name.toLowerCase(), owned: owned, gc: gc };
}
section.querySelectorAll('.card-grid').forEach(function(grid){
var arr = Array.prototype.slice.call(grid.querySelectorAll('.card-tile'));
arr.sort(function(a,b){
var ka = keyFor(a), kb = keyFor(b);
if (sortMode === 'owned'){
if (kb.owned !== ka.owned) return kb.owned - ka.owned;
if (kb.gc !== ka.gc) return kb.gc - ka.gc; // gc next
return ka.name.localeCompare(kb.name);
} else if (sortMode === 'gc'){
if (kb.gc !== ka.gc) return kb.gc - ka.gc;
if (kb.owned !== ka.owned) return kb.owned - ka.owned;
return ka.name.localeCompare(kb.name);
}
// default AZ
return ka.name.localeCompare(kb.name);
});
arr.forEach(function(el){ grid.appendChild(el); });
});
// Update group counts based on visible tiles within each group
section.querySelectorAll('.group').forEach(function(wrapper){
var grid = wrapper.querySelector('.group-grid');
var count = 0;
if (grid){
grid.querySelectorAll('.card-tile').forEach(function(t){ if (t.style.display !== 'none') count++; });
}
var cEl = wrapper.querySelector('[data-count]');
if (cEl) cEl.textContent = count;
});
if (resultsEl) resultsEl.textContent = String(visible);
if (emptyEl) emptyEl.hidden = (visible !== 0);
// Persist prefs
if (q && q.hasAttribute('data-pref')) state.set(q.getAttribute('data-pref'), q.value);
if (ownedSel && ownedSel.hasAttribute('data-pref')) state.set(ownedSel.getAttribute('data-pref'), ownedSel.value);
if (showReasons && showReasons.hasAttribute('data-pref')) state.set(showReasons.getAttribute('data-pref'), !!showReasons.checked);
if (collapseGroups && collapseGroups.hasAttribute('data-pref')) state.set(collapseGroups.getAttribute('data-pref'), !!collapseGroups.checked);
if (sortSel && sortSel.hasAttribute('data-pref')) state.set(sortSel.getAttribute('data-pref'), sortSel.value);
// Update URL hash for shareability
try { window.__mtgState.inHash({ q: query, owned: ownedMode, showreasons: showR ? 1 : 0, collapse: collapse ? 1 : 0, sort: sortMode }); } catch(_){ }
}
// Wire events
if (q) q.addEventListener('input', apply);
if (ownedSel) ownedSel.addEventListener('change', apply);
if (showReasons) showReasons.addEventListener('change', apply);
if (collapseGroups) collapseGroups.addEventListener('change', apply);
if (chipOwned) chipOwned.addEventListener('click', function(){ if (ownedSel){ ownedSel.value = 'owned'; } apply(); });
if (chipNot) chipNot.addEventListener('click', function(){ if (ownedSel){ ownedSel.value = 'not'; } apply(); });
if (chipAll) chipAll.addEventListener('click', function(){ if (ownedSel){ ownedSel.value = 'all'; } apply(); });
if (chipClear) chipClear.addEventListener('click', function(){ if (q) q.value=''; if (ownedSel) ownedSel.value='all'; apply(); });
// Individual group toggles
section.querySelectorAll('.group-header .toggle').forEach(function(btn){
btn.addEventListener('click', function(){
var wrapper = btn.closest('.group');
var grid = wrapper && wrapper.querySelector('.group-grid');
if (!grid) return;
var key = wrapper.getAttribute('data-group-key');
var willCollapse = !grid.getAttribute('data-collapsed');
if (willCollapse) grid.setAttribute('data-collapsed','1'); else grid.removeAttribute('data-collapsed');
if (key){ state.set('cards:group:'+key, !!willCollapse); }
// ARIA
btn.setAttribute('aria-expanded', willCollapse ? 'false' : 'true');
});
});
// Per-card reason toggle: delegate clicks on .btn-why
section.addEventListener('click', function(e){
var t = e.target;
if (!t || !t.classList || !t.classList.contains('btn-why')) return;
e.preventDefault();
var tile = t.closest('.card-tile');
if (!tile) return;
var globalHidden = section.classList.contains('hide-reasons');
if (globalHidden){
// Force-show overrides global hidden
var on = tile.classList.toggle('force-show');
if (on) tile.classList.remove('force-hide');
t.textContent = on ? 'Hide why' : 'Why?';
} else {
// Hide this tile only
var off = tile.classList.toggle('force-hide');
if (off) tile.classList.remove('force-show');
t.textContent = off ? 'Show why' : 'Hide why';
}
});
// Initial apply on hydrate
apply();
// Keyboard helpers: '/' focuses query, Esc clears
function onKey(e){
// avoid when typing in inputs
if (e.target && (/input|textarea|select/i).test(e.target.tagName)) return;
if (e.key === '/'){
if (q){ e.preventDefault(); q.focus(); q.select && q.select(); }
} else if (e.key === 'Escape'){
if (q && q.value){ q.value=''; apply(); }
}
}
document.addEventListener('keydown', onKey);
}
})();

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

BIN
code/web/static/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

View file

@ -17,12 +17,18 @@
--text: #e8e8e8;
--muted: #b6b8bd;
--border: #2a2b2f;
--ring: #60a5fa; /* focus ring */
--ok: #16a34a; /* success */
--warn: #f59e0b; /* warning */
--err: #ef4444; /* error */
}
*{box-sizing:border-box}
html,body{height:100%}
body { font-family: system-ui, Arial, sans-serif; margin: 0; color: var(--text); background: var(--bg); }
/* Honor HTML hidden attribute across the app */
[hidden] { display: none !important; }
/* Accessible focus ring for keyboard navigation */
.focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }
/* Top banner */
.top-banner{ position:sticky; top:0; z-index:10; background:#0c0d0f; border-bottom:1px solid var(--border); }
.top-banner .top-inner{ margin:0; padding:.5rem 0; display:grid; grid-template-columns: var(--sidebar-w) 1fr; align-items:center; }
@ -70,6 +76,29 @@ select,input[type="text"],input[type="number"]{ background:#0f1115; color:var(--
fieldset{ border:1px solid var(--border); border-radius:8px; padding:.75rem; margin:.75rem 0; }
small, .muted{ color: var(--muted); }
/* Toasts */
.toast-host{ position: fixed; right: 12px; bottom: 12px; display: flex; flex-direction: column; gap: 8px; z-index: 9999; }
.toast{ background: rgba(17,24,39,.95); color:#e5e7eb; border:1px solid var(--border); border-radius:10px; padding:.5rem .65rem; box-shadow: 0 8px 24px rgba(0,0,0,.35); transition: transform .2s ease, opacity .2s ease; }
.toast.hide{ opacity:0; transform: translateY(6px); }
.toast.success{ border-color: rgba(22,163,74,.4); }
.toast.error{ border-color: rgba(239,68,68,.45); }
.toast.warn{ border-color: rgba(245,158,11,.45); }
/* Skeletons */
[data-skeleton]{ position: relative; }
[data-skeleton].is-loading > *{ opacity: 0; }
[data-skeleton]::after{
content: '';
position: absolute; inset: 0;
border-radius: 8px;
background: linear-gradient(90deg, rgba(255,255,255,0.04), rgba(255,255,255,0.08), rgba(255,255,255,0.04));
background-size: 200% 100%;
animation: shimmer 1.1s linear infinite;
display: none;
}
[data-skeleton].is-loading::after{ display:block; }
@keyframes shimmer{ 0%{ background-position: 200% 0; } 100%{ background-position: -200% 0; } }
/* Banner */
.banner{ background: linear-gradient(90deg, rgba(0,0,0,.25), rgba(0,0,0,0)); border: 1px solid var(--border); border-radius: 10px; padding: 2rem 1.6rem; margin-bottom: 1rem; box-shadow: 0 8px 30px rgba(0,0,0,.25) inset; }
.banner h1{ font-size: 2rem; margin:0 0 .35rem; }
@ -143,3 +172,46 @@ small, .muted{ color: var(--muted); }
/* Deck summary: highlight game changers */
.game-changer { color: var(--green-main); }
.stack-card.game-changer { outline: 2px solid var(--green-main); }
/* Stage Navigator */
.stage-nav { margin:.5rem 0 1rem; }
.stage-nav ol { list-style:none; padding:0; margin:0; display:flex; gap:.35rem; flex-wrap:wrap; }
.stage-nav .stage-link { display:flex; align-items:center; gap:.4rem; background:#0f1115; border:1px solid var(--border); color:var(--text); border-radius:999px; padding:.25rem .6rem; cursor:pointer; }
.stage-nav .stage-item.done .stage-link { opacity:.75; }
.stage-nav .stage-item.current .stage-link { box-shadow: 0 0 0 2px rgba(96,165,250,.4) inset; border-color:#3b82f6; }
.stage-nav .idx { display:inline-grid; place-items:center; width:20px; height:20px; border-radius:50%; background:#1f2937; font-size:12px; }
.stage-nav .name { font-size:12px; }
/* Build controls sticky box tweaks for small screens */
@media (max-width: 720px){
.build-controls { position: sticky; top: 0; border-radius: 0; margin-left: -1.5rem; margin-right: -1.5rem; }
}
/* Progress bar */
.progress { position: relative; height: 10px; background: #0f1115; border:1px solid var(--border); border-radius: 999px; overflow: hidden; }
.progress .bar { position:absolute; left:0; top:0; bottom:0; width: 0%; background: linear-gradient(90deg, rgba(96,165,250,.6), rgba(14,104,171,.9)); }
.progress.flash { box-shadow: 0 0 0 2px rgba(245,158,11,.35) inset; }
/* Chips */
.chip { display:inline-flex; align-items:center; gap:.35rem; background:#0f1115; border:1px solid var(--border); color:var(--text); border-radius:999px; padding:.2rem .55rem; font-size:12px; }
.chip .dot { width:8px; height:8px; border-radius:50%; background:#6b7280; }
/* Cards toolbar */
.cards-toolbar{ display:flex; flex-wrap:wrap; gap:.5rem .75rem; align-items:center; margin:.5rem 0 .25rem; }
.cards-toolbar input[type="text"]{ min-width: 220px; }
.cards-toolbar .sep{ width:1px; height:20px; background: var(--border); margin:0 .25rem; }
.cards-toolbar .hint{ color: var(--muted); font-size:12px; }
/* Collapse groups and reason toggle */
.group{ margin:.5rem 0; }
.group-header{ display:flex; align-items:center; gap:.5rem; }
.group-header h5{ margin:.4rem 0; }
.group-header .count{ color: var(--muted); font-size:12px; }
.group-header .toggle{ margin-left:auto; background:#1f2937; color:#e5e7eb; border:1px solid var(--border); border-radius:6px; padding:.2rem .5rem; font-size:12px; cursor:pointer; }
.group-grid[data-collapsed]{ display:none; }
.hide-reasons .card-tile .reason{ display:none; }
.card-tile.force-show .reason{ display:block !important; }
.card-tile.force-hide .reason{ display:none !important; }
.btn-why{ background:#1f2937; color:#e5e7eb; border:1px solid var(--border); border-radius:6px; padding:.15rem .4rem; font-size:12px; cursor:pointer; }
.chips-inline{ display:flex; gap:.35rem; flex-wrap:wrap; align-items:center; }
.chips-inline .chip{ cursor:pointer; user-select:none; }

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(); });