mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-09-22 04:50:46 +02:00
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:
parent
625f6abb13
commit
8d1f6a8ac4
27 changed files with 1704 additions and 154 deletions
|
@ -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
|
||||
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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",
|
||||
{
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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
329
code/web/static/app.js
Normal 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 A–Z
|
||||
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);
|
||||
}
|
||||
})();
|
BIN
code/web/static/favicon-small.png
Normal file
BIN
code/web/static/favicon-small.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 7.7 KiB |
BIN
code/web/static/favicon.png
Normal file
BIN
code/web/static/favicon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 9.1 KiB |
|
@ -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; }
|
||||
|
|
|
@ -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>
|
||||
|
|
25
code/web/templates/build/_stage_navigator.html
Normal file
25
code/web/templates/build/_stage_navigator.html
Normal 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>
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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;">
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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">A–Z</option>
|
||||
<option value="owned">Owned first</option>
|
||||
<option value="gc">Game-changers first</option>
|
||||
</select>
|
||||
<span class="sep"></span>
|
||||
<span class="hint">Visible: <strong data-results>0</strong></span>
|
||||
<span class="sep"></span>
|
||||
<div class="chips-inline">
|
||||
<span class="chip" data-chip-owned="all">All</span>
|
||||
<span class="chip" data-chip-owned="owned">Owned</span>
|
||||
<span class="chip" data-chip-owned="not">Not owned</span>
|
||||
<span class="chip" data-chip-clear>Clear</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sticky build controls on mobile -->
|
||||
<div class="build-controls" 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 %}
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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(); });
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue