diff --git a/CHANGELOG.md b/CHANGELOG.md index eba83c1..403d9e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 --- diff --git a/DOCKER.md b/DOCKER.md index ca58875..108362d 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -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. diff --git a/README.md b/README.md index ddba19a..e9375cd 100644 Binary files a/README.md and b/README.md differ diff --git a/RELEASE_NOTES_TEMPLATE.md b/RELEASE_NOTES_TEMPLATE.md index c473606..fa2475f 100644 --- a/RELEASE_NOTES_TEMPLATE.md +++ b/RELEASE_NOTES_TEMPLATE.md @@ -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 doesn’t 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`. ## What’s 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. diff --git a/code/tests/test_diagnostics.py b/code/tests/test_diagnostics.py new file mode 100644 index 0000000..2ac21dc --- /dev/null +++ b/code/tests/test_diagnostics.py @@ -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 diff --git a/code/web/app.py b/code/web/app.py index 054e1bc..8c5e0fd 100644 --- a/code/web/app.py +++ b/code/web/app.py @@ -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}) diff --git a/code/web/routes/owned.py b/code/web/routes/owned.py index e29145a..8f736f8 100644 --- a/code/web/routes/owned.py +++ b/code/web/routes/owned.py @@ -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. diff --git a/code/web/services/owned_store.py b/code/web/services/owned_store.py index eab044b..76fa313 100644 --- a/code/web/services/owned_store.py +++ b/code/web/services/owned_store.py @@ -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]: diff --git a/code/web/static/app.js b/code/web/static/app.js index 6aad0f9..8b3223e 100644 --- a/code/web/static/app.js +++ b/code/web/static/app.js @@ -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 = ''+ + '
'+ + ''+String(msg)+''+ (status? ' ('+status+')' : '')+ + (rid ? '' : '')+ + '
'+ + (rid ? '
Request-ID: '+rid+'
' : ''); + 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 = ''+String(msg)+'' + (rid? ' (Request-ID: '+rid+')' : ''); + surface.prepend(banner); + setTimeout(function(){ banner.remove(); }, 8000); + } + } catch(_){ } }); document.addEventListener('htmx:sendError', function(){ toast('Network error', 'error', { duration: 4000 }); }); diff --git a/code/web/static/styles.css b/code/web/static/styles.css index 08715d5..c1c7940 100644 --- a/code/web/static/styles.css +++ b/code/web/static/styles.css @@ -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; } diff --git a/code/web/templates/base.html b/code/web/templates/base.html index 23f95d7..0971445 100644 --- a/code/web/templates/base.html +++ b/code/web/templates/base.html @@ -15,7 +15,10 @@

MTG Deckbuilder

- +
+ + +
@@ -36,10 +39,11 @@ {% if show_setup %}Setup/Tag{% endif %} Owned Library Finished Decks + {% if show_diagnostics %}Diagnostics{% endif %} {% if show_logs %}Logs{% endif %} -
+
{% block content %}{% endblock %}
@@ -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 + 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 += '
Role' + role.replace(/'; } - if (tags) { - html += '
Themes' + tags.replace(/'; + if (tags && tags.length) { + html += '
Themes
    ' + tags.map(function(t){ return '
  • ' + t.replace(/'; }).join('') + '
'; } 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(); }); })(); diff --git a/code/web/templates/build/_step1.html b/code/web/templates/build/_step1.html index 00a0f26..10267ac 100644 --- a/code/web/templates/build/_step1.html +++ b/code/web/templates/build/_step1.html @@ -39,7 +39,7 @@
@@ -75,7 +75,7 @@
diff --git a/code/web/templates/build/_step2.html b/code/web/templates/build/_step2.html index e09a6f1..d7830c8 100644 --- a/code/web/templates/build/_step2.html +++ b/code/web/templates/build/_step2.html @@ -4,7 +4,7 @@
diff --git a/code/web/templates/build/_step3.html b/code/web/templates/build/_step3.html index 35805a0..f6399cd 100644 --- a/code/web/templates/build/_step3.html +++ b/code/web/templates/build/_step3.html @@ -4,7 +4,7 @@
diff --git a/code/web/templates/build/_step4.html b/code/web/templates/build/_step4.html index 7130938..29e8b71 100644 --- a/code/web/templates/build/_step4.html +++ b/code/web/templates/build/_step4.html @@ -4,7 +4,7 @@
diff --git a/code/web/templates/build/_step5.html b/code/web/templates/build/_step5.html index 58e4d39..5416079 100644 --- a/code/web/templates/build/_step5.html +++ b/code/web/templates/build/_step5.html @@ -4,7 +4,7 @@