Web UI polish: thumbnail-hover preview, white thumbnail selection, Themes bullet list; global Scryfall image retry (thumbs+previews) with fallbacks and cache-bust; standardized data-card-name. Deck Summary alignment overhaul (count//name/owned grid, tabular numerals, inset highlight, tooltips, starts under header). Added diagnostics (health + logs pages, error pages, request-id propagation), global HTMX error toasts, and docs updates. Update DOCKER guide and add run-web scripts. Update CHANGELOG and release notes template.

This commit is contained in:
mwisnowski 2025-08-27 11:21:46 -07:00
parent 8d1f6a8ac4
commit f8c6b5c07e
30 changed files with 786 additions and 232 deletions

View file

@ -0,0 +1,82 @@
import os
import importlib
import types
import pytest
from starlette.testclient import TestClient
fastapi = pytest.importorskip("fastapi") # skip tests if FastAPI isn't installed
def load_app_with_env(**env: str) -> types.ModuleType:
for k, v in env.items():
os.environ[k] = v
import code.web.app as app_module # type: ignore
importlib.reload(app_module)
return app_module
def test_healthz_ok_and_request_id_header():
app_module = load_app_with_env()
client = TestClient(app_module.app)
r = client.get("/healthz")
assert r.status_code == 200
data = r.json()
assert data.get("status") in {"ok", "degraded"}
assert "uptime_seconds" in data
assert r.headers.get("X-Request-ID")
def test_404_renders_html_when_accept_html():
app_module = load_app_with_env()
client = TestClient(app_module.app)
r = client.get("/this-does-not-exist", headers={"Accept": "text/html"})
assert r.status_code == 404
body = r.text.lower()
assert "page not found" in body
assert "go home" in body
assert r.headers.get("X-Request-ID")
def test_htmx_http_exception_returns_json_with_request_id():
app_module = load_app_with_env(SHOW_DIAGNOSTICS="1")
client = TestClient(app_module.app)
r = client.get("/diagnostics/trigger-error", headers={"HX-Request": "true"})
assert r.status_code == 418
data = r.json()
assert data.get("error") is True
assert data.get("status") == 418
assert data.get("request_id")
assert r.headers.get("X-Request-ID")
def test_unhandled_exception_returns_500_json_with_request_id():
app_module = load_app_with_env(SHOW_DIAGNOSTICS="1")
# Configure client to not re-raise server exceptions so we can assert the 500 response
client = TestClient(app_module.app, raise_server_exceptions=False)
r = client.get("/diagnostics/trigger-error?kind=unhandled", headers={"HX-Request": "true"})
assert r.status_code == 500
data = r.json()
assert data.get("error") is True
assert data.get("status") == 500
assert data.get("request_id")
assert r.headers.get("X-Request-ID")
def test_status_sys_summary_and_flags():
app_module = load_app_with_env(
SHOW_LOGS="1",
SHOW_DIAGNOSTICS="1",
SHOW_SETUP="1",
APP_VERSION="testver",
)
client = TestClient(app_module.app)
r = client.get("/status/sys")
assert r.status_code == 200
data = r.json()
assert data.get("version") == "testver"
assert isinstance(data.get("uptime_seconds"), int)
assert isinstance(data.get("server_time_utc"), str)
flags = data.get("flags") or {}
assert flags.get("SHOW_LOGS") is True
assert flags.get("SHOW_DIAGNOSTICS") is True
assert flags.get("SHOW_SETUP") is True

View file

@ -1,7 +1,7 @@
from __future__ import annotations
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import HTMLResponse, FileResponse, PlainTextResponse, JSONResponse
from fastapi import FastAPI, Request, HTTPException, Query
from fastapi.responses import HTMLResponse, FileResponse, PlainTextResponse, JSONResponse, Response
from fastapi.templating import Jinja2Templates
from fastapi.staticfiles import StaticFiles
from pathlib import Path
@ -10,6 +10,7 @@ import json as _json
import time
import uuid
import logging
from starlette.exceptions import HTTPException as StarletteHTTPException
# Resolve template/static dirs relative to this file
_THIS_DIR = Path(__file__).resolve().parent
@ -33,11 +34,13 @@ def _as_bool(val: str | None, default: bool = False) -> bool:
SHOW_LOGS = _as_bool(os.getenv("SHOW_LOGS"), False)
SHOW_SETUP = _as_bool(os.getenv("SHOW_SETUP"), True)
SHOW_DIAGNOSTICS = _as_bool(os.getenv("SHOW_DIAGNOSTICS"), False)
# Expose as Jinja globals so all templates can reference without passing per-view
templates.env.globals.update({
"show_logs": SHOW_LOGS,
"show_setup": SHOW_SETUP,
"show_diagnostics": SHOW_DIAGNOSTICS,
})
# --- Diagnostics: request-id and uptime ---
@ -74,6 +77,58 @@ async def healthz():
# Avoid throwing from health
return {"status": "degraded"}
# System summary endpoint for diagnostics
@app.get("/status/sys")
async def status_sys():
try:
version = os.getenv("APP_VERSION", "dev")
uptime_s = int(time.time() - _APP_START_TIME)
server_time = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
return {
"version": version,
"uptime_seconds": uptime_s,
"server_time_utc": server_time,
"flags": {
"SHOW_LOGS": bool(SHOW_LOGS),
"SHOW_SETUP": bool(SHOW_SETUP),
"SHOW_DIAGNOSTICS": bool(SHOW_DIAGNOSTICS),
},
}
except Exception:
return {"version": "unknown", "uptime_seconds": 0, "flags": {}}
# Logs tail endpoint (read-only)
@app.get("/status/logs")
async def status_logs(
tail: int = Query(200, ge=1, le=500),
q: str | None = None,
level: str | None = Query(None, description="Optional level filter: error|warning|info|debug"),
):
try:
if not SHOW_LOGS:
# Hide when logs are disabled
return JSONResponse({"error": True, "status": 403, "detail": "Logs disabled"}, status_code=403)
log_path = Path('logs/deck_builder.log')
if not log_path.exists():
return JSONResponse({"lines": [], "count": 0})
from collections import deque
with log_path.open('r', encoding='utf-8', errors='ignore') as lf:
lines = list(deque(lf, maxlen=tail))
if q:
ql = q.lower()
lines = [ln for ln in lines if ql in ln.lower()]
# Optional level filter (simple substring match)
if level:
lv = level.strip().lower()
# accept warn as alias for warning
if lv == "warn":
lv = "warning"
if lv in {"error", "warning", "info", "debug"}:
lines = [ln for ln in lines if lv in ln.lower()]
return JSONResponse({"lines": lines, "count": len(lines)})
except Exception:
return JSONResponse({"lines": [], "count": 0})
# Lightweight setup/tagging status endpoint
@app.get("/status/setup")
async def setup_status():
@ -124,24 +179,59 @@ app.include_router(setup_routes.router)
app.include_router(owned_routes.router)
# --- Exception handling ---
def _wants_html(request: Request) -> bool:
try:
accept = request.headers.get('accept', '')
is_htmx = request.headers.get('hx-request') == 'true'
return ("text/html" in accept) and not is_htmx
except Exception:
return False
@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},
if _wants_html(request):
# Friendly HTML page
template = "errors/404.html" if exc.status_code == 404 else "errors/4xx.html"
try:
return templates.TemplateResponse(template, {"request": request, "status": exc.status_code, "detail": exc.detail, "request_id": rid}, status_code=exc.status_code, headers={"X-Request-ID": rid})
except Exception:
# Fallback plain text
return PlainTextResponse(f"Error {exc.status_code}: {exc.detail}\nRequest-ID: {rid}", status_code=exc.status_code, headers={"X-Request-ID": rid})
# JSON structure for HTMX/API
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})
# Also handle Starlette's HTTPException (e.g., 404 route not found)
@app.exception_handler(StarletteHTTPException)
async def starlette_http_exception_handler(request: Request, exc: StarletteHTTPException):
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}"
)
if _wants_html(request):
template = "errors/404.html" if exc.status_code == 404 else "errors/4xx.html"
try:
return templates.TemplateResponse(template, {"request": request, "status": exc.status_code, "detail": exc.detail, "request_id": rid}, status_code=exc.status_code, headers={"X-Request-ID": rid})
except Exception:
return PlainTextResponse(f"Error {exc.status_code}: {exc.detail}\nRequest-ID: {rid}", status_code=exc.status_code, headers={"X-Request-ID": rid})
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)
@ -150,17 +240,18 @@ async def unhandled_exception_handler(request: Request, exc: Exception):
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},
)
if _wants_html(request):
try:
return templates.TemplateResponse("errors/500.html", {"request": request, "request_id": rid}, status_code=500, headers={"X-Request-ID": rid})
except Exception:
return PlainTextResponse(f"Internal Server Error\nRequest-ID: {rid}", status_code=500, headers={"X-Request-ID": rid})
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")
@ -196,3 +287,47 @@ async def favicon():
return FileResponse(str(target))
except Exception:
return PlainTextResponse("Error", status_code=500)
# Simple Logs page (optional, controlled by SHOW_LOGS)
@app.get("/logs", response_class=HTMLResponse)
async def logs_page(
request: Request,
tail: int = Query(200, ge=1, le=500),
q: str | None = None,
level: str | None = Query(None),
) -> Response:
if not SHOW_LOGS:
# Respect feature flag
raise HTTPException(status_code=404, detail="Not Found")
# Reuse status_logs logic
data = await status_logs(tail=tail, q=q, level=level) # type: ignore[arg-type]
lines: list[str]
if isinstance(data, JSONResponse):
payload = data.body
try:
parsed = _json.loads(payload)
lines = parsed.get("lines", [])
except Exception:
lines = []
else:
lines = []
return templates.TemplateResponse(
"diagnostics/logs.html",
{"request": request, "lines": lines, "tail": tail, "q": q or "", "level": (level or "all")},
)
# Error trigger route for demoing HTMX/global error handling (feature-flagged)
@app.get("/diagnostics/trigger-error")
async def trigger_error(kind: str = Query("http")):
if kind == "http":
raise HTTPException(status_code=418, detail="Teapot: example error for testing")
raise RuntimeError("Example unhandled error for testing")
@app.get("/diagnostics", response_class=HTMLResponse)
async def diagnostics_home(request: Request) -> HTMLResponse:
if not SHOW_DIAGNOSTICS:
raise HTTPException(status_code=404, detail="Not Found")
return templates.TemplateResponse("diagnostics/index.html", {"request": request})

View file

@ -77,7 +77,6 @@ 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
@ -98,7 +97,6 @@ def _build_owned_context(request: Request, notice: str | None = None, error: str
"all_colors": all_colors,
"color_combos": combos,
"added_at_map": added_at_map,
"user_tags_map": user_tags_map,
}
if notice:
ctx["notice"] = notice
@ -170,56 +168,12 @@ async def owned_remove(request: Request) -> HTMLResponse:
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)
# Bulk user-tag endpoints removed by request.
@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)
"""
Note: Per request, all user tag add/remove endpoints have been removed.
"""
# Legacy /owned/use route removed; owned-only toggle now lives on the Builder Review step.

View file

@ -303,14 +303,7 @@ def get_enriched() -> Tuple[List[str], Dict[str, List[str]], Dict[str, str], Dic
for n in names:
info = meta.get(n) 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())
# user-defined tags are no longer supported; no merge
typ = info.get('type') or None
cols = info.get('colors') or []
if tags:
@ -322,54 +315,7 @@ 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
# add_user_tag/remove_user_tag removed; user-defined tags are not persisted anymore
def get_added_at_map() -> Dict[str, int]:
@ -416,18 +362,8 @@ def remove_names(names: Iterable[str]) -> Tuple[int, int]:
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
"""Deprecated: user-defined tags have been removed. Always returns empty mapping."""
return {}
def parse_txt_bytes(content: bytes) -> List[str]:

View file

@ -41,20 +41,62 @@
t.className = 'toast' + (type ? ' '+type : '');
t.setAttribute('role','status');
t.setAttribute('aria-live','polite');
t.textContent = msg;
t.textContent = '';
if (typeof msg === 'string') { t.textContent = msg; }
else if (msg && msg.nodeType === 1) { t.appendChild(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;
function toastHTML(html, type, opts){
var container = document.createElement('div');
container.innerHTML = html;
return toast(container, type, opts);
}
window.toastHTML = toastHTML;
// 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 });
var rid = (xhr.getResponseHeader && xhr.getResponseHeader('X-Request-ID')) || '';
var payload = (function(){ try { return JSON.parse(xhr.responseText || '{}'); } catch(_){ return {}; } })();
var status = payload.status || xhr.status || '';
var msg = payload.detail || payload.message || 'Action failed';
var path = payload.path || (e && e.detail && e.detail.path) || '';
var html = ''+
'<div style="display:flex; align-items:center; gap:.5rem">'+
'<span style="font-weight:600">'+String(msg)+'</span>'+ (status? ' <span class="muted">('+status+')</span>' : '')+
(rid ? '<button class="btn small" style="margin-left:auto" type="button" data-copy-error>Copy details</button>' : '')+
'</div>'+
(rid ? '<div class="muted" style="font-size:11px; margin-top:2px">Request-ID: <code>'+rid+'</code></div>' : '');
var t = toastHTML(html, 'error', { duration: 7000 });
// Wire Copy
var btn = t.querySelector('[data-copy-error]');
if (btn){
btn.addEventListener('click', function(){
var lines = [
'Error: '+String(msg),
'Status: '+String(status),
'Path: '+String(path || (xhr.responseURL||'')),
'Request-ID: '+String(rid)
];
try { navigator.clipboard.writeText(lines.join('\n')); btn.textContent = 'Copied'; setTimeout(function(){ btn.textContent = 'Copy details'; }, 1200); } catch(_){ }
});
}
// Optional inline banner if a surface is available
try {
var target = e && e.target;
var surface = (target && target.closest && target.closest('[data-error-surface]')) || document.querySelector('[data-error-surface]');
if (surface){
var banner = document.createElement('div');
banner.className = 'inline-error-banner';
banner.innerHTML = '<strong>'+String(msg)+'</strong>' + (rid? ' <span class="muted">(Request-ID: '+rid+')</span>' : '');
surface.prepend(banner);
setTimeout(function(){ banner.remove(); }, 8000);
}
} catch(_){ }
});
document.addEventListener('htmx:sendError', function(){ toast('Network error', 'error', { duration: 4000 }); });

View file

@ -35,6 +35,8 @@ body { font-family: system-ui, Arial, sans-serif; margin: 0; color: var(--text);
.top-banner h1{ font-size: 1.1rem; margin:0; padding-left: 1rem; }
.banner-status{ color: var(--muted); font-size:.9rem; text-align:left; padding-left: 1.5rem; padding-right: 1.5rem; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.banner-status.busy{ color:#fbbf24; }
.health-dot{ width:10px; height:10px; border-radius:50%; display:inline-block; background:#10b981; box-shadow:0 0 0 2px rgba(16,185,129,.25) inset; }
.health-dot[data-state="bad"]{ background:#ef4444; box-shadow:0 0 0 2px rgba(239,68,68,.3) inset; }
/* Layout */
.layout{ display:grid; grid-template-columns: var(--sidebar-w) 1fr; min-height: calc(100vh - 52px); }
@ -215,3 +217,7 @@ small, .muted{ color: var(--muted); }
.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; }
/* Inline error banner */
.inline-error-banner{ background:#1a0f10; border:1px solid #b91c1c; color:#fca5a5; padding:.5rem .6rem; border-radius:8px; margin-bottom:.5rem; }
.inline-error-banner .muted{ color:#fda4af; }

View file

@ -15,7 +15,10 @@
<header class="top-banner">
<div class="top-inner">
<h1>MTG Deckbuilder</h1>
<div id="banner-status" class="banner-status">{% block banner_subtitle %}{% endblock %}</div>
<div style="display:flex; align-items:center; gap:.5rem">
<span id="health-dot" class="health-dot" title="Health"></span>
<div id="banner-status" class="banner-status">{% block banner_subtitle %}{% endblock %}</div>
</div>
</div>
</header>
<div class="layout">
@ -36,10 +39,11 @@
{% if show_setup %}<a href="/setup">Setup/Tag</a>{% endif %}
<a href="/owned">Owned Library</a>
<a href="/decks">Finished Decks</a>
{% if show_diagnostics %}<a href="/diagnostics">Diagnostics</a>{% endif %}
{% if show_logs %}<a href="/logs">Logs</a>{% endif %}
</nav>
</aside>
<main class="content">
<main class="content" data-error-surface>
{% block content %}{% endblock %}
</main>
</div>
@ -52,8 +56,12 @@
.card-hover { position: fixed; pointer-events: none; z-index: 9999; display: none; }
.card-hover-inner { display:flex; gap:12px; align-items:flex-start; }
.card-hover img { width: 320px; height: auto; display: block; border-radius: 8px; box-shadow: 0 6px 18px rgba(0,0,0,.55); border: 1px solid var(--border); background:#0f1115; }
.card-meta { background: #0f1115; color: #e5e7eb; border: 1px solid var(--border); border-radius: 8px; padding: .5rem .6rem; max-width: 280px; font-size: 12px; line-height: 1.35; box-shadow: 0 6px 18px rgba(0,0,0,.35); }
.card-meta .label { color:#94a3b8; text-transform: uppercase; font-size: 10px; letter-spacing: .04em; display:block; margin-bottom:.15rem; }
.card-meta { background: #0f1115; color: #e5e7eb; border: 1px solid var(--border); border-radius: 8px; padding: .5rem .6rem; max-width: 320px; font-size: 13px; line-height: 1.4; box-shadow: 0 6px 18px rgba(0,0,0,.35); }
.card-meta ul { margin:.25rem 0; padding-left: 1.1rem; list-style: disc; }
.card-meta li { margin:.1rem 0; }
.card-meta .themes-list { font-size: 18px; line-height: 1.35; }
.card-meta .label { color:#94a3b8; text-transform: uppercase; font-size: 10px; letter-spacing: .04em; display:block; margin-bottom:.15rem; }
.card-meta .themes-label { color:#f3f4f6; font-size: 20px; letter-spacing: .05em; }
.card-meta .line + .line { margin-top:.35rem; }
.site-footer { margin: 12px 16px 0; padding: 8px 12px; border-top: 1px solid var(--border); color: #94a3b8; font-size: 12px; text-align: center; }
.site-footer a { color: #cbd5e1; text-decoration: underline; }
@ -94,7 +102,26 @@
setInterval(pollStatus, 3000);
pollStatus();
function ensureCard() {
// Health indicator poller
var healthDot = document.getElementById('health-dot');
function renderHealth(data){
if (!healthDot) return;
var ok = data && data.status === 'ok';
healthDot.setAttribute('data-state', ok ? 'ok' : 'bad');
if (!ok) { healthDot.title = 'Degraded'; } else { healthDot.title = 'OK'; }
}
function pollHealth(){
try {
fetch('/healthz', { cache: 'no-store' })
.then(function(r){ return r.json(); })
.then(renderHealth)
.catch(function(){ renderHealth({ status: 'bad' }); });
} catch(e){ renderHealth({ status: 'bad' }); }
}
setInterval(pollHealth, 5000);
pollHealth();
function ensureCard() {
var pop = document.getElementById('card-hover');
if (!pop) {
pop = document.createElement('div');
@ -113,7 +140,65 @@
}
return pop;
}
var cardPop = ensureCard();
var cardPop = ensureCard();
var PREVIEW_VERSIONS = ['normal','large'];
function buildCardUrl(name, version, nocache){
var q = encodeURIComponent(name||'');
var url = 'https://api.scryfall.com/cards/named?fuzzy=' + q + '&format=image&version=' + (version||'normal');
if (nocache) url += '&t=' + Date.now();
return url;
}
// Generic Scryfall image URL builder
function buildScryfallImageUrl(name, version, nocache){
var q = encodeURIComponent(name||'');
var url = 'https://api.scryfall.com/cards/named?fuzzy=' + q + '&format=image&version=' + (version||'normal');
if (nocache) url += '&t=' + Date.now();
return url;
}
// Global image retry binding for any <img data-card-name>
var IMG_FLAG = '__cardImgRetry';
function bindCardImageRetry(img, versions){
try {
if (!img || img[IMG_FLAG]) return;
var name = img.getAttribute('data-card-name') || '';
if (!name) return;
img[IMG_FLAG] = { vi: 0, nocache: 0, versions: versions && versions.length ? versions.slice() : ['normal','large'] };
img.addEventListener('error', function(){
var st = img[IMG_FLAG];
if (!st) return;
if (st.vi < st.versions.length - 1){
st.vi += 1;
img.src = buildScryfallImageUrl(name, st.versions[st.vi], false);
} else if (!st.nocache){
st.nocache = 1;
img.src = buildScryfallImageUrl(name, st.versions[st.vi], true);
}
});
// If the initial load already failed before binding, try the next immediately
if (img.complete && img.naturalWidth === 0){
// If src corresponds to the first version, move to next; else, just force a reload
var st = img[IMG_FLAG];
var current = img.src || '';
var first = buildScryfallImageUrl(name, st.versions[0], false);
if (current.indexOf(encodeURIComponent(name)) !== -1 && current.indexOf('version='+st.versions[0]) !== -1){
st.vi = Math.min(1, st.versions.length - 1);
img.src = buildScryfallImageUrl(name, st.versions[st.vi], false);
} else {
// Re-trigger current request (may succeed if transient)
img.src = current;
}
}
} catch(_){}
}
function bindAllCardImageRetries(){
document.querySelectorAll('img[data-card-name]').forEach(function(img){
// Use thumbnail fallbacks for card-thumb, otherwise preview fallbacks
var versions = (img.classList && img.classList.contains('card-thumb')) ? ['small','normal','large'] : ['normal','large'];
bindCardImageRetry(img, versions);
});
}
function positionCard(e) {
var x = e.clientX + 16, y = e.clientY + 16;
cardPop.style.display = 'block';
@ -132,17 +217,32 @@
el.addEventListener('mouseenter', function(e) {
var img = cardPop.querySelector('img');
var meta = cardPop.querySelector('.card-meta');
var q = encodeURIComponent(el.getAttribute('data-card-name'));
img.src = 'https://api.scryfall.com/cards/named?fuzzy=' + q + '&format=image&version=normal';
var name = el.getAttribute('data-card-name') || '';
var vi = 0; // always start at 'normal' on hover
img.src = buildCardUrl(name, PREVIEW_VERSIONS[vi], false);
// Bind a one-off error handler per enter to try fallbacks
var triedNoCache = false;
function onErr(){
if (vi < PREVIEW_VERSIONS.length - 1){ vi += 1; img.src = buildCardUrl(name, PREVIEW_VERSIONS[vi], false); }
else if (!triedNoCache){ triedNoCache = true; img.src = buildCardUrl(name, PREVIEW_VERSIONS[vi], true); }
else { img.removeEventListener('error', onErr); }
}
img.addEventListener('error', onErr, { once:false });
img.addEventListener('load', function onOk(){ img.removeEventListener('load', onOk); img.removeEventListener('error', onErr); });
var role = el.getAttribute('data-role') || '';
var tags = el.getAttribute('data-tags') || '';
if (role || tags) {
var rawTags = el.getAttribute('data-tags') || '';
// Clean and split tags into an array; remove brackets and quotes
var tags = rawTags
.replace(/[\[\]\u2018\u2019'\u201C\u201D"]/g,'')
.split(/\s*,\s*/)
.filter(function(t){ return t && t.trim(); });
if (role || (tags && tags.length)) {
var html = '';
if (role) {
html += '<div class="line"><span class="label">Role</span>' + role.replace(/</g,'&lt;') + '</div>';
}
if (tags) {
html += '<div class="line"><span class="label">Themes</span>' + tags.replace(/</g,'&lt;') + '</div>';
if (tags && tags.length) {
html += '<div class="line"><span class="label themes-label">Themes</span><ul class="themes-list">' + tags.map(function(t){ return '<li>' + t.replace(/</g,'&lt;') + '</li>'; }).join('') + '</ul></div>';
}
meta.innerHTML = html;
meta.style.display = '';
@ -157,7 +257,8 @@
});
}
attachCardHover();
document.addEventListener('htmx:afterSwap', function() { attachCardHover(); });
bindAllCardImageRetries();
document.addEventListener('htmx:afterSwap', function() { attachCardHover(); bindAllCardImageRetries(); });
})();
</script>
<script src="/static/app.js?v=20250826-2"></script>

View file

@ -39,7 +39,7 @@
<form hx-post="/build/step1/confirm" hx-target="#wizard" hx-swap="innerHTML">
<input type="hidden" name="name" value="{{ name }}" />
<button class="img-btn" type="submit" title="Select {{ name }} (score {{ score }})">
<img src="https://api.scryfall.com/cards/named?fuzzy={{ name|urlencode }}&format=image&version=normal"
<img src="https://api.scryfall.com/cards/named?fuzzy={{ name|urlencode }}&format=image&version=normal" data-card-name="{{ name }}"
alt="{{ name }}" loading="lazy" decoding="async" />
</button>
</form>
@ -75,7 +75,7 @@
<div class="two-col two-col-left-rail">
<aside class="card-preview card-sm" data-card-name="{{ selected }}">
<a href="https://scryfall.com/search?q={{ selected|urlencode }}" target="_blank" rel="noopener">
<img src="https://api.scryfall.com/cards/named?fuzzy={{ selected|urlencode }}&format=image&version=normal" alt="{{ selected }} card image" />
<img src="https://api.scryfall.com/cards/named?fuzzy={{ selected|urlencode }}&format=image&version=normal" alt="{{ selected }} card image" data-card-name="{{ selected }}" />
</a>
</aside>
<div class="grow">

View file

@ -4,7 +4,7 @@
<div class="two-col two-col-left-rail">
<aside class="card-preview" data-card-name="{{ commander.name }}">
<a href="https://scryfall.com/search?q={{ commander.name|urlencode }}" target="_blank" rel="noopener">
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander.name|urlencode }}&format=image&version=normal" alt="{{ commander.name }} card image" />
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander.name|urlencode }}&format=image&version=normal" alt="{{ commander.name }} card image" data-card-name="{{ commander.name }}" />
</a>
</aside>
<div class="grow" data-skeleton>

View file

@ -4,7 +4,7 @@
<div class="two-col two-col-left-rail">
<aside class="card-preview" data-card-name="{{ commander|urlencode }}">
<a href="https://scryfall.com/search?q={{ commander|urlencode }}" target="_blank" rel="noopener">
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=normal" alt="{{ commander }} card image" />
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=normal" alt="{{ commander }} card image" data-card-name="{{ commander }}" />
</a>
</aside>
<div class="grow" data-skeleton>

View file

@ -4,7 +4,7 @@
<div class="two-col two-col-left-rail">
<aside class="card-preview" data-card-name="{{ commander|urlencode }}">
<a href="https://scryfall.com/search?q={{ commander|urlencode }}" target="_blank" rel="noopener">
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=normal" alt="{{ commander }} card image" />
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=normal" alt="{{ commander }} card image" data-card-name="{{ commander }}" />
</a>
</aside>
<div class="grow" data-skeleton>

View file

@ -4,7 +4,7 @@
<div class="two-col two-col-left-rail">
<aside class="card-preview">
<a href="https://scryfall.com/search?q={{ commander|urlencode }}" target="_blank" rel="noopener">
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=normal" alt="{{ commander }} card image" />
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=normal" alt="{{ commander }} card image" data-card-name="{{ commander }}" />
</a>
{% if status and status.startswith('Build complete') %}
<div style="margin-top:.75rem; display:flex; gap:.35rem; flex-wrap:wrap;">
@ -144,7 +144,7 @@
{% 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 '' }}" 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" />
<img src="https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=normal" alt="{{ c.name }} image" width="160" data-card-name="{{ c.name }}" />
</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>
@ -165,7 +165,7 @@
{% 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 '' }}" 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" />
<img src="https://api.scryfall.com/cards/named?fuzzy={{ c.name|urlencode }}&format=image&version=normal" alt="{{ c.name }} image" width="160" data-card-name="{{ c.name }}" />
</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>

View file

@ -10,7 +10,7 @@
<aside class="card-preview">
{% if commander %}
<a href="https://scryfall.com/search?q={{ commander|urlencode }}" target="_blank" rel="noopener">
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=normal" alt="{{ commander }} card image" width="320" />
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=normal" alt="{{ commander }} card image" width="320" data-card-name="{{ commander }}" />
</a>
{% endif %}
<div style="margin-top:.75rem; display:flex; gap:.35rem; flex-wrap:wrap;">

View file

@ -8,7 +8,7 @@
<div style="display:grid; grid-template-columns: 360px 1fr; gap: 1rem; align-items:start; margin-top: .75rem;">
<div>
{% if commander %}
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=normal" alt="{{ commander }}" style="width:320px; height:auto; border-radius:8px; border:1px solid var(--border); box-shadow: 0 6px 18px rgba(0,0,0,.55);" />
<img src="https://api.scryfall.com/cards/named?fuzzy={{ commander|urlencode }}&format=image&version=normal" alt="{{ commander }}" data-card-name="{{ commander }}" style="width:320px; height:auto; border-radius:8px; border:1px solid var(--border); box-shadow: 0 6px 18px rgba(0,0,0,.55);" />
<div class="muted" style="margin-top:.25rem;">Commander: <span data-card-name="{{ commander }}">{{ commander }}</span></div>
{% endif %}
<div style="margin-top:.75rem; display:flex; gap:.35rem; flex-wrap:wrap;">

View file

@ -0,0 +1,44 @@
{% extends "base.html" %}
{% block content %}
<section>
<h2>Diagnostics</h2>
<p class="muted">Use these tools to verify error handling surfaces.</p>
<div class="card" style="background:#0f1115; border:1px solid var(--border); border-radius:10px; padding:.75rem; margin-bottom:.75rem">
<h3 style="margin-top:0">System summary</h3>
<div id="sysSummary" class="muted">Loading…</div>
</div>
<div class="card" style="background:#0f1115; border:1px solid var(--border); border-radius:10px; padding:.75rem;">
<h3 style="margin-top:0">Error triggers</h3>
<div class="row" style="display:flex; gap:.5rem; align-items:center">
<button class="btn" hx-get="/diagnostics/trigger-error" hx-trigger="click" hx-target="this" hx-swap="none">Trigger HTTP error (418)</button>
<button class="btn" hx-get="/diagnostics/trigger-error?kind=unhandled" hx-trigger="click" hx-target="this" hx-swap="none">Trigger unhandled error (500)</button>
<small class="muted">You should see a toast and an inline banner with Request-ID.</small>
</div>
</div>
{% if show_logs %}
<p style="margin-top:.75rem"><a class="btn" href="/logs">Open Logs</a></p>
{% endif %}
</section>
<script>
(function(){
var el = document.getElementById('sysSummary');
function render(data){
if (!el) return;
try {
var v = (data && data.version) || 'dev';
var up = (data && data.uptime_seconds) || 0;
var st = (data && data.server_time_utc) || '';
var flags = (data && data.flags) || {};
el.innerHTML = '<div><strong>Version:</strong> '+String(v)+'</div>'+
(st ? '<div><strong>Server time (UTC):</strong> '+String(st)+'</div>' : '')+
'<div><strong>Uptime:</strong> '+String(up)+'s</div>'+
'<div><strong>Flags:</strong> SHOW_LOGS='+ (flags.SHOW_LOGS? '1':'0') +', SHOW_DIAGNOSTICS='+ (flags.SHOW_DIAGNOSTICS? '1':'0') +', SHOW_SETUP='+ (flags.SHOW_SETUP? '1':'0') +'</div>';
} catch(_){ el.textContent = 'Unavailable'; }
}
function load(){
try { fetch('/status/sys', { cache: 'no-store' }).then(function(r){ return r.json(); }).then(render).catch(function(){ el.textContent='Unavailable'; }); } catch(_){ el.textContent='Unavailable'; }
}
load();
})();
</script>
{% endblock %}

View file

@ -0,0 +1,85 @@
{% extends "base.html" %}
{% block content %}
<section>
<h2>Logs</h2>
<form method="get" action="/logs" class="form-row" style="gap:.5rem; align-items: center;">
<label>Tail <input type="number" name="tail" value="{{ tail }}" min="1" max="500" style="width:80px"></label>
<label>Filter <input type="text" name="q" value="{{ q }}" placeholder="keyword"></label>
<label>Level
<select name="level" id="levelSel">
{% set _lvl = (level or 'all') %}
<option value="all" {% if _lvl=='all' %}selected{% endif %}>All</option>
<option value="error" {% if _lvl=='error' %}selected{% endif %}>Error</option>
<option value="warning" {% if _lvl=='warning' %}selected{% endif %}>Warning</option>
<option value="info" {% if _lvl=='info' %}selected{% endif %}>Info</option>
<option value="debug" {% if _lvl=='debug' %}selected{% endif %}>Debug</option>
</select>
</label>
<button class="btn" type="submit">Refresh</button>
<button class="btn" type="button" id="copyLogsBtn" title="Copy visible logs">Copy</button>
<label style="margin-left:1rem; display:inline-flex; align-items:center; gap:.35rem">
<input type="checkbox" id="autoRefreshLogs" data-pref="logs:auto" /> Auto-refresh
</label>
<label style="display:inline-flex; align-items:center; gap:.35rem">
every <input type="number" id="autoRefreshInterval" value="3" min="1" max="30" style="width:60px"> s
</label>
</form>
<pre id="logTail" class="log-tail" data-tail="{{ tail }}" data-q="{{ q }}" data-level="{{ level or 'all' }}" style="white-space: pre-wrap; background:#0f1115; color:#e5e7eb; border:1px solid var(--border); border-radius:8px; padding:.75rem; margin-top:.75rem; max-height:60vh; overflow:auto">{{ lines | join('') }}</pre>
<script>
(function(){
var pre = document.getElementById('logTail');
var autoCb = document.getElementById('autoRefreshLogs');
var intervalInput = document.getElementById('autoRefreshInterval');
// hydrate from saved pref
try { var saved = window.__mtgState && window.__mtgState.get('logs:auto', false); if (typeof saved === 'boolean') autoCb.checked = saved; } catch(_){ }
var params = new URLSearchParams(window.location.search);
var tailAttr = (pre && pre.getAttribute('data-tail')) || '200';
var qAttr = (pre && pre.getAttribute('data-q')) || '';
var levelAttr = (pre && pre.getAttribute('data-level')) || 'all';
var tail = parseInt(params.get('tail') || tailAttr, 10) || parseInt(tailAttr, 10) || 200;
var q = params.get('q') || qAttr;
var level = params.get('level') || levelAttr;
var timer = null;
function fetchLogs(){
try {
var url = '/status/logs?tail='+encodeURIComponent(String(tail));
if (q) url += '&q='+encodeURIComponent(q);
if (level && level !== 'all') url += '&level='+encodeURIComponent(level);
fetch(url, { cache: 'no-store' })
.then(function(r){ return r.json(); })
.then(function(data){ if (pre && data && data.lines){ pre.textContent = (data.lines||[]).join(''); pre.scrollTop = pre.scrollHeight; } });
} catch(e){}
}
function start(){
stop();
var sec = parseInt(intervalInput.value||'3', 10); if (isNaN(sec) || sec < 1) sec = 3; if (sec > 30) sec = 30;
timer = setInterval(fetchLogs, sec * 1000);
fetchLogs();
}
function stop(){ if (timer){ clearInterval(timer); timer = null; } }
autoCb.addEventListener('change', function(){
try { window.__mtgState && window.__mtgState.set('logs:auto', !!autoCb.checked); } catch(_){ }
if (autoCb.checked) start(); else stop();
});
intervalInput.addEventListener('change', function(){ if (autoCb.checked) start(); });
if (autoCb.checked) start();
var levelSel = document.getElementById('levelSel');
if (levelSel){ levelSel.addEventListener('change', function(){ if (autoCb.checked) fetchLogs(); }); }
// Copy button
var copyBtn = document.getElementById('copyLogsBtn');
function copyText(text){
try { navigator.clipboard.writeText(text); return true; } catch(_) {
try {
var ta = document.createElement('textarea'); ta.value = text; ta.style.position='fixed'; ta.style.opacity='0'; document.body.appendChild(ta); ta.select(); var ok = document.execCommand('copy'); ta.remove(); return ok; } catch(__){ return false; }
}
}
if (copyBtn){
copyBtn.addEventListener('click', function(){
var ok = copyText(pre ? pre.textContent || '' : '');
if (ok){ if (window.toast) window.toast('Copied logs'); copyBtn.textContent = 'Copied'; setTimeout(function(){ copyBtn.textContent='Copy'; }, 1200); }
});
}
})();
</script>
</section>
{% endblock %}

View file

@ -0,0 +1,13 @@
{% extends "base.html" %}
{% block content %}
<section>
<h2>Page not found</h2>
<p>The page you requested could not be found.</p>
<p class="muted">Request ID: <code>{{ request_id or request.state.request_id }}</code></p>
<p><a class="btn" href="/">Go home</a></p>
<details>
<summary>Details</summary>
<pre>Status: {{ status }}
Path: {{ request.url.path }}</pre>
</details>
{% endblock %}

View file

@ -0,0 +1,13 @@
{% extends "base.html" %}
{% block content %}
<section>
<h2>Error {{ status }}</h2>
<p>{{ detail }}</p>
<p class="muted">Request ID: <code>{{ request_id or request.state.request_id }}</code></p>
<p><a class="btn" href="/">Go home</a></p>
<details>
<summary>Details</summary>
<pre>Status: {{ status }}
Path: {{ request.url.path }}</pre>
</details>
{% endblock %}

View file

@ -0,0 +1,8 @@
{% extends "base.html" %}
{% block content %}
<section>
<h2>Internal Server Error</h2>
<p>Something went wrong.</p>
<p class="muted">Request ID: <code>{{ request_id or request.state.request_id }}</code></p>
<p><a class="btn" href="/">Go home</a></p>
{% endblock %}

View file

@ -5,6 +5,7 @@
<a class="action-button primary" href="/build">Build a Deck</a>
<a class="action-button" href="/configs">Run a JSON Config</a>
{% if show_setup %}<a class="action-button" href="/setup">Initial Setup</a>{% endif %}
<a class="action-button" href="/owned">Owned Library</a>
<a class="action-button" href="/decks">Finished Decks</a>
{% if show_logs %}<a class="action-button" href="/logs">View Logs</a>{% endif %}
</div>

View file

@ -78,27 +78,22 @@
{% set cols = (colors_by_name.get(n, []) if colors_by_name else []) %}
{% 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 class="owned-row" style="cursor:pointer;" tabindex="0">
<input type="checkbox" class="sel sr-only" aria-label="Select {{ n }}" />
<div class="owned-vstack">
<img class="card-thumb" loading="lazy" alt="{{ n }} image" src="https://api.scryfall.com/cards/named?fuzzy={{ n|urlencode }}&format=image&version=small" data-card-name="{{ n }}" {% if tags %}data-tags="{{ (tags or [])|join(', ') }}"{% endif %} />
<span class="card-name"{% if tags %} data-tags="{{ (tags or [])|join(', ') }}"{% endif %}>{{ n }}</span>
{% if cols and cols|length %}
<div class="mana-group" aria-hidden="true">
{% for c in cols %}
<span class="mana mana-{{ c }}" title="{{ c }}"></span>
{% endfor %}
</div>
<span class="sr-only"> Colors: {{ cols|join(', ') }}</span>
{% endif %}
</div>
</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 %}
<span class="mana mana-{{ c }}" title="{{ c }}"></span>
{% endfor %}
</span>
<span class="sr-only"> Colors: {{ cols|join(', ') }}</span>
{% endif %}
</li>
{% endfor %}
</ul>
@ -127,7 +122,7 @@
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 = {
@ -137,6 +132,14 @@
readHash: function(){ try{ return new URLSearchParams((location.hash||'').replace(/^#/,'')); }catch(e){ return new URLSearchParams(); } }
};
// Helper: build Scryfall image URL with optional cache-busting
function buildImageUrl(name, version, nocache){
var q = encodeURIComponent(name||'');
var url = 'https://api.scryfall.com/cards/named?fuzzy=' + q + '&format=image&version=' + (version||'small');
if (nocache) url += '&t=' + Date.now();
return url;
}
// Resize the container to fill the viewport height
function sizeBox(){
if (!box) return;
@ -236,25 +239,7 @@
});
}
// 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'); }); });
})();
// Bulk user-tag add/remove controls removed by request; inline chip removal remains supported.
function resort(){
if (!fSort) return;
@ -308,6 +293,11 @@
selAll.checked = (vis.length>0 && selected.length === vis.length);
selAll.indeterminate = (selected.length>0 && selected.length < vis.length);
}
// Toggle selected class for visual feedback
Array.prototype.forEach.call(grid.children, function(li){
var cb = li.querySelector('input.sel');
li.classList.toggle('is-selected', !!(cb && cb.checked));
});
}
if (selAll){
@ -318,6 +308,15 @@
});
}
grid.addEventListener('change', function(e){ if (e.target && e.target.classList && e.target.classList.contains('sel')) updateSelectedState(); });
// Keyboard: allow Enter/Space on the row to toggle selection
grid.addEventListener('keydown', function(e){
if (!(e.key === 'Enter' || e.key === ' ')) return;
var row = e.target && e.target.closest && e.target.closest('label.owned-row');
if (!row) return;
e.preventDefault();
var cb = row.querySelector('input.sel');
if (cb){ cb.checked = !cb.checked; cb.dispatchEvent(new Event('change', { bubbles:true })); }
});
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){
@ -362,19 +361,9 @@
// Initial state
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'); });
});
// Thumbnail retry now handled by global binder in base.html
// User tag chip UI removed by request.
})();
</script>
<style>
@ -393,5 +382,14 @@
#owned-box::-webkit-scrollbar-track{ background: transparent; }
#owned-box::-webkit-scrollbar-thumb{ background-color: rgba(148,163,184,.35); border-radius:8px; }
#owned-box:hover::-webkit-scrollbar-thumb{ background-color: rgba(148,163,184,.6); }
/* Owned item layout */
#owned-grid{ justify-items:center; }
.owned-row{ display:flex; align-items:center; justify-content:center; gap:.5rem; border:1px solid transparent; border-radius:8px; padding:.5rem; width:100%; max-width:200px; margin:0 auto; }
.owned-vstack{ display:flex; flex-direction:column; gap:.25rem; align-items:center; text-align:center; }
.card-thumb{ display:block; width:100px; height:auto; border-radius:6px; border:1px solid var(--border); background:#0b0d12; object-fit:cover; }
/* Highlight only the thumbnail when selected */
li.is-selected .card-thumb{ border-color:#ffffff; box-shadow:0 0 0 3px rgba(255,255,255,.35); }
.mana-group{ display:flex; gap:4px; justify-content:center; }
.card-name{ display:block; }
</style>
{% endblock %}

View file

@ -40,14 +40,25 @@
</div>
{% set clist = tb.cards.get(t, []) %}
{% if clist %}
<div style="display:grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap:.35rem .75rem; margin:.25rem 0 .75rem 0;">
<style>
.list-grid { display:grid; grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); gap:.35rem .75rem; margin:.25rem 0 .75rem 0; }
@media (max-width: 1199px) {
.list-grid { grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); }
}
.list-row { display:grid; grid-template-columns: 4ch 1.25ch minmax(0,1fr) 1.6em; align-items:center; column-gap:.45rem; width:100%; }
.list-row .count { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-variant-numeric: tabular-nums; font-feature-settings: 'tnum'; text-align:right; color:#94a3b8; }
.list-row .times { color:#94a3b8; text-align:center; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
.list-row .name { display:inline-block; padding: 2px 4px; border-radius: 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.list-row .owned-flag { width: 1.6em; min-width: 1.6em; text-align:center; display:inline-block; }
</style>
<div class="list-grid">
{% for c in clist %}
<div class="{% if (game_changers and (c.name in game_changers)) or ('game_changer' in (c.role or '') or 'Game Changer' in (c.role or '')) %}game-changer{% endif %}">
<div class="list-row {% if (game_changers and (c.name in game_changers)) or ('game_changer' in (c.role or '') or 'Game Changer' in (c.role or '')) %}game-changer{% endif %}">
{% set cnt = c.count if c.count else 1 %}
{% set owned = (owned_set is defined and c.name and (c.name|lower in owned_set)) %}
<span data-card-name="{{ c.name }}" data-count="{{ cnt }}" data-role="{{ c.role }}" data-tags="{{ (c.tags|join(', ')) if c.tags else '' }}">
{{ cnt }}x {{ c.name }}
</span>
<span class="count">{{ cnt }}</span>
<span class="times">x</span>
<span class="name" title="{{ c.name }}" data-card-name="{{ c.name }}" data-count="{{ cnt }}" data-role="{{ c.role }}" data-tags="{{ (c.tags|join(', ')) if c.tags else '' }}">{{ c.name }}</span>
<span class="owned-flag" title="{{ 'Owned' if owned else 'Not owned' }}" aria-label="{{ 'Owned' if owned else 'Not owned' }}">{% if owned %}✔{% else %}✖{% endif %}</span>
</div>
{% endfor %}
@ -375,9 +386,9 @@
<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; }
.chart-highlight { border-radius: 6px; background: rgba(245,158,11,.08); box-shadow: 0 0 0 2px #f59e0b inset; }
/* For list view, ensure baseline padding so no layout shift on highlight */
#typeview-list .list-row .name { 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>