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

@ -33,6 +33,10 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
- Source detection: include non-land mana producers and colorless 'C'; basic lands reliably counted; fetch lands excluded as sources
- Favicon support: `/favicon.ico` served (ICO with PNG fallback)
- Diagnostics: `/healthz` endpoint returns `{status, version, uptime_seconds}`; responses carry `X-Request-ID`; unhandled errors return JSON with request_id
- Diagnostics page and tools gated by `SHOW_DIAGNOSTICS`; Logs page gated by `SHOW_LOGS`; both off by default
- Global error handling: friendly HTML templates for 404/4xx/500 with Request-ID and "Go home" link; JSON structure for HTMX/API
- Request-ID middleware assigns `X-Request-ID` to all responses and includes it in JSON error payloads
- `/status/logs?tail=N` endpoint (read-only) to fetch a recent log tail for quick diagnostics
- Tooltip Copy action on chart tooltips (Pips/Sources) for quick sharing of per-color card lists
### Changed
@ -49,6 +53,10 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
- Deck summary reporting now includes colorless 'C' in totals and cards; UI adds a Show C toggle for Sources
- List view highlight polished to wrap only the card name (no overrun of the row)
- Total sources calculation updated to include 'C' properly
- 404s from Starlette now render the HTML 404 page when requested from a browser (Accept: text/html)
- Owned page UX: full-size preview now pops on thumbnail hover (not the name); selection highlight tightened to the thumbnail only and changed to white for better contrast; Themes in the hover popout render as a larger bullet list with a brighter "THEMES" label
- Image robustness: standardized `data-card-name` on all Scryfall images and centralized retry logic (thumbnails + previews) with version fallbacks (small/normal/large) and a single cache-bust refresh on final failure; removed the previous hover-image cache to reduce complexity and overhead
- Deck Summary list view: rows use fixed tracks for count, ×, name, and owned columns (monospace tabular numerals) to ensure perfect alignment; highlight is an inset box-shadow on the name to avoid layout shifts; long names ellipsize with a tooltip; list starts directly under the type header and remains stable on full-screen widths
### Fixed
- Docker Hub workflow no longer publishes a `major.minor` tag (e.g., `1.1`); only full semver (e.g., `1.2.3`) and `latest`
@ -57,6 +65,8 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
- Source highlighting consistency: charts now correctly cross-highlight corresponding cards in both list and thumbnail views
- Basics handling: ensured basic lands and Wastes are recognized as sources; added fallback oracle text for basics in CSV export
- Fetch lands are no longer miscounted as mana sources
- Web 404s previously returned JSON to browsers in some cases; now correctly render HTML via a Starlette HTTPException handler
- Deck summary alignment issues in some sections (e.g., Enchantments) fixed by splitting the count and the × into separate columns and pinning the owned flag to a fixed width; prevents drift across responsive breakpoints
---

View file

@ -27,8 +27,7 @@ The web UI runs the same deckbuilding logic behind a browser-based interface.
### PowerShell (recommended)
```powershell
docker compose build web
docker compose up --no-deps web
docker compose up --build --no-deps -d web
```
Then open http://localhost:8080
@ -36,6 +35,37 @@ Then open http://localhost:8080
Volumes are the same as the CLI service, so deck exports/logs/configs persist in your working folder.
The app serves a favicon at `/favicon.ico` and exposes a health endpoint at `/healthz`.
### Diagnostics and logs (optional)
Enable internal diagnostics and a read-only logs viewer with environment flags.
- `SHOW_DIAGNOSTICS=1` — adds a Diagnostics nav link and `/diagnostics` tools
- `SHOW_LOGS=1` — enables `/logs` and `/status/logs?tail=200`
When enabled:
- `/logs` supports an auto-refresh toggle with interval, a level filter (All/Error/Warning/Info/Debug), and a Copy button to copy the visible tail.
- `/status/sys` returns a simple system summary (version, uptime, UTC server time, and feature flags) and is shown on the Diagnostics page when `SHOW_DIAGNOSTICS=1`.
Compose example (web service):
```yaml
environment:
- SHOW_LOGS=1
- SHOW_DIAGNOSTICS=1
```
Docker Hub (PowerShell) example:
```powershell
docker run --rm `
-p 8080:8080 `
-e SHOW_LOGS=1 -e SHOW_DIAGNOSTICS=1 `
-v "${PWD}/deck_files:/app/deck_files" `
-v "${PWD}/logs:/app/logs" `
-v "${PWD}/csv_files:/app/csv_files" `
-v "${PWD}/owned_cards:/app/owned_cards" `
-v "${PWD}/config:/app/config" `
mwisnowski/mtg-python-deckbuilder:latest `
bash -lc "cd /app && uvicorn code.web.app:app --host 0.0.0.0 --port 8080"
```
### Setup speed: parallel tagging (Web)
First-time setup or stale data triggers card tagging. The web service uses parallel workers by default.

BIN
README.md

Binary file not shown.

View file

@ -13,6 +13,15 @@
- Prefer-owned option in the Web UI Review step prioritizes owned cards while allowing unowned fallback; applied across creatures and spells with stable reordering and gentle weight boosts.
- Owned page: export TXT/CSV, sort controls, live "N shown," color identity dots, exact color-identity combo filters (incl. 4-color), viewport-filling list, and scrollbar styling. Upload-time enrichment and de-duplication speeds up page loads.
- Staged build visibility: optional "Show skipped stages" reveals phases that added no cards with a clear annotation.
- Owned page UX: hover preview now triggers from the thumbnail, not the name; selection outline is restricted to the thumbnail and uses white for clarity; hover popout shows Themes as a larger bullet list with a bright label.
- Image robustness: all Scryfall images include `data-card-name` and participate in centralized retry (version fallback + one cache-bust) for thumbnails and previews.
- Deck Summary: aligned text-mode list (fixed columns for count/×/name/owned), highlight that doesnt shift layout, and tooltips for truncated names. The list begins directly under each type header for better scanability.
### Diagnostics and error handling
- Health endpoint `/healthz` returns `{ status, version, uptime_seconds }`.
- All responses include `X-Request-ID`; JSON error payloads include `request_id` for correlation.
- Friendly HTML error pages for 404/4xx/500 with a "Go home" link (browser requests).
- Feature flags: `SHOW_DIAGNOSTICS=1` to enable a diagnostics page with test tools; `SHOW_LOGS=1` to enable a logs page and `/status/logs?tail=N`.
## Whats new
- Web UI: Staged run with a new "Creatures: All-Theme" phase in AND mode; shows matched selected themes per card for explainability. Step 2 UI clarifies AND/OR with a tooltip and restyled Why panel.
@ -80,6 +89,8 @@ docker compose up --no-deps web
- Review step consolidates owned-only and prefer-owned controls; Step 5 is status-only with an "Edit in Review" link for changes.
- Owned lists processing moved to upload-time in Web; per-request parsing removed. Enriched store powers fast Owned page and deck-building.
- Finished Decks page uses a dropdown theme filter with shareable state.
- Global image retry binding for all card images (thumbnails and previews), with no JS hover cache to minimize memory and complexity.
- Deck Summary fixes: separated count and × into distinct columns, fixed-width owned indicator, and responsive stability at fullscreen widths.
### Tagging updates
- Explore/Map: treat "+1/+1 counter" as a literal; Explore adds Card Selection and may add +1/+1 Counters; Map adds Card Selection and Tokens Matter.

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>

View file

@ -32,6 +32,9 @@ services:
- PYTHONUNBUFFERED=1
- TERM=xterm-256color
- DEBIAN_FRONTEND=noninteractive
# Logging and error utilities
# - SHOW_LOGS=1
# - SHOW_DIAGNOSTICS=1
# Speed up setup/tagging in Web UI via parallel workers
- WEB_TAG_PARALLEL=1
- WEB_TAG_WORKERS=4

6
pytest.ini Normal file
View file

@ -0,0 +1,6 @@
[pytest]
minversion = 7.0
# Disable built-in debugging plugin to avoid importing stdlib 'code' module,
# which conflicts with our package named 'code/'.
addopts = -q -p no:debugging
testpaths = code/tests

View file

@ -0,0 +1,35 @@
@echo off
setlocal ENABLEDELAYEDEXPANSION
echo MTG Python Deckbuilder - Web UI (Docker Hub)
echo ============================================
REM Create directories if they don't exist
if not exist "deck_files" mkdir deck_files
if not exist "logs" mkdir logs
if not exist "csv_files" mkdir csv_files
if not exist "config" mkdir config
if not exist "owned_cards" mkdir owned_cards
REM Flags (override by setting env vars before running)
if "%SHOW_LOGS%"=="" set SHOW_LOGS=1
if "%SHOW_DIAGNOSTICS%"=="" set SHOW_DIAGNOSTICS=1
echo Starting Web UI on http://localhost:8080
printf Flags: SHOW_LOGS=%SHOW_LOGS% SHOW_DIAGNOSTICS=%SHOW_DIAGNOSTICS%
docker run --rm ^
-p 8080:8080 ^
-e SHOW_LOGS=%SHOW_LOGS% -e SHOW_DIAGNOSTICS=%SHOW_DIAGNOSTICS% ^
-v "%cd%\deck_files:/app/deck_files" ^
-v "%cd%\logs:/app/logs" ^
-v "%cd%\csv_files:/app/csv_files" ^
-v "%cd%\owned_cards:/app/owned_cards" ^
-v "%cd%\config:/app/config" ^
mwisnowski/mtg-python-deckbuilder:latest ^
bash -lc "cd /app && uvicorn code.web.app:app --host 0.0.0.0 --port 8080"
echo.
echo Open: http://localhost:8080
echo Tip: set SHOW_LOGS=0 or SHOW_DIAGNOSTICS=0 before running to hide those pages.
endlocal

30
run-web-from-dockerhub.sh Normal file
View file

@ -0,0 +1,30 @@
#!/bin/bash
set -euo pipefail
echo "MTG Python Deckbuilder - Web UI (Docker Hub)"
echo "==========================================="
# Create directories if they don't exist
mkdir -p deck_files logs csv_files config owned_cards
# Flags (override by exporting before running)
: "${SHOW_LOGS:=1}"
: "${SHOW_DIAGNOSTICS:=1}"
echo "Starting Web UI on http://localhost:8080"
echo "Flags: SHOW_LOGS=${SHOW_LOGS} SHOW_DIAGNOSTICS=${SHOW_DIAGNOSTICS}"
docker run --rm \
-p 8080:8080 \
-e SHOW_LOGS=${SHOW_LOGS} -e SHOW_DIAGNOSTICS=${SHOW_DIAGNOSTICS} \
-v "$(pwd)/deck_files:/app/deck_files" \
-v "$(pwd)/logs:/app/logs" \
-v "$(pwd)/csv_files:/app/csv_files" \
-v "$(pwd)/owned_cards:/app/owned_cards" \
-v "$(pwd)/config:/app/config" \
mwisnowski/mtg-python-deckbuilder:latest \
bash -lc "cd /app && uvicorn code.web.app:app --host 0.0.0.0 --port 8080"
echo
echo "Open: http://localhost:8080"
echo "Tip: export SHOW_LOGS=0 or SHOW_DIAGNOSTICS=0 to hide those pages."