mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2025-12-16 15:40:12 +01:00
feat(web): launch commander browser with deck builder CTA
This commit is contained in:
parent
6e9ba244c9
commit
8e57588f40
27 changed files with 1960 additions and 45 deletions
37
.gitignore
vendored
37
.gitignore
vendored
|
|
@ -1,32 +1,39 @@
|
|||
*.bkp
|
||||
*.csv
|
||||
*.json
|
||||
*.log
|
||||
*.txt
|
||||
.mypy_cache/
|
||||
.venv/
|
||||
.pytest_cache/
|
||||
test.py
|
||||
!requirements.txt
|
||||
|
||||
RELEASE_NOTES.md
|
||||
test.py
|
||||
!test_exclude_cards.txt
|
||||
!test_include_exclude_config.json
|
||||
|
||||
.mypy_cache/
|
||||
.pytest_cache/
|
||||
.venv/
|
||||
__pycache__/
|
||||
|
||||
.github/*.md
|
||||
|
||||
config/themes/catalog/
|
||||
config/themes/
|
||||
!config/themes/*.yml
|
||||
!config/card_lists/*.json
|
||||
!config/deck.json
|
||||
|
||||
# Keep main CSV datasets out of Git, but allow the tiny deterministic fixtures used by CI.
|
||||
csv_files/*
|
||||
!csv_files/testdata/
|
||||
!csv_files/testdata/**/*
|
||||
|
||||
deck_files/
|
||||
dist/
|
||||
|
||||
logs/
|
||||
!logs/
|
||||
logs/*
|
||||
!logs/perf/
|
||||
logs/perf/*
|
||||
!logs/perf/theme_preview_warm_baseline.json
|
||||
deck_files/
|
||||
config/themes/catalog/
|
||||
!config/card_lists/*.json
|
||||
!config/themes/*.yml
|
||||
config/themes/theme_list.json
|
||||
!config/deck.json
|
||||
!test_exclude_cards.txt
|
||||
!test_include_exclude_config.json
|
||||
RELEASE_NOTES.md
|
||||
*.bkp
|
||||
.github/*.md
|
||||
13
CHANGELOG.md
13
CHANGELOG.md
|
|
@ -14,10 +14,19 @@ This format follows Keep a Changelog principles and aims for Semantic Versioning
|
|||
|
||||
## [Unreleased]
|
||||
### Added
|
||||
- _No changes yet._
|
||||
- Commander browser skeleton page at `/commanders` with HTMX-capable filtering and catalog-backed commander rows.
|
||||
- Shared color-identity macro and accessible theme chips powering the commander browser UI.
|
||||
- Commander browser QA walkthrough documenting desktop and mobile validation steps (`docs/qa/commander_browser_walkthrough.md`).
|
||||
- Home screen actions now surface Commander Browser and Diagnostics shortcuts when the corresponding feature flags are enabled.
|
||||
- Manual QA pass (2025-09-30) recorded in project docs, covering desktop/mobile flows and edge cases.
|
||||
|
||||
### Changed
|
||||
- _No changes yet._
|
||||
- Commander browser now paginates results in 20-commander pages with accessible navigation controls and range summaries to keep the catalog responsive.
|
||||
- Commander hover preview collapses to a card-only view when browsing commanders, and all theme chips display without the previous “+ more” overflow badge.
|
||||
- Added a Content Security Policy upgrade directive so proxied HTTPS deployments safely rewrite commander pagination requests to HTTPS, preventing mixed-content blocks.
|
||||
- Commander thumbnails use a fixed-width 160px frame (scaling down on small screens) to eliminate inconsistent image sizing across the catalog.
|
||||
- Commander list pagination controls now appear above and below the results and automatically scroll to the top when switching pages for quicker navigation.
|
||||
- Mobile commander rows now feature larger thumbnails and a centered preview modal with expanded card art for improved readability.
|
||||
|
||||
### Fixed
|
||||
- _No changes yet._
|
||||
|
|
|
|||
BIN
README.md
BIN
README.md
Binary file not shown.
|
|
@ -1,9 +1,24 @@
|
|||
# MTG Python Deckbuilder ${VERSION}
|
||||
|
||||
## Summary
|
||||
- Theme catalog pagination buttons now re-run HTMX processing after fragment fetches so “Next” works reliably in the picker and simple catalog views.
|
||||
- Docker entrypoint seeds default theme catalog configuration files into volume-backed deployments, keeping Docker Hub images ready out of the box.
|
||||
- Introduced the Commander Browser with HTMX-powered pagination, theme surfacing, and direct Create Deck integration.
|
||||
- Shared color-identity macro and accessible theme chips power the new commander rows.
|
||||
- Manual QA walkthrough (desktop + mobile) recorded on 2025‑09‑30 with edge-case checks.
|
||||
- Home dashboard aligns its quick actions with feature flags, exposing Commanders, Diagnostics, Random, Logs, and Setup where enabled.
|
||||
|
||||
## Added
|
||||
- Commander browser skeleton page at `/commanders` with catalog-backed rows and accessible theme chips.
|
||||
- Documented QA checklist and results for the commander browser launch in `docs/qa/commander_browser_walkthrough.md`.
|
||||
- Shared color-identity macro for reusable mana dots across commander rows and other templates.
|
||||
- Home dashboard Commander/Diagnostics shortcuts gated by feature flags so all primary destinations have quick actions.
|
||||
- Manual QA pass entered into project docs (2025-09-30) outlining desktop, mobile, and edge-case validations.
|
||||
|
||||
## Changed
|
||||
- Commander list paginates in 20-item pages, with navigation controls mirrored above and below the results and automatic scroll-to-top.
|
||||
- Commander hover preview shows card-only panel in browser context and removes the “+ more” overflow badge from theme chips.
|
||||
- Content Security Policy upgrade directive ensures HTMX pagination requests remain HTTPS-safe behind proxies.
|
||||
- Commander thumbnails adopt a fixed-width 160px frame (responsive on small screens) for consistent layout.
|
||||
- Mobile commander rows now feature larger thumbnails and a centered preview modal with expanded card art for improved readability.
|
||||
|
||||
## Fixed
|
||||
- Theme catalog Next pagination button now reprocesses HTMX fragments after manual fetches, restoring navigation controls in theme picker and simple catalog modes.
|
||||
- Docker entrypoint seeds default `config/themes` YAML files (synergy pairs, clusters, whitelist, etc.) into mounted volumes so Docker Hub deployments have the expected baseline data.
|
||||
- Documented friendly handling for missing `commander_cards.csv` data during manual QA drills to prevent white-screen failures.
|
||||
97
code/tests/test_commander_build_cta.py
Normal file
97
code/tests/test_commander_build_cta.py
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import html as _html
|
||||
import re
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from code.web.app import app # type: ignore
|
||||
from code.web.services.commander_catalog_loader import clear_commander_catalog_cache
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch):
|
||||
csv_dir = Path("csv_files/testdata").resolve()
|
||||
monkeypatch.setenv("CSV_FILES_DIR", str(csv_dir))
|
||||
clear_commander_catalog_cache()
|
||||
with TestClient(app) as test_client:
|
||||
yield test_client
|
||||
clear_commander_catalog_cache()
|
||||
|
||||
|
||||
def test_commander_row_has_build_cta_with_return_url(client: TestClient) -> None:
|
||||
# Load the commanders page
|
||||
resp = client.get("/commanders", params={"q": "atraxa"})
|
||||
assert resp.status_code == 200
|
||||
body = resp.text
|
||||
# Ensure the Build link includes the builder path with commander and return params
|
||||
match = re.search(r'href="(/build\?[^\"]+)"', body)
|
||||
assert match is not None
|
||||
href = _html.unescape(match.group(1))
|
||||
assert href.startswith("/build?commander=")
|
||||
parsed = urlparse(href)
|
||||
params = parse_qs(parsed.query)
|
||||
assert "return" in params
|
||||
return_value = params["return"][0]
|
||||
assert return_value.startswith("/commanders")
|
||||
parsed_return = urlparse(return_value)
|
||||
assert parsed_return.path.rstrip("/") == "/commanders"
|
||||
parsed_return_params = parse_qs(parsed_return.query)
|
||||
assert parsed_return_params.get("q") == ["atraxa"]
|
||||
# Ensure no absolute scheme slipped through
|
||||
assert not return_value.startswith("http")
|
||||
|
||||
|
||||
def test_build_page_includes_back_link_for_safe_return(client: TestClient) -> None:
|
||||
resp = client.get("/build", params={"return": "/commanders?page=2&color=W"})
|
||||
assert resp.status_code == 200
|
||||
body = resp.text
|
||||
match = re.search(r'href="(/commanders[^\"]+)"', body)
|
||||
assert match is not None
|
||||
href = _html.unescape(match.group(1))
|
||||
parsed = urlparse(href)
|
||||
assert parsed.path == "/commanders"
|
||||
params = parse_qs(parsed.query)
|
||||
assert params.get("page") == ["2"]
|
||||
assert params.get("color") == ["W"]
|
||||
|
||||
|
||||
def test_build_page_ignores_external_return(client: TestClient) -> None:
|
||||
resp = client.get("/build", params={"return": "https://evil.example.com"})
|
||||
assert resp.status_code == 200
|
||||
body = resp.text
|
||||
assert "Back to Commanders" not in body
|
||||
|
||||
|
||||
def test_commander_launch_preselects_commander_and_requires_theme(client: TestClient) -> None:
|
||||
commander_name = "Atraxa, Praetors' Voice"
|
||||
resp = client.get(
|
||||
"/build",
|
||||
params={"commander": commander_name, "return": "/commanders?page=2"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.text
|
||||
init_match = re.search(r'<span id="builder-init"[^>]*data-commander="([^"]+)"', body)
|
||||
assert init_match is not None
|
||||
assert _html.unescape(init_match.group(1)) == commander_name
|
||||
assert "Back to Commanders" in body
|
||||
|
||||
step2 = client.get("/build/step2")
|
||||
assert step2.status_code == 200
|
||||
step2_body = step2.text
|
||||
assert commander_name in _html.unescape(step2_body)
|
||||
assert 'name="primary_tag"' in step2_body
|
||||
|
||||
submit = client.post(
|
||||
"/build/step2",
|
||||
data={
|
||||
"commander": commander_name,
|
||||
"bracket": "3",
|
||||
"tag_mode": "AND",
|
||||
},
|
||||
)
|
||||
assert submit.status_code == 200
|
||||
assert "Please choose a primary theme." in submit.text
|
||||
71
code/tests/test_commander_catalog_loader.py
Normal file
71
code/tests/test_commander_catalog_loader.py
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from web.services import commander_catalog_loader as loader
|
||||
|
||||
|
||||
FIXTURE_DIR = Path(__file__).resolve().parents[2] / "csv_files" / "testdata"
|
||||
|
||||
|
||||
def _set_csv_dir(monkeypatch: pytest.MonkeyPatch, path: Path) -> None:
|
||||
monkeypatch.setenv("CSV_FILES_DIR", str(path))
|
||||
loader.clear_commander_catalog_cache()
|
||||
|
||||
|
||||
def test_commander_catalog_basic_normalization(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_set_csv_dir(monkeypatch, FIXTURE_DIR)
|
||||
|
||||
catalog = loader.load_commander_catalog()
|
||||
|
||||
assert catalog.source_path.name == "commander_cards.csv"
|
||||
assert len(catalog.entries) == 4
|
||||
|
||||
krenko = catalog.by_slug["krenko-mob-boss"]
|
||||
assert krenko.display_name == "Krenko, Mob Boss"
|
||||
assert krenko.color_identity == ("R",)
|
||||
assert krenko.color_identity_key == "R"
|
||||
assert not krenko.is_colorless
|
||||
assert krenko.themes == ("Goblin Kindred",)
|
||||
assert "goblin kindred" in krenko.theme_tokens
|
||||
assert "version=small" in krenko.image_small_url
|
||||
assert "exact=Krenko%2C%20Mob%20Boss" in krenko.image_small_url
|
||||
|
||||
traxos = catalog.by_slug["traxos-scourge-of-kroog"]
|
||||
assert traxos.is_colorless
|
||||
assert traxos.color_identity == ()
|
||||
assert traxos.color_identity_key == "C"
|
||||
|
||||
atraxa = catalog.by_slug["atraxa-praetors-voice"]
|
||||
assert atraxa.color_identity == ("W", "U", "B", "G")
|
||||
assert atraxa.color_identity_key == "WUBG"
|
||||
assert atraxa.is_partner is False
|
||||
assert atraxa.supports_backgrounds is False
|
||||
|
||||
|
||||
def test_commander_catalog_cache_invalidation(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
fixture_csv = FIXTURE_DIR / "commander_cards.csv"
|
||||
work_dir = tmp_path / "csv"
|
||||
work_dir.mkdir()
|
||||
target_csv = work_dir / "commander_cards.csv"
|
||||
target_csv.write_text(fixture_csv.read_text(encoding="utf-8"), encoding="utf-8")
|
||||
|
||||
_set_csv_dir(monkeypatch, work_dir)
|
||||
|
||||
first = loader.load_commander_catalog()
|
||||
again = loader.load_commander_catalog()
|
||||
assert again is first
|
||||
|
||||
time.sleep(1.1) # ensure mtime tick on systems with 1s resolution
|
||||
target_csv.write_text(
|
||||
fixture_csv.read_text(encoding="utf-8")
|
||||
+ "\"Zada, Hedron Grinder\",\"Zada, Hedron Grinder\",9999,R,R,{3}{R},4,\"Legendary Creature — Goblin\",\"['Goblin']\",\"Test\",3,3,,\"['Goblin Kindred']\",normal,\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
updated = loader.load_commander_catalog()
|
||||
assert updated is not first
|
||||
assert "zada-hedron-grinder" in updated.by_slug
|
||||
56
code/tests/test_commander_telemetry.py
Normal file
56
code/tests/test_commander_telemetry.py
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from code.web.app import app # type: ignore
|
||||
from code.web.services import telemetry
|
||||
from code.web.services.commander_catalog_loader import clear_commander_catalog_cache
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch: pytest.MonkeyPatch):
|
||||
csv_dir = Path("csv_files/testdata").resolve()
|
||||
monkeypatch.setenv("CSV_FILES_DIR", str(csv_dir))
|
||||
clear_commander_catalog_cache()
|
||||
with TestClient(app) as test_client:
|
||||
yield test_client
|
||||
clear_commander_catalog_cache()
|
||||
|
||||
|
||||
def test_commander_page_logs_event(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
events: list[dict] = []
|
||||
|
||||
def capture(_logger, payload):
|
||||
events.append(payload)
|
||||
|
||||
monkeypatch.setattr(telemetry, "_emit", capture)
|
||||
|
||||
response = client.get("/commanders", params={"q": "atraxa"})
|
||||
assert response.status_code == 200
|
||||
assert events, "expected telemetry events to be emitted"
|
||||
event = events[-1]
|
||||
assert event["event"] == "commander_browser.page_view"
|
||||
assert event["page"] == 1
|
||||
assert event["query"]["q"] == "atraxa"
|
||||
assert event["is_htmx"] is False
|
||||
|
||||
|
||||
def test_commander_create_deck_logs_event(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
events: list[dict] = []
|
||||
|
||||
def capture(_logger, payload):
|
||||
events.append(payload)
|
||||
|
||||
monkeypatch.setattr(telemetry, "_emit", capture)
|
||||
|
||||
response = client.get("/build", params={"commander": "Atraxa", "return": "/commanders"})
|
||||
assert response.status_code == 200
|
||||
assert events, "expected telemetry events to be emitted"
|
||||
event = events[-1]
|
||||
assert event["event"] == "commander_browser.create_deck"
|
||||
assert event["commander"] == "Atraxa"
|
||||
assert event["has_return"] is True
|
||||
assert event["return_url"] == "/commanders"
|
||||
137
code/tests/test_commanders_route.py
Normal file
137
code/tests/test_commanders_route.py
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from code.web.app import app # type: ignore
|
||||
from code.web.routes import commanders
|
||||
from code.web.services import commander_catalog_loader
|
||||
from code.web.services.commander_catalog_loader import clear_commander_catalog_cache, load_commander_catalog
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch):
|
||||
csv_dir = Path("csv_files/testdata").resolve()
|
||||
monkeypatch.setenv("CSV_FILES_DIR", str(csv_dir))
|
||||
clear_commander_catalog_cache()
|
||||
with TestClient(app) as test_client:
|
||||
yield test_client
|
||||
clear_commander_catalog_cache()
|
||||
|
||||
|
||||
def test_commanders_page_renders(client: TestClient) -> None:
|
||||
response = client.get("/commanders")
|
||||
assert response.status_code == 200
|
||||
body = response.text
|
||||
assert "data-commander-slug=\"atraxa-praetors-voice\"" in body
|
||||
assert "data-commander-slug=\"krenko-mob-boss\"" in body
|
||||
assert "data-theme-summary=\"" in body
|
||||
assert 'id="commander-loading"' in body
|
||||
|
||||
|
||||
def test_commanders_search_filters(client: TestClient) -> None:
|
||||
response = client.get("/commanders", params={"q": "krenko"})
|
||||
assert response.status_code == 200
|
||||
body = response.text
|
||||
assert "data-commander-slug=\"krenko-mob-boss\"" in body
|
||||
assert "data-commander-slug=\"atraxa-praetors-voice\"" not in body
|
||||
|
||||
|
||||
def test_commanders_color_filter(client: TestClient) -> None:
|
||||
response = client.get("/commanders", params={"color": "W"})
|
||||
assert response.status_code == 200
|
||||
body = response.text
|
||||
assert "data-commander-slug=\"isamaru-hound-of-konda\"" in body
|
||||
assert "data-commander-slug=\"krenko-mob-boss\"" not in body
|
||||
|
||||
|
||||
def test_commanders_htmx_fragment(client: TestClient) -> None:
|
||||
response = client.get(
|
||||
"/commanders",
|
||||
params={"q": "atraxa"},
|
||||
headers={"HX-Request": "true"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
body = response.text
|
||||
assert "commander-row" in body
|
||||
assert "<section class=\"commander-page\"" not in body
|
||||
|
||||
|
||||
def _install_paginated_catalog(monkeypatch: pytest.MonkeyPatch, total: int) -> None:
|
||||
base_catalog = load_commander_catalog()
|
||||
sample = base_catalog.entries[0]
|
||||
records = []
|
||||
for index in range(total):
|
||||
name = f"Pagination Test {index:02d}"
|
||||
record = replace(
|
||||
sample,
|
||||
name=name,
|
||||
face_name=name,
|
||||
display_name=name,
|
||||
slug=f"pagination-test-{index:02d}",
|
||||
search_haystack=f"{name.lower()}"
|
||||
)
|
||||
records.append(record)
|
||||
fake_catalog = SimpleNamespace(entries=tuple(records))
|
||||
def loader() -> SimpleNamespace:
|
||||
return fake_catalog
|
||||
|
||||
monkeypatch.setattr(commander_catalog_loader, "load_commander_catalog", loader)
|
||||
monkeypatch.setattr(commanders, "load_commander_catalog", loader)
|
||||
|
||||
|
||||
def test_commanders_pagination_limits_results(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_install_paginated_catalog(monkeypatch, total=35)
|
||||
|
||||
response = client.get("/commanders")
|
||||
assert response.status_code == 200
|
||||
body = response.text
|
||||
|
||||
assert "Page 1 of 2" in body
|
||||
assert "Showing 1 – 20 of 35" in body
|
||||
assert body.count('href="/commanders?page=2"') == 2
|
||||
assert body.count('data-commander-slug="pagination-test-') == 20
|
||||
|
||||
|
||||
def test_commanders_second_page_shows_remaining_results(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_install_paginated_catalog(monkeypatch, total=35)
|
||||
|
||||
response = client.get("/commanders", params={"page": 2})
|
||||
assert response.status_code == 200
|
||||
body = response.text
|
||||
|
||||
assert "Page 2 of 2" in body
|
||||
assert 'data-commander-slug="pagination-test-00"' not in body
|
||||
assert 'data-commander-slug="pagination-test-20"' in body
|
||||
assert 'data-commander-slug="pagination-test-34"' in body
|
||||
assert 'href="/commanders?page=1"' in body
|
||||
|
||||
|
||||
def test_commanders_show_all_themes_without_overflow(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
catalog = load_commander_catalog()
|
||||
sample = catalog.entries[0]
|
||||
themes = tuple(f"Theme {idx}" for idx in range(1, 9))
|
||||
enriched = replace(
|
||||
sample,
|
||||
themes=themes,
|
||||
theme_tokens=tuple(theme.lower() for theme in themes),
|
||||
)
|
||||
fake_catalog = SimpleNamespace(entries=(enriched,))
|
||||
|
||||
def loader() -> SimpleNamespace:
|
||||
return fake_catalog
|
||||
|
||||
monkeypatch.setattr(commander_catalog_loader, "load_commander_catalog", loader)
|
||||
monkeypatch.setattr(commanders, "load_commander_catalog", loader)
|
||||
|
||||
response = client.get("/commanders")
|
||||
assert response.status_code == 200
|
||||
body = response.text
|
||||
|
||||
assert "commander-theme-chip-more" not in body # no overflow badge rendered
|
||||
for name in themes:
|
||||
assert name in body
|
||||
|
|
@ -8,6 +8,18 @@ fastapi = pytest.importorskip("fastapi") # skip tests if FastAPI isn't installe
|
|||
|
||||
|
||||
def load_app_with_env(**env: str) -> types.ModuleType:
|
||||
for key in (
|
||||
"SHOW_LOGS",
|
||||
"SHOW_DIAGNOSTICS",
|
||||
"SHOW_SETUP",
|
||||
"SHOW_COMMANDERS",
|
||||
"ENABLE_THEMES",
|
||||
"ENABLE_PWA",
|
||||
"ENABLE_PRESETS",
|
||||
"APP_VERSION",
|
||||
"THEME",
|
||||
):
|
||||
os.environ.pop(key, None)
|
||||
for k, v in env.items():
|
||||
os.environ[k] = v
|
||||
import code.web.app as app_module # type: ignore
|
||||
|
|
@ -67,6 +79,7 @@ def test_status_sys_summary_and_flags():
|
|||
SHOW_LOGS="1",
|
||||
SHOW_DIAGNOSTICS="1",
|
||||
SHOW_SETUP="1",
|
||||
SHOW_COMMANDERS="1",
|
||||
ENABLE_THEMES="1",
|
||||
ENABLE_PWA="1",
|
||||
ENABLE_PRESETS="1",
|
||||
|
|
@ -84,8 +97,27 @@ def test_status_sys_summary_and_flags():
|
|||
assert flags.get("SHOW_LOGS") is True
|
||||
assert flags.get("SHOW_DIAGNOSTICS") is True
|
||||
assert flags.get("SHOW_SETUP") is True
|
||||
assert flags.get("SHOW_COMMANDERS") is True
|
||||
# Theme-related flags
|
||||
assert flags.get("ENABLE_THEMES") is True
|
||||
assert flags.get("ENABLE_PWA") is True
|
||||
assert flags.get("ENABLE_PRESETS") is True
|
||||
assert flags.get("DEFAULT_THEME") == "dark"
|
||||
|
||||
|
||||
def test_commanders_nav_hidden_when_flag_disabled():
|
||||
app_module = load_app_with_env(SHOW_COMMANDERS="0")
|
||||
client = TestClient(app_module.app)
|
||||
r = client.get("/")
|
||||
assert r.status_code == 200
|
||||
body = r.text
|
||||
assert '<a href="/commanders"' not in body
|
||||
|
||||
|
||||
def test_commanders_nav_visible_by_default():
|
||||
app_module = load_app_with_env()
|
||||
client = TestClient(app_module.app)
|
||||
r = client.get("/")
|
||||
assert r.status_code == 200
|
||||
body = r.text
|
||||
assert '<a href="/commanders"' in body
|
||||
|
|
|
|||
61
code/tests/test_home_actions_buttons.py
Normal file
61
code/tests/test_home_actions_buttons.py
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import os
|
||||
import importlib
|
||||
import types
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
|
||||
def load_app_with_env(**env: str) -> types.ModuleType:
|
||||
for key in (
|
||||
"SHOW_LOGS",
|
||||
"SHOW_DIAGNOSTICS",
|
||||
"SHOW_SETUP",
|
||||
"SHOW_COMMANDERS",
|
||||
"ENABLE_THEMES",
|
||||
"ENABLE_PWA",
|
||||
"ENABLE_PRESETS",
|
||||
"APP_VERSION",
|
||||
"THEME",
|
||||
"RANDOM_UI",
|
||||
):
|
||||
os.environ.pop(key, None)
|
||||
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_home_actions_show_all_enabled_buttons():
|
||||
app_module = load_app_with_env(
|
||||
SHOW_LOGS="1",
|
||||
SHOW_DIAGNOSTICS="1",
|
||||
SHOW_SETUP="1",
|
||||
SHOW_COMMANDERS="1",
|
||||
RANDOM_UI="1",
|
||||
)
|
||||
client = TestClient(app_module.app)
|
||||
response = client.get("/")
|
||||
body = response.text
|
||||
assert 'href="/setup"' in body
|
||||
assert 'href="/commanders"' in body
|
||||
assert 'href="/random"' in body
|
||||
assert 'href="/diagnostics"' in body
|
||||
assert 'href="/logs"' in body
|
||||
|
||||
|
||||
def test_home_actions_hides_disabled_sections():
|
||||
app_module = load_app_with_env(
|
||||
SHOW_LOGS="0",
|
||||
SHOW_DIAGNOSTICS="0",
|
||||
SHOW_SETUP="0",
|
||||
SHOW_COMMANDERS="0",
|
||||
RANDOM_UI="0",
|
||||
)
|
||||
client = TestClient(app_module.app)
|
||||
response = client.get("/")
|
||||
body = response.text
|
||||
assert 'href="/setup"' not in body
|
||||
assert 'href="/commanders"' not in body
|
||||
assert 'href="/random"' not in body
|
||||
assert 'href="/diagnostics"' not in body
|
||||
assert 'href="/logs"' not in body
|
||||
|
|
@ -103,6 +103,7 @@ 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)
|
||||
SHOW_COMMANDERS = _as_bool(os.getenv("SHOW_COMMANDERS"), True)
|
||||
SHOW_VIRTUALIZE = _as_bool(os.getenv("WEB_VIRTUALIZE"), False)
|
||||
ENABLE_THEMES = _as_bool(os.getenv("ENABLE_THEMES"), False)
|
||||
ENABLE_PWA = _as_bool(os.getenv("ENABLE_PWA"), False)
|
||||
|
|
@ -231,6 +232,7 @@ templates.env.globals.update({
|
|||
"show_logs": SHOW_LOGS,
|
||||
"show_setup": SHOW_SETUP,
|
||||
"show_diagnostics": SHOW_DIAGNOSTICS,
|
||||
"show_commanders": SHOW_COMMANDERS,
|
||||
"virtualize": SHOW_VIRTUALIZE,
|
||||
"enable_themes": ENABLE_THEMES,
|
||||
"enable_pwa": ENABLE_PWA,
|
||||
|
|
@ -815,6 +817,7 @@ async def status_sys():
|
|||
"flags": {
|
||||
"SHOW_LOGS": bool(SHOW_LOGS),
|
||||
"SHOW_SETUP": bool(SHOW_SETUP),
|
||||
"SHOW_COMMANDERS": bool(SHOW_COMMANDERS),
|
||||
"SHOW_DIAGNOSTICS": bool(SHOW_DIAGNOSTICS),
|
||||
"ENABLE_THEMES": bool(ENABLE_THEMES),
|
||||
"ENABLE_PWA": bool(ENABLE_PWA),
|
||||
|
|
@ -2128,12 +2131,14 @@ from .routes import decks as decks_routes # noqa: E402
|
|||
from .routes import setup as setup_routes # noqa: E402
|
||||
from .routes import owned as owned_routes # noqa: E402
|
||||
from .routes import themes as themes_routes # noqa: E402
|
||||
from .routes import commanders as commanders_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)
|
||||
app.include_router(owned_routes.router)
|
||||
app.include_router(themes_routes.router)
|
||||
app.include_router(commanders_routes.router)
|
||||
|
||||
# Warm validation cache early to reduce first-call latency in tests and dev
|
||||
try:
|
||||
|
|
@ -2190,6 +2195,7 @@ async def http_exception_handler(request: Request, exc: HTTPException):
|
|||
"error": True,
|
||||
"status": exc.status_code,
|
||||
"detail": exc.detail,
|
||||
"request_id": rid,
|
||||
"path": str(request.url.path),
|
||||
}, headers=headers)
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ from deck_builder import builder_utils as bu
|
|||
from ..services.combo_utils import detect_all as _detect_all
|
||||
from path_util import csv_dir as _csv_dir
|
||||
from ..services.alts_utils import get_cached as _alts_get_cached, set_cached as _alts_set_cached
|
||||
from ..services.telemetry import log_commander_create_deck
|
||||
from urllib.parse import urlparse
|
||||
|
||||
# Cache for available card names used by validation endpoints
|
||||
_AVAILABLE_CARDS_CACHE: set[str] | None = None
|
||||
|
|
@ -188,6 +190,39 @@ def _rebuild_ctx_with_multicopy(sess: dict) -> None:
|
|||
async def build_index(request: Request) -> HTMLResponse:
|
||||
sid = request.cookies.get("sid") or new_sid()
|
||||
sess = get_session(sid)
|
||||
# Seed commander from query string when arriving from commander browser
|
||||
q_commander = None
|
||||
try:
|
||||
q_commander = request.query_params.get("commander")
|
||||
if q_commander:
|
||||
# Persist a human-friendly commander name into session for the wizard
|
||||
sess["commander"] = str(q_commander)
|
||||
except Exception:
|
||||
pass
|
||||
return_url = None
|
||||
try:
|
||||
raw_return = request.query_params.get("return")
|
||||
if raw_return:
|
||||
parsed = urlparse(raw_return)
|
||||
if not parsed.scheme and not parsed.netloc and parsed.path:
|
||||
safe_path = parsed.path if parsed.path.startswith("/") else f"/{parsed.path}"
|
||||
safe_return = safe_path
|
||||
if parsed.query:
|
||||
safe_return += f"?{parsed.query}"
|
||||
if parsed.fragment:
|
||||
safe_return += f"#{parsed.fragment}"
|
||||
return_url = safe_return
|
||||
except Exception:
|
||||
return_url = None
|
||||
if q_commander:
|
||||
try:
|
||||
log_commander_create_deck(
|
||||
request,
|
||||
commander=str(q_commander),
|
||||
return_url=return_url,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
# Determine last step (fallback heuristics if not set)
|
||||
last_step = sess.get("last_step")
|
||||
if not last_step:
|
||||
|
|
@ -210,6 +245,7 @@ async def build_index(request: Request) -> HTMLResponse:
|
|||
"tags": sess.get("tags", []),
|
||||
"name": sess.get("custom_export_base"),
|
||||
"last_step": last_step,
|
||||
"return_url": return_url,
|
||||
},
|
||||
)
|
||||
resp.set_cookie("sid", sid, httponly=True, samesite="lax")
|
||||
|
|
|
|||
323
code/web/routes/commanders.py
Normal file
323
code/web/routes/commanders.py
Normal file
|
|
@ -0,0 +1,323 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from math import ceil
|
||||
from typing import Iterable, Mapping, Sequence
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from fastapi import APIRouter, Query, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from ..app import templates
|
||||
from ..services.commander_catalog_loader import CommanderRecord, load_commander_catalog
|
||||
from ..services.theme_catalog_loader import load_index, slugify
|
||||
from ..services.telemetry import log_commander_page_view
|
||||
|
||||
router = APIRouter(prefix="/commanders", tags=["commanders"])
|
||||
|
||||
PAGE_SIZE = 20
|
||||
|
||||
_WUBRG_ORDER: tuple[str, ...] = ("W", "U", "B", "R", "G")
|
||||
_COLOR_NAMES: dict[str, str] = {
|
||||
"W": "White",
|
||||
"U": "Blue",
|
||||
"B": "Black",
|
||||
"R": "Red",
|
||||
"G": "Green",
|
||||
"C": "Colorless",
|
||||
}
|
||||
_TWO_COLOR_LABELS: dict[str, str] = {
|
||||
"WU": "Azorius",
|
||||
"UB": "Dimir",
|
||||
"BR": "Rakdos",
|
||||
"RG": "Gruul",
|
||||
"WG": "Selesnya",
|
||||
"WB": "Orzhov",
|
||||
"UR": "Izzet",
|
||||
"BG": "Golgari",
|
||||
"WR": "Boros",
|
||||
"UG": "Simic",
|
||||
}
|
||||
_THREE_COLOR_LABELS: dict[str, str] = {
|
||||
"WUB": "Esper",
|
||||
"UBR": "Grixis",
|
||||
"BRG": "Jund",
|
||||
"WRG": "Naya",
|
||||
"WUG": "Bant",
|
||||
"WBR": "Mardu",
|
||||
"WUR": "Jeskai",
|
||||
"UBG": "Sultai",
|
||||
"URG": "Temur",
|
||||
"WBG": "Abzan",
|
||||
}
|
||||
_FOUR_COLOR_LABELS: dict[str, str] = {
|
||||
"WUBR": "Yore-Tiller",
|
||||
"WUBG": "Witch-Maw",
|
||||
"WURG": "Ink-Treader",
|
||||
"WBRG": "Dune-Brood",
|
||||
"UBRG": "Glint-Eye",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CommanderTheme:
|
||||
name: str
|
||||
slug: str
|
||||
summary: str | None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class CommanderView:
|
||||
record: CommanderRecord
|
||||
color_code: str
|
||||
color_label: str
|
||||
color_aria_label: str
|
||||
themes: tuple[CommanderTheme, ...]
|
||||
partner_summary: tuple[str, ...]
|
||||
|
||||
|
||||
def _is_htmx(request: Request) -> bool:
|
||||
return request.headers.get("HX-Request", "").lower() == "true"
|
||||
|
||||
|
||||
def _record_color_code(record: CommanderRecord) -> str:
|
||||
code = record.color_identity_key or ""
|
||||
if not code and record.is_colorless:
|
||||
return "C"
|
||||
return code
|
||||
|
||||
|
||||
def _canon_color_code(raw: str | None) -> str:
|
||||
if not raw:
|
||||
return ""
|
||||
text = raw.upper()
|
||||
seen: set[str] = set()
|
||||
ordered: list[str] = []
|
||||
for color in _WUBRG_ORDER:
|
||||
if color in text:
|
||||
seen.add(color)
|
||||
ordered.append(color)
|
||||
if not ordered and "C" in text:
|
||||
return "C"
|
||||
return "".join(ordered)
|
||||
|
||||
|
||||
def _color_label_from_code(code: str) -> str:
|
||||
if not code:
|
||||
return ""
|
||||
if code == "C":
|
||||
return "Colorless (C)"
|
||||
if len(code) == 1:
|
||||
base = _COLOR_NAMES.get(code, code)
|
||||
return f"{base} ({code})"
|
||||
if len(code) == 2:
|
||||
label = _TWO_COLOR_LABELS.get(code)
|
||||
if label:
|
||||
return f"{label} ({code})"
|
||||
if len(code) == 3:
|
||||
label = _THREE_COLOR_LABELS.get(code)
|
||||
if label:
|
||||
return f"{label} ({code})"
|
||||
if len(code) == 4:
|
||||
label = _FOUR_COLOR_LABELS.get(code)
|
||||
if label:
|
||||
return f"{label} ({code})"
|
||||
if code == "WUBRG":
|
||||
return "Five-Color (WUBRG)"
|
||||
parts = [_COLOR_NAMES.get(ch, ch) for ch in code]
|
||||
pretty = " / ".join(parts)
|
||||
return f"{pretty} ({code})"
|
||||
|
||||
|
||||
def _color_aria_label(record: CommanderRecord) -> str:
|
||||
if record.color_identity:
|
||||
names = [_COLOR_NAMES.get(ch, ch) for ch in record.color_identity]
|
||||
return ", ".join(names)
|
||||
return _COLOR_NAMES.get("C", "Colorless")
|
||||
|
||||
|
||||
def _partner_summary(record: CommanderRecord) -> tuple[str, ...]:
|
||||
parts: list[str] = []
|
||||
if record.partner_with:
|
||||
parts.append("Partner with " + ", ".join(record.partner_with))
|
||||
elif record.is_partner:
|
||||
parts.append("Partner available")
|
||||
if record.supports_backgrounds:
|
||||
parts.append("Choose a Background")
|
||||
if record.is_background:
|
||||
parts.append("Background commander")
|
||||
return tuple(parts)
|
||||
|
||||
|
||||
def _record_to_view(record: CommanderRecord, theme_info: Mapping[str, CommanderTheme]) -> CommanderView:
|
||||
theme_objs: list[CommanderTheme] = []
|
||||
for theme_name in record.themes:
|
||||
info = theme_info.get(theme_name)
|
||||
if info is not None:
|
||||
theme_objs.append(info)
|
||||
else:
|
||||
slug = slugify(theme_name)
|
||||
theme_objs.append(CommanderTheme(name=theme_name, slug=slug, summary=None))
|
||||
color_code = _record_color_code(record)
|
||||
return CommanderView(
|
||||
record=record,
|
||||
color_code=color_code,
|
||||
color_label=_color_label_from_code(color_code),
|
||||
color_aria_label=_color_aria_label(record),
|
||||
themes=tuple(theme_objs),
|
||||
partner_summary=_partner_summary(record),
|
||||
)
|
||||
|
||||
|
||||
def _filter_commanders(records: Iterable[CommanderRecord], q: str | None, color: str | None) -> list[CommanderRecord]:
|
||||
items = list(records)
|
||||
color_code = _canon_color_code(color)
|
||||
if color_code:
|
||||
items = [rec for rec in items if _record_color_code(rec) == color_code]
|
||||
if q:
|
||||
lowered = q.lower().strip()
|
||||
if lowered:
|
||||
tokens = [tok for tok in lowered.split() if tok]
|
||||
if tokens:
|
||||
filtered: list[CommanderRecord] = []
|
||||
for rec in items:
|
||||
haystack = rec.search_haystack or ""
|
||||
if all(tok in haystack for tok in tokens):
|
||||
filtered.append(rec)
|
||||
items = filtered
|
||||
return items
|
||||
|
||||
|
||||
def _build_color_options(records: Sequence[CommanderRecord]) -> list[tuple[str, str]]:
|
||||
present: set[str] = set()
|
||||
for rec in records:
|
||||
code = _record_color_code(rec)
|
||||
if code:
|
||||
present.add(code)
|
||||
options: list[tuple[str, str]] = []
|
||||
for mono in ("W", "U", "B", "R", "G", "C"):
|
||||
if mono in present:
|
||||
options.append((mono, _color_label_from_code(mono)))
|
||||
combos = sorted((code for code in present if len(code) >= 2), key=lambda c: (len(c), c))
|
||||
for code in combos:
|
||||
options.append((code, _color_label_from_code(code)))
|
||||
return options
|
||||
|
||||
|
||||
def _build_theme_info(records: Sequence[CommanderRecord]) -> dict[str, CommanderTheme]:
|
||||
unique_names: set[str] = set()
|
||||
for rec in records:
|
||||
unique_names.update(rec.themes)
|
||||
if not unique_names:
|
||||
return {}
|
||||
try:
|
||||
idx = load_index()
|
||||
except FileNotFoundError:
|
||||
return {}
|
||||
except Exception:
|
||||
return {}
|
||||
info: dict[str, CommanderTheme] = {}
|
||||
for name in unique_names:
|
||||
try:
|
||||
slug = slugify(name)
|
||||
except Exception:
|
||||
slug = name
|
||||
summary: str | None = None
|
||||
try:
|
||||
data = idx.summary_by_slug.get(slug)
|
||||
if data:
|
||||
summary = data.get("short_description") or data.get("description")
|
||||
except Exception:
|
||||
summary = None
|
||||
info[name] = CommanderTheme(name=name, slug=slug, summary=summary)
|
||||
return info
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def commanders_index(
|
||||
request: Request,
|
||||
q: str | None = Query(default=None, alias="q"),
|
||||
color: str | None = Query(default=None, alias="color"),
|
||||
page: int = Query(default=1, ge=1),
|
||||
) -> HTMLResponse:
|
||||
entries: Sequence[CommanderRecord] = ()
|
||||
error: str | None = None
|
||||
try:
|
||||
catalog = load_commander_catalog()
|
||||
entries = catalog.entries
|
||||
except FileNotFoundError:
|
||||
error = "Commander catalog is unavailable. Ensure csv_files/commander_cards.csv exists."
|
||||
filtered = _filter_commanders(entries, q, color)
|
||||
total_filtered = len(filtered)
|
||||
page_count = max(1, ceil(total_filtered / PAGE_SIZE)) if total_filtered else 1
|
||||
if page > page_count:
|
||||
page = page_count
|
||||
start_index = (page - 1) * PAGE_SIZE
|
||||
end_index = start_index + PAGE_SIZE
|
||||
page_records = filtered[start_index:end_index]
|
||||
theme_info = _build_theme_info(page_records)
|
||||
views = [_record_to_view(rec, theme_info) for rec in page_records]
|
||||
color_options = _build_color_options(entries) if entries else []
|
||||
page_start = start_index + 1 if total_filtered else 0
|
||||
page_end = start_index + len(page_records)
|
||||
has_prev = page > 1
|
||||
has_next = page < page_count
|
||||
canon_color = _canon_color_code(color)
|
||||
|
||||
def _page_url(page_value: int) -> str:
|
||||
params: dict[str, str] = {}
|
||||
if q:
|
||||
params["q"] = q
|
||||
if canon_color:
|
||||
params["color"] = canon_color
|
||||
params["page"] = str(page_value)
|
||||
return f"/commanders?{urlencode(params)}"
|
||||
|
||||
prev_page = page - 1 if has_prev else None
|
||||
next_page = page + 1 if has_next else None
|
||||
prev_url = _page_url(prev_page) if prev_page else None
|
||||
next_url = _page_url(next_page) if next_page else None
|
||||
|
||||
current_path = request.url.path or "/commanders"
|
||||
current_query = request.url.query or ""
|
||||
if current_query:
|
||||
return_url = f"{current_path}?{current_query}"
|
||||
else:
|
||||
return_url = current_path
|
||||
|
||||
context = {
|
||||
"request": request,
|
||||
"commanders": views,
|
||||
"query": q or "",
|
||||
"color": canon_color,
|
||||
"color_options": color_options,
|
||||
"total_count": len(entries),
|
||||
"result_count": len(views),
|
||||
"result_total": total_filtered,
|
||||
"page": page,
|
||||
"page_count": page_count,
|
||||
"page_size": PAGE_SIZE,
|
||||
"page_start": page_start,
|
||||
"page_end": page_end,
|
||||
"has_prev": has_prev,
|
||||
"has_next": has_next,
|
||||
"prev_page": prev_page,
|
||||
"next_page": next_page,
|
||||
"prev_url": prev_url,
|
||||
"next_url": next_url,
|
||||
"is_filtered": bool((q or "").strip() or (color or "").strip()),
|
||||
"error": error,
|
||||
"return_url": return_url,
|
||||
}
|
||||
template_name = "commanders/list_fragment.html" if _is_htmx(request) else "commanders/index.html"
|
||||
try:
|
||||
log_commander_page_view(
|
||||
request,
|
||||
page=page,
|
||||
result_total=total_filtered,
|
||||
result_count=len(views),
|
||||
is_htmx=_is_htmx(request),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return templates.TemplateResponse(template_name, context)
|
||||
423
code/web/services/commander_catalog_loader.py
Normal file
423
code/web/services/commander_catalog_loader.py
Normal file
|
|
@ -0,0 +1,423 @@
|
|||
"""Commander catalog loader and normalization helpers for the web UI.
|
||||
|
||||
Responsibilities
|
||||
================
|
||||
- Read and normalize `commander_cards.csv` (shared with the deck builder).
|
||||
- Produce deterministic commander records with rich metadata (slug, colors,
|
||||
partner/background flags, theme tags, Scryfall image URLs).
|
||||
- Cache the parsed catalog and invalidate on file timestamp changes.
|
||||
|
||||
The loader operates without pandas to keep the web layer light-weight and to
|
||||
simplify unit testing. It honors the `CSV_FILES_DIR` environment variable via
|
||||
`path_util.csv_dir()` just like the CLI builder.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Dict, Iterable, List, Mapping, Optional, Tuple
|
||||
import ast
|
||||
import csv
|
||||
import os
|
||||
import re
|
||||
from urllib.parse import quote
|
||||
|
||||
from path_util import csv_dir
|
||||
|
||||
__all__ = [
|
||||
"CommanderRecord",
|
||||
"CommanderCatalog",
|
||||
"load_commander_catalog",
|
||||
"clear_commander_catalog_cache",
|
||||
]
|
||||
|
||||
|
||||
_COLOR_ALIAS = {
|
||||
"W": "W",
|
||||
"WHITE": "W",
|
||||
"U": "U",
|
||||
"BLUE": "U",
|
||||
"B": "B",
|
||||
"BLACK": "B",
|
||||
"R": "R",
|
||||
"RED": "R",
|
||||
"G": "G",
|
||||
"GREEN": "G",
|
||||
"C": "C",
|
||||
"COLORLESS": "C",
|
||||
}
|
||||
_WUBRG_ORDER: Tuple[str, ...] = ("W", "U", "B", "R", "G")
|
||||
_SCYRFALL_BASE = "https://api.scryfall.com/cards/named?format=image"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CommanderRecord:
|
||||
"""Normalized commander row."""
|
||||
|
||||
name: str
|
||||
face_name: str
|
||||
display_name: str
|
||||
slug: str
|
||||
color_identity: Tuple[str, ...]
|
||||
color_identity_key: str
|
||||
is_colorless: bool
|
||||
colors: Tuple[str, ...]
|
||||
mana_cost: str
|
||||
mana_value: Optional[float]
|
||||
type_line: str
|
||||
creature_types: Tuple[str, ...]
|
||||
oracle_text: str
|
||||
power: Optional[str]
|
||||
toughness: Optional[str]
|
||||
keywords: Tuple[str, ...]
|
||||
themes: Tuple[str, ...]
|
||||
theme_tokens: Tuple[str, ...]
|
||||
edhrec_rank: Optional[int]
|
||||
layout: str
|
||||
side: Optional[str]
|
||||
image_small_url: str
|
||||
image_normal_url: str
|
||||
partner_with: Tuple[str, ...]
|
||||
is_partner: bool
|
||||
supports_backgrounds: bool
|
||||
is_background: bool
|
||||
search_haystack: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CommanderCatalog:
|
||||
"""Cached commander catalog with lookup helpers."""
|
||||
|
||||
source_path: Path
|
||||
etag: str
|
||||
mtime_ns: int
|
||||
size: int
|
||||
entries: Tuple[CommanderRecord, ...]
|
||||
by_slug: Mapping[str, CommanderRecord]
|
||||
|
||||
def get(self, slug: str) -> Optional[CommanderRecord]:
|
||||
return self.by_slug.get(slug)
|
||||
|
||||
|
||||
_CACHE: Dict[str, CommanderCatalog] = {}
|
||||
|
||||
|
||||
def clear_commander_catalog_cache() -> None:
|
||||
"""Clear the in-memory commander catalog cache (testing/support)."""
|
||||
|
||||
_CACHE.clear()
|
||||
|
||||
|
||||
def load_commander_catalog(
|
||||
source_path: str | os.PathLike[str] | None = None,
|
||||
*,
|
||||
force_reload: bool = False,
|
||||
) -> CommanderCatalog:
|
||||
"""Load (and cache) the commander catalog.
|
||||
|
||||
Args:
|
||||
source_path: Optional path to override the default csv (mostly for tests).
|
||||
force_reload: When True, bypass cache even if the file is unchanged.
|
||||
"""
|
||||
|
||||
csv_path = _resolve_commander_path(source_path)
|
||||
key = str(csv_path)
|
||||
|
||||
if not force_reload:
|
||||
cached = _CACHE.get(key)
|
||||
if cached and _is_cache_valid(csv_path, cached):
|
||||
return cached
|
||||
|
||||
catalog = _build_catalog(csv_path)
|
||||
_CACHE[key] = catalog
|
||||
return catalog
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internals
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _resolve_commander_path(source_path: str | os.PathLike[str] | None) -> Path:
|
||||
if source_path is not None:
|
||||
return Path(source_path).resolve()
|
||||
return (Path(csv_dir()) / "commander_cards.csv").resolve()
|
||||
|
||||
|
||||
def _is_cache_valid(path: Path, cached: CommanderCatalog) -> bool:
|
||||
try:
|
||||
stat_result = path.stat()
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
mtime_ns = getattr(stat_result, "st_mtime_ns", int(stat_result.st_mtime * 1_000_000_000))
|
||||
if mtime_ns != cached.mtime_ns:
|
||||
return False
|
||||
return stat_result.st_size == cached.size
|
||||
|
||||
|
||||
def _build_catalog(path: Path) -> CommanderCatalog:
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Commander CSV not found at {path}")
|
||||
|
||||
entries: List[CommanderRecord] = []
|
||||
used_slugs: set[str] = set()
|
||||
|
||||
with path.open("r", encoding="utf-8", newline="") as handle:
|
||||
reader = csv.DictReader(handle)
|
||||
if reader.fieldnames is None:
|
||||
raise ValueError("Commander CSV missing header row")
|
||||
|
||||
for index, row in enumerate(reader):
|
||||
try:
|
||||
record = _row_to_record(row, used_slugs)
|
||||
except Exception:
|
||||
continue
|
||||
entries.append(record)
|
||||
used_slugs.add(record.slug)
|
||||
|
||||
stat_result = path.stat()
|
||||
mtime_ns = getattr(stat_result, "st_mtime_ns", int(stat_result.st_mtime * 1_000_000_000))
|
||||
etag = f"{stat_result.st_size}-{mtime_ns}-{len(entries)}"
|
||||
frozen_entries = tuple(entries)
|
||||
by_slug = {record.slug: record for record in frozen_entries}
|
||||
return CommanderCatalog(
|
||||
source_path=path,
|
||||
etag=etag,
|
||||
mtime_ns=mtime_ns,
|
||||
size=stat_result.st_size,
|
||||
entries=frozen_entries,
|
||||
by_slug=by_slug,
|
||||
)
|
||||
|
||||
|
||||
def _row_to_record(row: Mapping[str, object], used_slugs: Iterable[str]) -> CommanderRecord:
|
||||
name = _clean_str(row.get("name")) or "Unknown Commander"
|
||||
face_name = _clean_str(row.get("faceName"))
|
||||
display_name = face_name or name
|
||||
|
||||
base_slug = _slugify(display_name)
|
||||
side = _clean_str(row.get("side"))
|
||||
if side and side.lower() not in {"", "a"}:
|
||||
candidate = f"{base_slug}-{side.lower()}"
|
||||
else:
|
||||
candidate = base_slug
|
||||
slug = _dedupe_slug(candidate, used_slugs)
|
||||
|
||||
color_identity, is_colorless = _parse_color_identity(row.get("colorIdentity"))
|
||||
colors, _ = _parse_color_identity(row.get("colors"))
|
||||
mana_cost = _clean_str(row.get("manaCost"))
|
||||
mana_value = _parse_float(row.get("manaValue"))
|
||||
type_line = _clean_str(row.get("type"))
|
||||
creature_types = tuple(_parse_literal_list(row.get("creatureTypes")))
|
||||
oracle_text = _clean_multiline(row.get("text"))
|
||||
power = _clean_str(row.get("power")) or None
|
||||
toughness = _clean_str(row.get("toughness")) or None
|
||||
keywords = tuple(_split_to_list(row.get("keywords")))
|
||||
themes = tuple(_parse_literal_list(row.get("themeTags")))
|
||||
theme_tokens = tuple(dict.fromkeys(t.lower() for t in themes if t))
|
||||
edhrec_rank = _parse_int(row.get("edhrecRank"))
|
||||
layout = _clean_str(row.get("layout")) or "normal"
|
||||
partner_with = tuple(_extract_partner_with(oracle_text))
|
||||
is_partner = bool(
|
||||
partner_with
|
||||
or _contains_keyword(oracle_text, "partner")
|
||||
or _contains_keyword(oracle_text, "friends forever")
|
||||
or _contains_keyword(oracle_text, "doctor's companion")
|
||||
)
|
||||
supports_backgrounds = _contains_keyword(oracle_text, "choose a background")
|
||||
is_background = "background" in (type_line.lower() if type_line else "")
|
||||
|
||||
image_small_url = _build_scryfall_url(display_name, "small")
|
||||
image_normal_url = _build_scryfall_url(display_name, "normal")
|
||||
search_haystack = _build_haystack(display_name, type_line, themes, creature_types, keywords, oracle_text)
|
||||
|
||||
color_identity_key = "".join(color_identity) if color_identity else "C"
|
||||
|
||||
return CommanderRecord(
|
||||
name=name,
|
||||
face_name=face_name,
|
||||
display_name=display_name,
|
||||
slug=slug,
|
||||
color_identity=color_identity,
|
||||
color_identity_key=color_identity_key,
|
||||
is_colorless=is_colorless,
|
||||
colors=colors,
|
||||
mana_cost=mana_cost,
|
||||
mana_value=mana_value,
|
||||
type_line=type_line,
|
||||
creature_types=creature_types,
|
||||
oracle_text=oracle_text,
|
||||
power=power,
|
||||
toughness=toughness,
|
||||
keywords=keywords,
|
||||
themes=themes,
|
||||
theme_tokens=theme_tokens,
|
||||
edhrec_rank=edhrec_rank,
|
||||
layout=layout,
|
||||
side=side or None,
|
||||
image_small_url=image_small_url,
|
||||
image_normal_url=image_normal_url,
|
||||
partner_with=partner_with,
|
||||
is_partner=is_partner,
|
||||
supports_backgrounds=supports_backgrounds,
|
||||
is_background=is_background,
|
||||
search_haystack=search_haystack,
|
||||
)
|
||||
|
||||
|
||||
def _clean_str(value: object) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
def _clean_multiline(value: object) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
text = str(value).replace("\r\n", "\n").replace("\r", "\n")
|
||||
return "\n".join(line.rstrip() for line in text.split("\n"))
|
||||
|
||||
|
||||
def _parse_float(value: object) -> Optional[float]:
|
||||
text = _clean_str(value)
|
||||
if not text:
|
||||
return None
|
||||
try:
|
||||
return float(text)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _parse_int(value: object) -> Optional[int]:
|
||||
text = _clean_str(value)
|
||||
if not text:
|
||||
return None
|
||||
try:
|
||||
return int(float(text))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _parse_literal_list(value: object) -> List[str]:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
return [str(v).strip() for v in value if str(v).strip()]
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
return []
|
||||
try:
|
||||
parsed = ast.literal_eval(text)
|
||||
if isinstance(parsed, (list, tuple, set)):
|
||||
return [str(v).strip() for v in parsed if str(v).strip()]
|
||||
except Exception:
|
||||
pass
|
||||
parts = [part.strip() for part in text.replace(";", ",").split(",")]
|
||||
return [part for part in parts if part]
|
||||
|
||||
|
||||
def _split_to_list(value: object) -> List[str]:
|
||||
text = _clean_str(value)
|
||||
if not text:
|
||||
return []
|
||||
parts = [part.strip() for part in text.split(",")]
|
||||
return [part for part in parts if part]
|
||||
|
||||
|
||||
def _extract_partner_with(text: str) -> List[str]:
|
||||
if not text:
|
||||
return []
|
||||
out: List[str] = []
|
||||
for raw_line in text.splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line:
|
||||
continue
|
||||
anchor = "Partner with "
|
||||
if anchor not in line:
|
||||
continue
|
||||
after = line.split(anchor, 1)[1]
|
||||
# Remove reminder text in parentheses and trailing punctuation.
|
||||
target = after.split("(", 1)[0]
|
||||
target = target.replace(" and ", ",")
|
||||
for token in target.split(","):
|
||||
cleaned = token.strip().strip(".")
|
||||
if cleaned:
|
||||
out.append(cleaned)
|
||||
return out
|
||||
|
||||
|
||||
def _contains_keyword(text: str, needle: str) -> bool:
|
||||
if not text:
|
||||
return False
|
||||
return needle.lower() in text.lower()
|
||||
|
||||
|
||||
def _parse_color_identity(value: object) -> Tuple[Tuple[str, ...], bool]:
|
||||
text = _clean_str(value)
|
||||
if not text:
|
||||
return tuple(), True
|
||||
tokens = re.split(r"[\s,&/]+", text)
|
||||
colors: List[str] = []
|
||||
colorless_flag = False
|
||||
for token in tokens:
|
||||
if not token:
|
||||
continue
|
||||
mapped = _COLOR_ALIAS.get(token.upper())
|
||||
if mapped is None:
|
||||
continue
|
||||
if mapped == "C":
|
||||
colorless_flag = True
|
||||
else:
|
||||
if mapped not in colors:
|
||||
colors.append(mapped)
|
||||
ordered = tuple(color for color in _WUBRG_ORDER if color in colors)
|
||||
if ordered:
|
||||
return ordered, False
|
||||
return tuple(), True if colorless_flag or text.upper() in {"C", "COLORLESS"} else False
|
||||
|
||||
|
||||
def _slugify(value: str) -> str:
|
||||
normalized = value.lower().strip()
|
||||
normalized = normalized.replace("+", " plus ")
|
||||
normalized = re.sub(r"[^a-z0-9]+", "-", normalized)
|
||||
normalized = re.sub(r"-+", "-", normalized).strip("-")
|
||||
return normalized or "commander"
|
||||
|
||||
|
||||
def _dedupe_slug(initial: str, existing: Iterable[str]) -> str:
|
||||
base = initial or "commander"
|
||||
if base not in existing:
|
||||
return base
|
||||
counter = 2
|
||||
while f"{base}-{counter}" in existing:
|
||||
counter += 1
|
||||
return f"{base}-{counter}"
|
||||
|
||||
|
||||
def _build_scryfall_url(name: str, version: str) -> str:
|
||||
encoded = quote(name, safe="")
|
||||
return f"{_SCYRFALL_BASE}&version={version}&exact={encoded}"
|
||||
|
||||
|
||||
def _build_haystack(
|
||||
display_name: str,
|
||||
type_line: str,
|
||||
themes: Tuple[str, ...],
|
||||
creature_types: Tuple[str, ...],
|
||||
keywords: Tuple[str, ...],
|
||||
oracle_text: str,
|
||||
) -> str:
|
||||
tokens: List[str] = []
|
||||
tokens.append(display_name.lower())
|
||||
if type_line:
|
||||
tokens.append(type_line.lower())
|
||||
if themes:
|
||||
tokens.extend(theme.lower() for theme in themes)
|
||||
if creature_types:
|
||||
tokens.extend(t.lower() for t in creature_types)
|
||||
if keywords:
|
||||
tokens.extend(k.lower() for k in keywords)
|
||||
if oracle_text:
|
||||
tokens.append(oracle_text.lower())
|
||||
return "|".join(t for t in tokens if t)
|
||||
106
code/web/services/telemetry.py
Normal file
106
code/web/services/telemetry.py
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
__all__ = [
|
||||
"log_commander_page_view",
|
||||
"log_commander_create_deck",
|
||||
]
|
||||
|
||||
_LOGGER = logging.getLogger("web.commander_browser")
|
||||
|
||||
|
||||
def _emit(logger: logging.Logger, payload: Dict[str, Any]) -> None:
|
||||
try:
|
||||
logger.info(json.dumps(payload, separators=(",", ":"), ensure_ascii=False))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _request_id(request: Request) -> str | None:
|
||||
try:
|
||||
rid = getattr(request.state, "request_id", None)
|
||||
if rid:
|
||||
return str(rid)
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _client_ip(request: Request) -> str | None:
|
||||
try:
|
||||
client = getattr(request, "client", None)
|
||||
if client and getattr(client, "host", None):
|
||||
return str(client.host)
|
||||
forwarded = request.headers.get("X-Forwarded-For")
|
||||
if forwarded:
|
||||
return forwarded.split(",")[0].strip()
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _query_snapshot(request: Request) -> Dict[str, Any]:
|
||||
snapshot: Dict[str, Any] = {}
|
||||
try:
|
||||
params = request.query_params
|
||||
items = params.multi_items() if hasattr(params, "multi_items") else params.items()
|
||||
for key, value in items:
|
||||
key = str(key)
|
||||
value = str(value)
|
||||
if key in snapshot:
|
||||
existing = snapshot[key]
|
||||
if isinstance(existing, list):
|
||||
existing.append(value)
|
||||
else:
|
||||
snapshot[key] = [existing, value]
|
||||
else:
|
||||
snapshot[key] = value
|
||||
except Exception:
|
||||
return {}
|
||||
return snapshot
|
||||
|
||||
|
||||
def log_commander_page_view(
|
||||
request: Request,
|
||||
*,
|
||||
page: int,
|
||||
result_total: int,
|
||||
result_count: int,
|
||||
is_htmx: bool,
|
||||
) -> None:
|
||||
payload: Dict[str, Any] = {
|
||||
"event": "commander_browser.page_view",
|
||||
"request_id": _request_id(request),
|
||||
"path": str(request.url.path),
|
||||
"query": _query_snapshot(request),
|
||||
"page": int(page),
|
||||
"result_total": int(result_total),
|
||||
"result_count": int(result_count),
|
||||
"is_htmx": bool(is_htmx),
|
||||
"client_ip": _client_ip(request),
|
||||
}
|
||||
_emit(_LOGGER, payload)
|
||||
|
||||
|
||||
def log_commander_create_deck(
|
||||
request: Request,
|
||||
*,
|
||||
commander: str,
|
||||
return_url: str | None,
|
||||
) -> None:
|
||||
payload: Dict[str, Any] = {
|
||||
"event": "commander_browser.create_deck",
|
||||
"request_id": _request_id(request),
|
||||
"path": str(request.url.path),
|
||||
"query": _query_snapshot(request),
|
||||
"commander": commander,
|
||||
"has_return": bool(return_url),
|
||||
"return_url": return_url,
|
||||
"client_ip": _client_ip(request),
|
||||
}
|
||||
_emit(_LOGGER, payload)
|
||||
|
|
@ -65,7 +65,7 @@
|
|||
--blue-main: #1565c0; /* balanced blue */
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
html,body{height:100%; overflow-x:hidden; max-width:100vw;}
|
||||
html{height:100%; overflow-x:hidden; overflow-y:hidden; max-width:100vw;}
|
||||
body {
|
||||
font-family: system-ui, Arial, sans-serif;
|
||||
margin: 0;
|
||||
|
|
@ -73,8 +73,10 @@ body {
|
|||
background: var(--bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
/* Honor HTML hidden attribute across the app */
|
||||
[hidden] { display: none !important; }
|
||||
|
|
@ -198,6 +200,15 @@ button:hover{ filter:brightness(1.05); }
|
|||
.btn:hover{ filter:brightness(1.05); text-decoration:none; }
|
||||
.btn.disabled, .btn[aria-disabled="true"]{ opacity:.6; cursor:default; pointer-events:none; }
|
||||
label{ display:inline-flex; flex-direction:column; gap:.25rem; margin-right:.75rem; }
|
||||
.color-identity{ display:inline-flex; align-items:center; gap:.35rem; }
|
||||
.color-identity .mana + .mana{ margin-left:4px; }
|
||||
.mana{ display:inline-block; width:16px; height:16px; border-radius:50%; border:1px solid var(--border); box-shadow:0 0 0 1px rgba(0,0,0,.25) inset; }
|
||||
.mana-W{ background:#f9fafb; border-color:#d1d5db; }
|
||||
.mana-U{ background:#3b82f6; border-color:#1d4ed8; }
|
||||
.mana-B{ background:#111827; border-color:#1f2937; }
|
||||
.mana-R{ background:#ef4444; border-color:#b91c1c; }
|
||||
.mana-G{ background:#10b981; border-color:#047857; }
|
||||
.mana-C{ background:#d3d3d3; border-color:#9ca3af; }
|
||||
select,input[type="text"],input[type="number"]{ background: var(--panel); color:var(--text); border:1px solid var(--border); border-radius:6px; padding:.35rem .4rem; }
|
||||
fieldset{ border:1px solid var(--border); border-radius:8px; padding:.75rem; margin:.75rem 0; }
|
||||
small, .muted{ color: var(--muted); }
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta http-equiv="Content-Security-Policy" content="upgrade-insecure-requests" />
|
||||
<title>MTG Deckbuilder</title>
|
||||
<script src="https://unpkg.com/htmx.org@1.9.12" onerror="var s=document.createElement('script');s.src='/static/vendor/htmx-1.9.12.min.js';document.head.appendChild(s);"></script>
|
||||
<script>
|
||||
|
|
@ -81,6 +82,7 @@
|
|||
<a href="/configs">Build from JSON</a>
|
||||
{% if show_setup %}<a href="/setup">Setup/Tag</a>{% endif %}
|
||||
<a href="/owned">Owned Library</a>
|
||||
{% if show_commanders %}<a href="/commanders">Commanders</a>{% endif %}
|
||||
<a href="/decks">Finished Decks</a>
|
||||
<a href="/themes/">Themes</a>
|
||||
{% if random_ui %}<a href="/random">Random</a>{% endif %}
|
||||
|
|
@ -172,6 +174,10 @@
|
|||
#hover-card-panel .hcp-body { display:grid; grid-template-columns: 320px 1fr; gap:18px; align-items:start; }
|
||||
#hover-card-panel .hcp-img-wrap { grid-column:1 / 2; }
|
||||
#hover-card-panel.compact-img .hcp-body { grid-template-columns: 120px 1fr; }
|
||||
#hover-card-panel.hcp-simple { width:auto !important; max-width:min(360px, 90vw) !important; padding:12px !important; height:auto !important; max-height:none !important; overflow:hidden !important; }
|
||||
#hover-card-panel.hcp-simple .hcp-body { display:flex; flex-direction:column; gap:12px; align-items:center; }
|
||||
#hover-card-panel.hcp-simple .hcp-right { display:none !important; }
|
||||
#hover-card-panel.hcp-simple .hcp-img { max-width:100%; }
|
||||
/* Tag list as multi-column list instead of pill chips for readability */
|
||||
#hover-card-panel .hcp-taglist { columns:2; column-gap:18px; font-size:13px; line-height:1.3; margin:6px 0 6px; padding:0; list-style:none; max-height:180px; overflow:auto; }
|
||||
#hover-card-panel .hcp-taglist li { break-inside:avoid; padding:2px 0 2px 0; position:relative; }
|
||||
|
|
@ -195,9 +201,9 @@
|
|||
.list-row .dfc-toggle .icon { font-size:12px; }
|
||||
.list-row .dfc-toggle[data-face='back'] { background:rgba(76,29,149,.3); }
|
||||
.list-row .dfc-toggle[data-face='front'] { background:rgba(56,189,248,.2); }
|
||||
#hover-card-panel.mobile { left:50% !important; top:auto !important; bottom:max(16px, 5vh); transform:translateX(-50%); width:min(92vw, 420px) !important; max-height:80vh; overflow-y:auto; padding:16px 18px; pointer-events:auto !important; }
|
||||
#hover-card-panel.mobile .hcp-body { display:flex; flex-direction:column; gap:18px; }
|
||||
#hover-card-panel.mobile .hcp-img { max-width:100%; margin:0 auto; }
|
||||
#hover-card-panel.mobile { left:50% !important; top:50% !important; bottom:auto !important; transform:translate(-50%, -50%); width:min(94vw, 460px) !important; max-height:88vh; overflow-y:auto; padding:20px 22px; pointer-events:auto !important; }
|
||||
#hover-card-panel.mobile .hcp-body { display:flex; flex-direction:column; gap:20px; }
|
||||
#hover-card-panel.mobile .hcp-img { width:100%; max-width:min(90vw, 420px) !important; margin:0 auto; }
|
||||
#hover-card-panel.mobile .hcp-right { width:100%; display:flex; flex-direction:column; gap:10px; align-items:flex-start; }
|
||||
#hover-card-panel.mobile .hcp-header { flex-wrap:wrap; gap:8px; align-items:flex-start; }
|
||||
#hover-card-panel.mobile .hcp-role { font-size:12px; letter-spacing:.55px; }
|
||||
|
|
@ -923,12 +929,14 @@
|
|||
var panel = ensurePanel();
|
||||
if(!panel || panel.__hoverInit) return;
|
||||
panel.__hoverInit = true;
|
||||
var imgEl = panel.querySelector('.hcp-img');
|
||||
var nameEl = panel.querySelector('.hcp-name');
|
||||
var rarityEl = panel.querySelector('.hcp-rarity');
|
||||
var metaEl = panel.querySelector('.hcp-meta');
|
||||
var reasonsList = panel.querySelector('.hcp-reasons');
|
||||
var tagsEl = panel.querySelector('.hcp-tags');
|
||||
var imgEl = panel.querySelector('.hcp-img');
|
||||
var nameEl = panel.querySelector('.hcp-name');
|
||||
var rarityEl = panel.querySelector('.hcp-rarity');
|
||||
var metaEl = panel.querySelector('.hcp-meta');
|
||||
var reasonsList = panel.querySelector('.hcp-reasons');
|
||||
var tagsEl = panel.querySelector('.hcp-tags');
|
||||
var bodyEl = panel.querySelector('.hcp-body');
|
||||
var rightCol = panel.querySelector('.hcp-right');
|
||||
var coarseQuery = window.matchMedia('(pointer: coarse)');
|
||||
function isMobileMode(){ return (coarseQuery && coarseQuery.matches) || window.innerWidth <= 768; }
|
||||
function refreshPosition(){ if(panel.style.display==='block'){ move(window.__lastPointerEvent); } }
|
||||
|
|
@ -946,12 +954,11 @@
|
|||
function positionPanel(evt){
|
||||
if(isMobileMode()){
|
||||
panel.classList.add('mobile');
|
||||
var bottomOffset = Math.max(16, Math.round(window.innerHeight * 0.05));
|
||||
panel.style.bottom = bottomOffset + 'px';
|
||||
panel.style.bottom = 'auto';
|
||||
panel.style.left = '50%';
|
||||
panel.style.top = 'auto';
|
||||
panel.style.top = '50%';
|
||||
panel.style.right = 'auto';
|
||||
panel.style.transform = 'translateX(-50%)';
|
||||
panel.style.transform = 'translate(-50%, -50%)';
|
||||
panel.style.pointerEvents = 'auto';
|
||||
} else {
|
||||
panel.classList.remove('mobile');
|
||||
|
|
@ -990,6 +997,11 @@
|
|||
if(!card) return;
|
||||
// Prefer attributes on container, fallback to child (image) if missing
|
||||
function attr(name){ return card.getAttribute(name) || (card.querySelector('[data-'+name.slice(5)+']') && card.querySelector('[data-'+name.slice(5)+']').getAttribute(name)) || ''; }
|
||||
var simpleSource = null;
|
||||
if(card.closest){
|
||||
simpleSource = card.closest('[data-hover-simple]');
|
||||
}
|
||||
var forceSimple = (card.hasAttribute && card.hasAttribute('data-hover-simple')) || !!simpleSource;
|
||||
var nm = attr('data-card-name') || attr('data-original-name') || 'Card';
|
||||
var rarity = (attr('data-rarity')||'').trim();
|
||||
var mana = (attr('data-mana')||'').trim();
|
||||
|
|
@ -1110,6 +1122,26 @@
|
|||
}
|
||||
panel.classList.toggle('is-payoff', role === 'payoff');
|
||||
panel.classList.toggle('is-commander', isCommanderRole);
|
||||
var hasDetails = !forceSimple && (
|
||||
!!roleLabel || !!mana || !!rarity || (reasonsRaw && reasonsRaw.trim()) || (overlapArr && overlapArr.length) || (allTags && allTags.length)
|
||||
);
|
||||
panel.classList.toggle('hcp-simple', !hasDetails);
|
||||
if(rightCol){
|
||||
rightCol.style.display = hasDetails ? 'flex' : 'none';
|
||||
}
|
||||
if(bodyEl){
|
||||
if(!hasDetails){
|
||||
bodyEl.style.display = 'flex';
|
||||
bodyEl.style.flexDirection = 'column';
|
||||
bodyEl.style.alignItems = 'center';
|
||||
bodyEl.style.gap = '12px';
|
||||
} else {
|
||||
bodyEl.style.display = '';
|
||||
bodyEl.style.flexDirection = '';
|
||||
bodyEl.style.alignItems = '';
|
||||
bodyEl.style.gap = '';
|
||||
}
|
||||
}
|
||||
var fuzzy = encodeURIComponent(nm);
|
||||
var rawName = nm || '';
|
||||
var hasBack = rawName.indexOf('//')>-1 || (attr('data-original-name')||'').indexOf('//')>-1;
|
||||
|
|
|
|||
|
|
@ -2,12 +2,80 @@
|
|||
{% block banner_subtitle %}Build a Deck{% endblock %}
|
||||
{% block content %}
|
||||
<h2>Build a Deck</h2>
|
||||
<div style="margin:.25rem 0 1rem 0;">
|
||||
<div style="margin:.25rem 0 1rem 0; display:flex; align-items:center; gap:.75rem; flex-wrap:wrap;">
|
||||
<button type="button" class="btn" hx-get="/build/new" hx-target="body" hx-swap="beforeend">Build a New Deck…</button>
|
||||
<span class="muted" style="margin-left:.5rem;">Quick-start wizard (name, commander, themes, ideals)</span>
|
||||
<span class="muted" style="margin-left:.25rem;">Quick-start wizard (name, commander, themes, ideals)</span>
|
||||
{% if return_url %}
|
||||
<a href="{{ return_url }}" class="btn" style="margin-left:auto;">← Back to Commanders</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div id="wizard">
|
||||
<!-- Wizard content will load here after the modal submit starts the build. -->
|
||||
<noscript><p>Enable JavaScript to build a deck.</p></noscript>
|
||||
</div>
|
||||
{% if commander %}
|
||||
<span id="builder-init" data-commander="{{ commander|e }}" hidden></span>
|
||||
<script>
|
||||
(function(){
|
||||
var opened = false;
|
||||
function openWizard(){
|
||||
if(opened) return; opened = true;
|
||||
try{
|
||||
var btn = document.querySelector('button.btn[hx-get="/build/new"]');
|
||||
if(btn){ btn.click(); }
|
||||
}catch(_){ }
|
||||
}
|
||||
// Pre-fill and auto-inspect when the modal content is injected
|
||||
function onModalLoaded(e){
|
||||
try{
|
||||
var target = (e && e.detail && e.detail.target) ? e.detail.target : null; if(!target) return;
|
||||
if(!(target.tagName && target.tagName.toLowerCase() === 'body')) return;
|
||||
var init = document.getElementById('builder-init');
|
||||
var preset = init && init.dataset ? (init.dataset.commander || '') : '';
|
||||
if(!preset) return;
|
||||
var input = document.querySelector('input[name="commander"]');
|
||||
if(input){
|
||||
if(!input.value){ input.value = preset; }
|
||||
try { input.dispatchEvent(new Event('input', {bubbles:true})); } catch(_){ }
|
||||
try { input.focus(); } catch(_){ }
|
||||
}
|
||||
// If htmx is available, auto-load the inspect view for an exact preset name.
|
||||
try {
|
||||
if (window.htmx && preset && typeof window.htmx.ajax === 'function'){
|
||||
window.htmx.ajax('GET', '/build/new/inspect?name=' + encodeURIComponent(preset), { target: '#newdeck-tags-slot', swap: 'innerHTML' });
|
||||
// Also try to load multi-copy suggestions based on current radio defaults
|
||||
setTimeout(function(){
|
||||
try{
|
||||
var mode = document.querySelector('input[name="tag_mode"]') || document.getElementById('modal_tag_mode');
|
||||
var primary = document.getElementById('modal_primary_tag');
|
||||
var secondary = document.getElementById('modal_secondary_tag');
|
||||
var tertiary = document.getElementById('modal_tertiary_tag');
|
||||
var params = new URLSearchParams();
|
||||
params.set('commander', preset);
|
||||
if (primary && primary.value) params.set('primary_tag', primary.value);
|
||||
if (secondary && secondary.value) params.set('secondary_tag', secondary.value);
|
||||
if (tertiary && tertiary.value) params.set('tertiary_tag', tertiary.value);
|
||||
if (mode && mode.value) params.set('tag_mode', mode.value);
|
||||
window.htmx.ajax('GET', '/build/new/multicopy?' + params.toString(), { target: '#newdeck-multicopy-slot', swap: 'innerHTML' });
|
||||
}catch(_){ }
|
||||
}, 250);
|
||||
}
|
||||
} catch(_){ }
|
||||
}catch(_){ }
|
||||
}
|
||||
document.addEventListener('htmx:afterSwap', onModalLoaded);
|
||||
// Open after DOM is ready; try a few hooks to ensure htmx is initialized
|
||||
if (document.readyState === 'complete' || document.readyState === 'interactive') {
|
||||
setTimeout(openWizard, 0);
|
||||
} else {
|
||||
document.addEventListener('DOMContentLoaded', function(){ setTimeout(openWizard, 0); });
|
||||
}
|
||||
if (window.htmx && typeof window.htmx.onLoad === 'function'){
|
||||
window.htmx.onLoad(function(){ setTimeout(openWizard, 0); });
|
||||
}
|
||||
// Last resort: delayed attempt in case previous hooks raced htmx init
|
||||
setTimeout(openWizard, 200);
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
|
|
|||
201
code/web/templates/commanders/index.html
Normal file
201
code/web/templates/commanders/index.html
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<section class="commander-page">
|
||||
<header class="commander-hero">
|
||||
<h2>Commanders</h2>
|
||||
<p class="muted">Browse the catalog and jump straight into a build with your chosen leader.</p>
|
||||
</header>
|
||||
|
||||
<form
|
||||
id="commander-filter-form"
|
||||
class="commander-filters"
|
||||
action="/commanders"
|
||||
method="get"
|
||||
hx-get="/commanders"
|
||||
hx-target="#commander-results"
|
||||
hx-trigger="submit, change from:#commander-color, keyup changed delay:300ms from:#commander-search"
|
||||
hx-include="#commander-filter-form"
|
||||
hx-push-url="true"
|
||||
hx-indicator="#commander-loading"
|
||||
novalidate
|
||||
>
|
||||
<label>
|
||||
<span class="filter-label">Search</span>
|
||||
<input type="search" id="commander-search" name="q" value="{{ query }}" placeholder="Search commanders, themes, or text..." autocomplete="off" />
|
||||
</label>
|
||||
<label>
|
||||
<span class="filter-label">Color identity</span>
|
||||
<select id="commander-color" name="color">
|
||||
<option value="">All colors</option>
|
||||
{% for code, label in color_options %}
|
||||
<option value="{{ code }}" {% if color == code %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<input type="hidden" name="page" value="{{ page }}" />
|
||||
<button type="submit" class="btn filter-submit">Apply</button>
|
||||
</form>
|
||||
|
||||
<div id="commander-loading" class="commander-loading" role="status" aria-live="polite">
|
||||
<span class="sr-only">Loading commanders…</span>
|
||||
<div class="commander-skeleton-list" aria-hidden="true">
|
||||
{% for i in range(3) %}
|
||||
<article class="commander-skeleton">
|
||||
<div class="skeleton-thumb shimmer"></div>
|
||||
<div class="skeleton-main">
|
||||
<div class="skeleton-line skeleton-title shimmer"></div>
|
||||
<div class="skeleton-line skeleton-meta shimmer"></div>
|
||||
<div class="skeleton-chip-row">
|
||||
<span class="skeleton-chip shimmer"></span>
|
||||
<span class="skeleton-chip shimmer"></span>
|
||||
<span class="skeleton-chip shimmer"></span>
|
||||
</div>
|
||||
<div class="skeleton-line skeleton-text shimmer"></div>
|
||||
</div>
|
||||
<div class="skeleton-cta shimmer"></div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="commander-results">
|
||||
{% include "commanders/list_fragment.html" %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.commander-page { display:flex; flex-direction:column; gap:1.25rem; }
|
||||
.commander-hero h2 { margin:0; font-size:1.75rem; }
|
||||
.commander-hero p { margin:0; max-width:60ch; }
|
||||
.commander-filters { display:flex; flex-wrap:wrap; gap:.75rem 1rem; align-items:flex-end; }
|
||||
.commander-filters label { display:flex; flex-direction:column; gap:.35rem; min-width:220px; }
|
||||
.filter-label { font-size:.85rem; color:var(--muted); letter-spacing:.03em; text-transform:uppercase; }
|
||||
.commander-filters input,
|
||||
.commander-filters select { background:var(--panel); color:var(--text); border:1px solid var(--border); border-radius:8px; padding:.45rem .6rem; min-height:2.4rem; }
|
||||
.commander-filters input:focus,
|
||||
.commander-filters select:focus { outline:2px solid var(--ring); outline-offset:2px; }
|
||||
.filter-submit { height:2.4rem; align-self:flex-end; }
|
||||
|
||||
.commander-summary { font-size:.9rem; }
|
||||
.commander-error { padding:.75rem .9rem; border:1px solid #f87171; background:rgba(248,113,113,.12); border-radius:10px; color:#fca5a5; }
|
||||
.commander-empty { margin:1rem 0 0; }
|
||||
.commander-list { display:flex; flex-direction:column; gap:1rem; margin-top:.5rem; }
|
||||
|
||||
.commander-row { display:flex; gap:1rem; padding:1rem; border:1px solid var(--border); border-radius:14px; background:var(--panel); align-items:stretch; }
|
||||
.commander-thumb { width:160px; flex:0 0 auto; }
|
||||
.commander-thumb img { width:160px; height:auto; border-radius:10px; border:1px solid var(--border); background:#0b0d12; display:block; }
|
||||
.commander-main { flex:1 1 auto; display:flex; flex-direction:column; gap:.6rem; min-width:0; }
|
||||
.commander-header { display:flex; flex-wrap:wrap; align-items:center; gap:.5rem .75rem; }
|
||||
.commander-name { margin:0; font-size:1.25rem; }
|
||||
.color-identity { display:flex; align-items:center; gap:.35rem; }
|
||||
.commander-context { margin:0; font-size:.95rem; }
|
||||
.commander-themes { display:flex; flex-wrap:wrap; gap:.4rem; }
|
||||
.commander-themes-empty { font-size:.85rem; }
|
||||
.commander-theme-chip { display:inline-flex; align-items:center; gap:.25rem; padding:4px 10px; border-radius:9999px; border:1px solid var(--border); background:rgba(148,163,184,.15); font-size:.75rem; letter-spacing:.03em; color:inherit; cursor:pointer; transition:background .15s ease, border-color .15s ease, transform .15s ease; appearance:none; font:inherit; }
|
||||
.commander-theme-chip:focus-visible { outline:2px solid var(--ring); outline-offset:2px; }
|
||||
.commander-theme-chip:hover { background:rgba(148,163,184,.25); border-color:rgba(148,163,184,.45); transform:translateY(-1px); }
|
||||
.commander-partners { display:flex; flex-wrap:wrap; gap:.4rem; font-size:.85rem; }
|
||||
.commander-partner-sep { opacity:.6; }
|
||||
.commander-cta { margin-left:auto; display:flex; align-items:center; }
|
||||
.commander-cta .btn { white-space:nowrap; }
|
||||
.commander-pagination { display:flex; align-items:center; justify-content:space-between; gap:.75rem; margin-top:1rem; flex-wrap:wrap; }
|
||||
.commander-summary + .commander-pagination { margin-top:.75rem; }
|
||||
.commander-pagination .pagination-group { display:flex; align-items:center; gap:.5rem; flex-wrap:wrap; }
|
||||
.commander-pagination .commander-page-btn { display:inline-flex; align-items:center; justify-content:center; min-width:96px; }
|
||||
.commander-pagination .commander-page-btn[disabled],
|
||||
.commander-pagination .commander-page-btn.disabled { opacity:.55; cursor:default; pointer-events:none; }
|
||||
.commander-pagination-status { font-size:.85rem; color:var(--muted); }
|
||||
|
||||
.commander-loading { display:none; margin-top:1rem; }
|
||||
.commander-loading.htmx-request { display:block; }
|
||||
.commander-skeleton-list { display:flex; flex-direction:column; gap:1rem; }
|
||||
.commander-skeleton { display:flex; gap:1rem; padding:1rem; border:1px solid var(--border); border-radius:14px; background:var(--panel); align-items:stretch; }
|
||||
.skeleton-thumb { width:160px; height:220px; border-radius:10px; }
|
||||
.skeleton-main { flex:1 1 auto; display:flex; flex-direction:column; gap:.6rem; }
|
||||
.skeleton-line { height:16px; border-radius:9999px; }
|
||||
.skeleton-title { width:45%; height:22px; }
|
||||
.skeleton-meta { width:30%; }
|
||||
.skeleton-text { width:65%; }
|
||||
.skeleton-chip-row { display:flex; gap:.5rem; flex-wrap:wrap; }
|
||||
.skeleton-chip { width:90px; height:22px; border-radius:9999px; display:inline-block; }
|
||||
.skeleton-cta { width:120px; height:42px; border-radius:9999px; }
|
||||
.shimmer { background:linear-gradient(90deg, rgba(148,163,184,0.25) 25%, rgba(148,163,184,0.15) 37%, rgba(148,163,184,0.25) 63%); background-size:400% 100%; animation:commander-shimmer 1.4s ease-in-out infinite; }
|
||||
@keyframes commander-shimmer {
|
||||
0% { background-position:100% 0; }
|
||||
100% { background-position:-100% 0; }
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.commander-row { flex-direction:column; }
|
||||
.commander-thumb img { width:100%; max-width:280px; }
|
||||
.commander-cta { margin-left:0; }
|
||||
.commander-cta .btn { width:100%; justify-content:center; text-align:center; }
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.commander-filters { align-items:stretch; }
|
||||
.filter-submit { width:100%; }
|
||||
.commander-filters label { flex:1 1 100%; min-width:0; }
|
||||
.commander-thumb { width:min(70vw, 220px); align-self:center; }
|
||||
.commander-thumb img { width:100%; }
|
||||
.skeleton-thumb { width:min(70vw, 220px); height:calc(min(70vw, 220px) * 1.4); }
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
(function(){
|
||||
const form = document.getElementById('commander-filter-form');
|
||||
if (!form) return;
|
||||
const pageInput = form.querySelector('input[name="page"]');
|
||||
if (!pageInput) return;
|
||||
|
||||
const resetPage = () => { pageInput.value = '1'; };
|
||||
const searchField = document.getElementById('commander-search');
|
||||
const colorField = document.getElementById('commander-color');
|
||||
if (searchField) searchField.addEventListener('input', resetPage);
|
||||
if (colorField) colorField.addEventListener('change', resetPage);
|
||||
|
||||
const updatePageFromResults = (container) => {
|
||||
if (!container) return;
|
||||
const marker = container.querySelector('[data-current-page]');
|
||||
if (marker) {
|
||||
const current = marker.getAttribute('data-current-page');
|
||||
if (current) pageInput.value = current;
|
||||
}
|
||||
};
|
||||
|
||||
document.body.addEventListener('htmx:afterSwap', (event) => {
|
||||
const target = event.detail && event.detail.target;
|
||||
if (!target || target.id !== 'commander-results') return;
|
||||
updatePageFromResults(target);
|
||||
// Intelligent scroll-to-top: only when triggered from bottom controls or when the summary/top controls are off-screen
|
||||
const container = document.getElementById('commander-results');
|
||||
const searchEl = document.getElementById('commander-search');
|
||||
if (!container) return;
|
||||
const invoker = event.detail && event.detail.elt ? event.detail.elt : null;
|
||||
const fromBottom = invoker && invoker.closest && invoker.closest('[data-bottom-controls]');
|
||||
// If not from bottom, check whether the top of the results is already within view; if so, skip scroll
|
||||
const rect = container.getBoundingClientRect();
|
||||
const topInView = rect.top >= 0 && rect.top <= (window.innerHeight * 0.25);
|
||||
// If we're below the top controls (content's top is above viewport) or the click came from the bottom controls,
|
||||
// jump directly to the search input (no smooth animation) for fastest navigation.
|
||||
if (fromBottom || rect.top < 0) {
|
||||
requestAnimationFrame(() => {
|
||||
if (searchEl) {
|
||||
searchEl.scrollIntoView({ behavior: 'auto', block: 'start' });
|
||||
try { searchEl.focus({ preventScroll: true }); } catch(_) { /* no-op */ }
|
||||
} else {
|
||||
window.scrollTo({ top: 0, behavior: 'auto' });
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!topInView) {
|
||||
requestAnimationFrame(() => {
|
||||
container.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
updatePageFromResults(document.getElementById('commander-results'));
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
38
code/web/templates/commanders/list_fragment.html
Normal file
38
code/web/templates/commanders/list_fragment.html
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<div class="commander-results-inner" data-current-page="{{ page }}">
|
||||
{% if error %}
|
||||
<div class="commander-error" role="alert">{{ error }}</div>
|
||||
{% else %}
|
||||
<div class="commander-summary muted">
|
||||
{% if total_count %}
|
||||
{% if commanders %}
|
||||
Showing {{ page_start }} – {{ page_end }} of {{ result_total }} commander{% if result_total != 1 %}s{% endif %}{% if is_filtered %} (filtered){% endif %}.
|
||||
{% else %}
|
||||
No commanders matched your filters.
|
||||
{% endif %}
|
||||
{% else %}
|
||||
No commander data available.
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if commanders %}
|
||||
{% set pagination_position = 'top' %}
|
||||
{% include "commanders/pagination_controls.html" %}
|
||||
<div class="commander-list" role="list">
|
||||
{% for entry in commanders %}
|
||||
{% include "commanders/row_wireframe.html" %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if page_count > 1 %}
|
||||
{% set pagination_position = 'bottom' %}
|
||||
{% include "commanders/pagination_controls.html" %}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p class="muted commander-empty" role="status">
|
||||
{% if total_count %}
|
||||
No commanders matched your filters.
|
||||
{% else %}
|
||||
Commander catalog is empty.
|
||||
{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
37
code/web/templates/commanders/pagination_controls.html
Normal file
37
code/web/templates/commanders/pagination_controls.html
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<nav class="commander-pagination" role="navigation" aria-label="Commander pagination" {% if pagination_position == 'bottom' %}data-bottom-controls="1"{% endif %}>
|
||||
<div class="pagination-group">
|
||||
<a
|
||||
class="btn ghost commander-page-btn {% if not has_prev %}disabled{% endif %}"
|
||||
{% if has_prev %}
|
||||
href="{{ prev_url }}"
|
||||
hx-get="{{ prev_url }}"
|
||||
hx-target="#commander-results"
|
||||
hx-push-url="true"
|
||||
data-scroll-top-on-swap="1"
|
||||
{% else %}
|
||||
aria-disabled="true"
|
||||
tabindex="-1"
|
||||
{% endif %}
|
||||
>
|
||||
← Previous
|
||||
</a>
|
||||
<span class="commander-pagination-status" aria-live="polite">
|
||||
Page {{ page }} of {{ page_count }}
|
||||
</span>
|
||||
<a
|
||||
class="btn ghost commander-page-btn {% if not has_next %}disabled{% endif %}"
|
||||
{% if has_next %}
|
||||
href="{{ next_url }}"
|
||||
hx-get="{{ next_url }}"
|
||||
hx-target="#commander-results"
|
||||
hx-push-url="true"
|
||||
data-scroll-top-on-swap="1"
|
||||
{% else %}
|
||||
aria-disabled="true"
|
||||
tabindex="-1"
|
||||
{% endif %}
|
||||
>
|
||||
Next →
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
56
code/web/templates/commanders/row_wireframe.html
Normal file
56
code/web/templates/commanders/row_wireframe.html
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
{# Commander row partial fed by CommanderView entries #}
|
||||
{% from "partials/_macros.html" import color_identity %}
|
||||
{% set record = entry.record %}
|
||||
<article class="commander-row" data-commander-slug="{{ record.slug }}" data-hover-simple="true">
|
||||
<div class="commander-thumb">
|
||||
{% set small = record.image_small_url or record.image_normal_url %}
|
||||
<img
|
||||
src="{{ small }}"
|
||||
srcset="{{ small }} 160w, {{ record.image_normal_url or small }} 488w"
|
||||
sizes="160px"
|
||||
alt="{{ record.display_name }} card art"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
data-card-name="{{ record.display_name }}"
|
||||
data-hover-simple="true"
|
||||
/>
|
||||
</div>
|
||||
<div class="commander-main">
|
||||
<div class="commander-header">
|
||||
<h3 class="commander-name">{{ record.display_name }}</h3>
|
||||
{{ color_identity(record.color_identity, record.is_colorless, entry.color_aria_label, entry.color_label) }}
|
||||
</div>
|
||||
<p class="commander-context muted">{{ record.type_line or 'Legendary Creature' }}</p>
|
||||
{% if entry.themes %}
|
||||
<div class="commander-themes" role="list">
|
||||
{% for theme in entry.themes %}
|
||||
{% set summary = theme.summary or 'Summary unavailable' %}
|
||||
<button type="button"
|
||||
class="commander-theme-chip"
|
||||
role="listitem"
|
||||
data-theme-name="{{ theme.name }}"
|
||||
data-theme-slug="{{ theme.slug }}"
|
||||
data-theme-summary="{{ summary }}"
|
||||
title="{{ summary }}"
|
||||
aria-label="{{ theme.name }} theme: {{ summary }}">
|
||||
{{ theme.name }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="commander-themes commander-themes-empty">
|
||||
<span class="muted">No themes linked yet.</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if entry.partner_summary %}
|
||||
<div class="commander-partners muted">
|
||||
{% for note in entry.partner_summary %}
|
||||
<span>{{ note }}</span>{% if not loop.last %}<span aria-hidden="true" class="commander-partner-sep">•</span>{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="commander-cta">
|
||||
<a class="btn" href="/build?commander={{ record.display_name|urlencode }}&return={{ return_url|urlencode }}" data-commander="{{ record.slug }}">Build</a>
|
||||
</div>
|
||||
</article>
|
||||
|
|
@ -66,6 +66,7 @@
|
|||
+ 'SHOW_LOGS='+ (flags.SHOW_LOGS? '1':'0')
|
||||
+ ', SHOW_DIAGNOSTICS='+ (flags.SHOW_DIAGNOSTICS? '1':'0')
|
||||
+ ', SHOW_SETUP='+ (flags.SHOW_SETUP? '1':'0')
|
||||
+ ', SHOW_COMMANDERS='+ (flags.SHOW_COMMANDERS? '1':'0')
|
||||
+ ', RANDOM_MODES='+ (flags.RANDOM_MODES? '1':'0')
|
||||
+ ', RANDOM_UI='+ (flags.RANDOM_UI? '1':'0')
|
||||
+ ', RANDOM_MAX_ATTEMPTS='+ String(flags.RANDOM_MAX_ATTEMPTS ?? '')
|
||||
|
|
|
|||
|
|
@ -4,12 +4,14 @@
|
|||
<div class="actions-grid">
|
||||
<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 %}
|
||||
{% if show_setup %}<a class="action-button" href="/setup">Initial Setup</a>{% endif %}
|
||||
<a class="action-button" href="/owned">Owned Library</a>
|
||||
{% if show_commanders %}<a class="action-button" href="/commanders">Browse Commanders</a>{% endif %}
|
||||
<a class="action-button" href="/decks">Finished Decks</a>
|
||||
<a class="action-button" href="/themes/">Browse Themes</a>
|
||||
{% if random_ui %}<a class="action-button" href="/random">Random Build</a>{% endif %}
|
||||
{% if show_logs %}<a class="action-button" href="/logs">View Logs</a>{% endif %}
|
||||
<a class="action-button" href="/themes/">Browse Themes</a>
|
||||
{% if random_ui %}<a class="action-button" href="/random">Random Build</a>{% endif %}
|
||||
{% if show_diagnostics %}<a class="action-button" href="/diagnostics">Diagnostics</a>{% endif %}
|
||||
{% if show_logs %}<a class="action-button" href="/logs">View Logs</a>{% endif %}
|
||||
</div>
|
||||
<div id="themes-quick" style="margin-top:1rem; font-size:.85rem; color:var(--text-muted);">
|
||||
<span id="themes-quick-status">Themes: …</span>
|
||||
|
|
|
|||
|
|
@ -11,3 +11,19 @@
|
|||
{{ '🔒 Unlock' if locked else '🔓 Lock' }}
|
||||
</button>
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro color_identity(colors, is_colorless=False, aria_label='', title_text='') -%}
|
||||
<div class="color-identity" role="img"
|
||||
aria-label="{{ aria_label }}"
|
||||
data-colorless="{{ '1' if is_colorless or not (colors and colors|length) else '0' }}"
|
||||
{% if title_text %}title="{{ title_text }}"{% endif %}>
|
||||
{% if colors and colors|length %}
|
||||
{% for color in colors %}
|
||||
<span class="mana mana-{{ color }}" aria-hidden="true"></span>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<span class="mana mana-C" aria-hidden="true"></span>
|
||||
{% endif %}
|
||||
<span class="sr-only">{{ aria_label }}</span>
|
||||
</div>
|
||||
{%- endmacro %}
|
||||
|
|
|
|||
6
csv_files/testdata/commander_cards.csv
vendored
6
csv_files/testdata/commander_cards.csv
vendored
|
|
@ -1,3 +1,5 @@
|
|||
name,faceName,edhrecRank,colorIdentity,colors,manaCost,manaValue,type,creatureTypes,text,power,toughness,keywords,themeTags,layout,side
|
||||
Krenko, Mob Boss,,1200,R,R,{2}{R}{R},4,Legendary Creature,['Goblin'],Tap: Create X 1/1 red Goblin tokens.,3,3,,['Goblin Kindred'],normal,
|
||||
Isamaru, Hound of Konda,,2500,W,W,{W},1,Legendary Creature,['Hound'],Legendary creature.,2,2,,['Dog Kindred'],normal,
|
||||
"Krenko, Mob Boss","Krenko, Mob Boss",1200,R,R,{2}{R}{R},4,"Legendary Creature — Goblin","['Goblin']","Tap: Create X 1/1 red Goblin tokens.",3,3,,"['Goblin Kindred']",normal,
|
||||
"Isamaru, Hound of Konda","Isamaru, Hound of Konda",2500,W,W,{W},1,"Legendary Creature — Hound","['Hound']","Legendary creature.",2,2,,"['Dog Kindred']",normal,
|
||||
"Traxos, Scourge of Kroog","Traxos, Scourge of Kroog",3500,Colorless,,{4},4,"Legendary Artifact Creature — Construct","['Construct']","Trample\nTraxos enters the battlefield tapped and doesn't untap during your untap step.\nWhenever you cast a historic spell, untap Traxos.",7,7,Trample,"['Artifacts Matter']",normal,
|
||||
"Atraxa, Praetors' Voice","Atraxa, Praetors' Voice",150,"B, G, U, W","B, G, U, W",{1}{G}{W}{U}{B},4,"Legendary Creature — Phyrexian Angel Horror","['Angel', 'Horror', 'Phyrexian']","Flying, vigilance, deathtouch, lifelink\nAt the beginning of your end step, proliferate.",4,4,"Deathtouch, Flying, Lifelink, Vigilance","['Counters Matter', 'Proliferate']",normal,
|
||||
|
|
|
|||
|
72
docs/commander_catalog.md
Normal file
72
docs/commander_catalog.md
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
# Commander Catalog Onboarding
|
||||
|
||||
The Commander Browser and deck builder both read from `csv_files/commander_cards.csv`. This file is generated during setup and must stay in sync with the fields the web UI expects. Use this guide whenever you need to add a new commander, refresh the dataset, or troubleshoot missing entries.
|
||||
|
||||
## Where the file lives
|
||||
|
||||
- Default path: `csv_files/commander_cards.csv`
|
||||
- Override: set `CSV_FILES_DIR` (env var) before launching the app; the loader resolves `commander_cards.csv` inside that directory.
|
||||
- Caching: the web layer caches the parsed file in process. Restart the app or call `clear_commander_catalog_cache()` in a shell if you edit the CSV while the server is running.
|
||||
|
||||
## Required columns
|
||||
|
||||
The loader normalizes these columns; keep the header names exact. Optional fields can be blank but should still be present.
|
||||
|
||||
| Column | Notes |
|
||||
| --- | --- |
|
||||
| `name` | Printed front name. Used as the fallback display label.
|
||||
| `faceName` | Front face name for MDFCs/split cards. Defaults to `name` when empty.
|
||||
| `side` | Leave blank or `A` for the primary face. Secondary faces become distinct slugs.
|
||||
| `colorIdentity` | WUBRG characters (any casing). `C` marks colorless identities.
|
||||
| `colors` | Printed colors; mainly used for ordering badges.
|
||||
| `manaCost` | Optional but keeps rows sortable in the UI.
|
||||
| `manaValue` | Numeric converted mana cost.
|
||||
| `type` | Full type line (e.g., `Legendary Creature — Phyrexian Angel`).
|
||||
| `creatureTypes` | Python/JSON list or comma-separated string of creature subtypes.
|
||||
| `text` | Oracle text. Enables partner/background detection and hover tooltips.
|
||||
| `power` / `toughness` | Optional stats. Leave blank for non-creatures.
|
||||
| `keywords` | Comma-separated keywords (Flying, Vigilance, …).
|
||||
| `themeTags` | Python/JSON list of curated themes (e.g., `['Angels', 'Life Gain']`).
|
||||
| `edhrecRank` | Optional EDHREC popularity rank (integer).
|
||||
| `layout` | Layout string from MTGJSON (`normal`, `modal_dfc`, etc.).
|
||||
|
||||
Additional columns are preserved but ignored by the browser; feel free to keep upstream metadata.
|
||||
|
||||
## Recommended refresh workflow
|
||||
|
||||
1. Ensure dependencies are installed: `pip install -r requirements.txt`.
|
||||
2. Regenerate the commander CSV using the setup module:
|
||||
```powershell
|
||||
python -c "from file_setup.setup import regenerate_csvs_all; regenerate_csvs_all()"
|
||||
```
|
||||
This downloads the latest MTGJSON card dump (if needed), reapplies commander eligibility rules, and rewrites `commander_cards.csv`.
|
||||
3. (Optional) If you only need a fresh commander list and already have up-to-date `cards.csv`, run:
|
||||
```powershell
|
||||
python -c "from file_setup.setup import determine_commanders; determine_commanders()"
|
||||
```
|
||||
4. Restart the web server (or your desktop app) so the cache reloads the new file.
|
||||
5. Validate with the targeted test:
|
||||
```powershell
|
||||
python -m pytest -q code/tests/test_commander_catalog_loader.py
|
||||
```
|
||||
The test confirms required columns exist, normalization still works, and caching invalidates correctly.
|
||||
|
||||
## Manual edits (quick fixes)
|
||||
|
||||
If you need to hotfix a single row before a full regeneration:
|
||||
|
||||
1. Open the CSV in a UTF-8 aware editor (Excel can re-save with a UTF-8 BOM — prefer a text editor when possible).
|
||||
2. Add or edit the row, ensuring the slug-worthy fields (`name`, `faceName`, `side`) are unique.
|
||||
3. Keep the `themeTags` value as a Python/JSON list (e.g., `['Artifacts']`), or a comma-delimited list without stray quotes.
|
||||
4. Save the file and restart the server so the cache refreshes.
|
||||
5. Backfill the curated themes in `config/themes/` if the new commander should surface dedicated tags.
|
||||
|
||||
> Manual edits are acceptable for emergency fixes but commit regenerated data as soon as possible so automation stays trustworthy.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **`Commander catalog is unavailable` error**: The app could not find the CSV. Verify the file exists under `CSV_FILES_DIR` and has a header row.
|
||||
- **Row missing in the browser**: Ensure the commander passed eligibility (legendary rules) and the row’s `layout`/`side` data is correct. Slug collisions are auto-deduped (`-2`, `-3`, …) but rely on unique `name`+`side` combos.
|
||||
- **Theme chips absent**: Confirm `themeTags` contains at least one value and that the theme slug exists in the theme catalog; otherwise the UI hides the chips.
|
||||
|
||||
For deeper issues, enable verbose logs with `SHOW_LOGS=1` before restarting the web process.
|
||||
Loading…
Add table
Add a link
Reference in a new issue