mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-09-21 20:40:47 +02:00
Web UI: setup progress + logs folding, Finished Decks library, commander search UX (debounce, keyboard, highlights, color chips), ranking fixes (first-word priority, substring include), optional auto-select; setup start reliability (POST+GET), force runs, status with percent/ETA/timestamps; stepwise builder with added stage reporting and sidecar summaries; keyboard grid wrap-around; restrict commander search to eligible rows
This commit is contained in:
parent
8fa040a05a
commit
0f73a85a4e
43 changed files with 4515 additions and 105 deletions
118
code/web/app.py
Normal file
118
code/web/app.py
Normal file
|
@ -0,0 +1,118 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import HTMLResponse, FileResponse, PlainTextResponse, JSONResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pathlib import Path
|
||||
import os
|
||||
import json as _json
|
||||
|
||||
# Resolve template/static dirs relative to this file
|
||||
_THIS_DIR = Path(__file__).resolve().parent
|
||||
_TEMPLATES_DIR = _THIS_DIR / "templates"
|
||||
_STATIC_DIR = _THIS_DIR / "static"
|
||||
|
||||
app = FastAPI(title="MTG Deckbuilder Web UI")
|
||||
|
||||
# Mount static if present
|
||||
if _STATIC_DIR.exists():
|
||||
app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static")
|
||||
|
||||
# Jinja templates
|
||||
templates = Jinja2Templates(directory=str(_TEMPLATES_DIR))
|
||||
|
||||
# Global template flags (env-driven)
|
||||
def _as_bool(val: str | None, default: bool = False) -> bool:
|
||||
if val is None:
|
||||
return default
|
||||
return val.strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
SHOW_LOGS = _as_bool(os.getenv("SHOW_LOGS"), False)
|
||||
SHOW_SETUP = _as_bool(os.getenv("SHOW_SETUP"), True)
|
||||
|
||||
# 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,
|
||||
})
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def home(request: Request) -> HTMLResponse:
|
||||
return templates.TemplateResponse("home.html", {"request": request, "version": os.getenv("APP_VERSION", "dev")})
|
||||
|
||||
|
||||
# Simple health check
|
||||
@app.get("/healthz")
|
||||
async def healthz():
|
||||
return {"status": "ok"}
|
||||
|
||||
# Lightweight setup/tagging status endpoint
|
||||
@app.get("/status/setup")
|
||||
async def setup_status():
|
||||
try:
|
||||
p = Path("csv_files/.setup_status.json")
|
||||
if p.exists():
|
||||
with p.open("r", encoding="utf-8") as f:
|
||||
data = _json.load(f)
|
||||
# Attach a small log tail if available
|
||||
try:
|
||||
log_path = Path('logs/deck_builder.log')
|
||||
if log_path.exists():
|
||||
tail_lines = []
|
||||
with log_path.open('r', encoding='utf-8', errors='ignore') as lf:
|
||||
# Read last ~100 lines efficiently
|
||||
from collections import deque
|
||||
tail = deque(lf, maxlen=100)
|
||||
tail_lines = list(tail)
|
||||
# Reduce noise: keep lines related to setup/tagging; fallback to last 30 if too few remain
|
||||
try:
|
||||
lowered = [ln for ln in tail_lines]
|
||||
keywords = ["setup", "tag", "color", "csv", "initial setup", "tagging", "load_dataframe"]
|
||||
filtered = [ln for ln in lowered if any(kw in ln.lower() for kw in keywords)]
|
||||
if len(filtered) >= 5:
|
||||
use_lines = filtered[-60:]
|
||||
else:
|
||||
use_lines = tail_lines[-30:]
|
||||
data["log_tail"] = "".join(use_lines).strip()
|
||||
except Exception:
|
||||
data["log_tail"] = "".join(tail_lines).strip()
|
||||
except Exception:
|
||||
pass
|
||||
return JSONResponse(data)
|
||||
return JSONResponse({"running": False, "phase": "idle"})
|
||||
except Exception:
|
||||
return JSONResponse({"running": False, "phase": "error"})
|
||||
|
||||
# Routers
|
||||
from .routes import build as build_routes # noqa: E402
|
||||
from .routes import configs as config_routes # noqa: E402
|
||||
from .routes import decks as decks_routes # noqa: E402
|
||||
from .routes import setup as setup_routes # noqa: E402
|
||||
app.include_router(build_routes.router)
|
||||
app.include_router(config_routes.router)
|
||||
app.include_router(decks_routes.router)
|
||||
app.include_router(setup_routes.router)
|
||||
|
||||
# Lightweight file download endpoint for exports
|
||||
@app.get("/files")
|
||||
async def get_file(path: str):
|
||||
try:
|
||||
p = Path(path)
|
||||
if not p.exists() or not p.is_file():
|
||||
return PlainTextResponse("File not found", status_code=404)
|
||||
# Only allow returning files within the workspace directory for safety
|
||||
# (best-effort: require relative to current working directory)
|
||||
try:
|
||||
cwd = Path.cwd().resolve()
|
||||
if cwd not in p.resolve().parents and p.resolve() != cwd:
|
||||
# Still allow if under deck_files or config
|
||||
allowed = any(seg in ("deck_files", "config", "logs") for seg in p.parts)
|
||||
if not allowed:
|
||||
return PlainTextResponse("Access denied", status_code=403)
|
||||
except Exception:
|
||||
pass
|
||||
return FileResponse(path)
|
||||
except Exception:
|
||||
return PlainTextResponse("Error serving file", status_code=500)
|
Loading…
Add table
Add a link
Reference in a new issue