mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-09-21 20:40:47 +02:00
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:
parent
8d1f6a8ac4
commit
f8c6b5c07e
30 changed files with 786 additions and 232 deletions
10
CHANGELOG.md
10
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
|
||||
|
||||
---
|
||||
|
||||
|
|
34
DOCKER.md
34
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.
|
||||
|
||||
|
|
BIN
README.md
BIN
README.md
Binary file not shown.
|
@ -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.
|
||||
|
|
82
code/tests/test_diagnostics.py
Normal file
82
code/tests/test_diagnostics.py
Normal 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
|
183
code/web/app.py
183
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})
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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]:
|
||||
|
|
|
@ -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 }); });
|
||||
|
||||
|
|
|
@ -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; }
|
||||
|
|
|
@ -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,'<') + '</div>';
|
||||
}
|
||||
if (tags) {
|
||||
html += '<div class="line"><span class="label">Themes</span>' + tags.replace(/</g,'<') + '</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,'<') + '</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>
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;">
|
||||
|
|
|
@ -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;">
|
||||
|
|
44
code/web/templates/diagnostics/index.html
Normal file
44
code/web/templates/diagnostics/index.html
Normal 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 %}
|
85
code/web/templates/diagnostics/logs.html
Normal file
85
code/web/templates/diagnostics/logs.html
Normal 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 %}
|
13
code/web/templates/errors/404.html
Normal file
13
code/web/templates/errors/404.html
Normal 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 %}
|
13
code/web/templates/errors/4xx.html
Normal file
13
code/web/templates/errors/4xx.html
Normal 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 %}
|
8
code/web/templates/errors/500.html
Normal file
8
code/web/templates/errors/500.html
Normal 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 %}
|
|
@ -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>
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
6
pytest.ini
Normal 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
|
35
run-web-from-dockerhub.bat
Normal file
35
run-web-from-dockerhub.bat
Normal 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
30
run-web-from-dockerhub.sh
Normal 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."
|
Loading…
Add table
Add a link
Reference in a new issue